ニジカのスカトロ,ニジカトロ. https://bsky.app/profile/deerjika-bot.bsky.social
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

main.py 13 KiB

3 weeks ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. """
  2. Bluesky のニジカがいろいろする.
  3. (近々機能ごとにファイル分けて systemd でイベント管理する予定)
  4. """
  5. import io
  6. import random
  7. import sys
  8. import time
  9. from datetime import date, datetime
  10. from datetime import time as dt_time
  11. from datetime import timedelta
  12. from typing import cast
  13. import requests
  14. from atproto import Client, models
  15. from bs4 import BeautifulSoup
  16. from requests.exceptions import Timeout
  17. import account
  18. import nico
  19. from ai.talk import Talk
  20. def check_notifications (
  21. client: Client,
  22. ) -> list:
  23. (uris, last_seen_at) = ([], client.get_current_time_iso ())
  24. for notification in (client.app.bsky.notification.list_notifications ()
  25. .notifications):
  26. if not notification.is_read:
  27. if notification.reason in ['mention', 'reply', 'quote']:
  28. uris += [notification.uri]
  29. elif notification.reason == 'follow':
  30. client.follow (notification.author.did)
  31. client.app.bsky.notification.update_seen ({ 'seen_at': last_seen_at })
  32. return uris
  33. def get_thread_contents (
  34. client: Client,
  35. uri: str,
  36. parent_height: int,
  37. ) -> list:
  38. response = (client.get_post_thread (uri = uri,
  39. parent_height = parent_height)
  40. .thread)
  41. records = []
  42. while response is not None:
  43. records += [{ 'strong_ref': models.create_strong_ref (response.post),
  44. 'did': response.post.author.did,
  45. 'handle': response.post.author.handle,
  46. 'name': response.post.author.display_name,
  47. 'datetime': response.post.record.created_at,
  48. 'text': response.post.record.text,
  49. 'embed': response.post.record.embed }]
  50. response = response.parent
  51. return records
  52. def main (
  53. ) -> None:
  54. time.sleep (60)
  55. client = Client (base_url = 'https://bsky.social')
  56. client.login (account.USER_ID, account.PASSWORD)
  57. got_kiriban_at: date = datetime.now ().date () - timedelta (days = datetime.now ().hour < 15)
  58. kiriban_list: list[tuple[int, nico.VideoInfo, datetime]] = (
  59. nico.get_kiriban_list (got_kiriban_at))
  60. kiriban_interval: timedelta = ((get_kiriban_dt_to_update () - datetime.now ())
  61. / len (kiriban_list))
  62. next_kiriban_at = datetime.now ()
  63. last_posted_at = datetime.now () - timedelta (hours = 6)
  64. has_got_snack_time = False
  65. has_taken_hot_spring = False
  66. watched_videos = []
  67. while True:
  68. now = datetime.now ()
  69. for uri in check_notifications (client):
  70. records = get_thread_contents (client, uri, 20)
  71. if len (records) > 0:
  72. answer = Talk.main ((records[0]['text']
  73. if (records[0]['embed'] is None
  74. or not hasattr (records[0]['embed'],
  75. 'images'))
  76. else [
  77. { 'type': 'text', 'text': records[0]['text'] },
  78. { 'type': 'image_url', 'image_url': {
  79. 'url': f"https://cdn.bsky.app/img/feed_fullsize/plain/{ records[0]['did'] }/{ records[0]['embed'].images[0].image.ref.link }" } }]),
  80. records[0]['name'],
  81. [*map (lambda record: {
  82. 'role': ('assistant'
  83. if (record['handle']
  84. == account.USER_ID)
  85. else 'user'),
  86. 'content':
  87. record['text']},
  88. reversed (records[1:]))])
  89. client.post (answer,
  90. reply_to = models.AppBskyFeedPost.ReplyRef (
  91. parent = records[0]['strong_ref'],
  92. root = records[-1]['strong_ref']))
  93. if kiriban_list and datetime.now () >= next_kiriban_at:
  94. (views_count, video_info, uploaded_at) = (
  95. kiriban_list.pop (random.randint (0, len (kiriban_list) - 1)))
  96. since_posted = datetime.now () - uploaded_at
  97. uri = f"https://www.nicovideo.jp/watch/{ video_info['contentId'] }"
  98. (title, description, thumbnail) = get_embed_info (uri)
  99. try:
  100. upload = client.com.atproto.repo.upload_blob (
  101. io.BytesIO (requests.get (thumbnail,
  102. timeout = 60).content))
  103. thumb = upload.blob
  104. except Timeout:
  105. thumb = None
  106. comments = nico.get_comments (video_info['contentId'])
  107. popular_comments = sorted (comments,
  108. key = lambda c: c.nico_count,
  109. reverse = True)[:10]
  110. latest_comments = sorted (comments,
  111. key = lambda c: c.posted_at,
  112. reverse = True)[:10]
  113. embed_external = models.AppBskyEmbedExternal.Main (
  114. external = models.AppBskyEmbedExternal.External (
  115. title = title,
  116. description = description,
  117. thumb = thumb,
  118. uri = uri))
  119. prompt = f"{ since_posted.days }日と{ since_posted.seconds }秒前にニコニコに投稿された『{ video_info['title'] }』という動画が{ views_count }再生を突破しました。\n"
  120. if video_info['tags']:
  121. prompt += f"つけられたタグは「{ '」、「'.join (video_info['tags']) }」です。\n"
  122. if comments:
  123. prompt += f"人気のコメントは次の通りです:「{ '」、「'.join (c.content for c in popular_comments) }」\n"
  124. prompt += f"最新のコメントは次の通りです:「{ '」、「'.join (c.content for c in latest_comments) }」\n"
  125. prompt += f"""
  126. 概要には次のように書かれています:
  127. ```html
  128. { video_info['description'] }
  129. ```
  130. このことについて、ニジカちゃんからのお祝いメッセージを下さい。
  131. ただし、そのメッセージ内には再生数の数値とその多さに応じたリアクション(「すっご~い!」や「しょぼ~い」など)を添えてください。"""
  132. client.post (Talk.main (prompt), embed = embed_external)
  133. next_kiriban_at += kiriban_interval
  134. last_posted_at = now
  135. latest_deerjika = nico.get_latest_deerjika ()
  136. if latest_deerjika is not None:
  137. for datum in [e for e in [latest_deerjika]
  138. if e['contentId'] not in watched_videos]:
  139. watched_videos += [datum['contentId']]
  140. uri = f"https://www.nicovideo.jp/watch/{ datum['contentId'] }"
  141. (title, description, thumbnail) = get_embed_info (uri)
  142. try:
  143. upload = client.com.atproto.repo.upload_blob (
  144. io.BytesIO (requests.get (thumbnail,
  145. timeout = 60).content))
  146. thumb = upload.blob
  147. except Timeout:
  148. thumb = None
  149. embed_external = models.AppBskyEmbedExternal.Main (
  150. external = models.AppBskyEmbedExternal.External (
  151. title = title,
  152. description = description,
  153. thumb = thumb,
  154. uri = uri))
  155. client.post (Talk.main (f"""
  156. ニコニコに『{ datum['title'] }』という動画がアップされました。
  157. つけられたタグは「{ '」、「'.join (datum['tags']) }」です。
  158. 概要には次のように書かれています:
  159. ```html
  160. { datum['description'] }
  161. ```
  162. このことについて、みんなに告知するとともに、ニジカちゃんの感想を教えてください。 """),
  163. embed = embed_external)
  164. last_posted_at = now
  165. if now.hour == 14 and has_got_snack_time:
  166. has_got_snack_time = False
  167. if now.hour == 15:
  168. if got_kiriban_at < datetime.now ().date ():
  169. kiriban_list = nico.get_kiriban_list (datetime.now ().date ())
  170. got_kiriban_at = datetime.now ().date ()
  171. kiriban_interval = ((get_kiriban_dt_to_update () - datetime.now ())
  172. / len (kiriban_list))
  173. next_kiriban_at = datetime.now ()
  174. if not has_got_snack_time:
  175. try:
  176. with open ('./assets/snack-time.jpg', 'rb') as f:
  177. image = models.AppBskyEmbedImages.Image (
  178. alt = ('左に喜多ちゃん、右に人面鹿のニジカが'
  179. 'V字に並んでいる。'
  180. '喜多ちゃんは右手でピースサインをして'
  181. '片目をウインクしている。'
  182. 'ニジカは両手を広げ、'
  183. '右手にスプーンを持って'
  184. 'ポーズを取っている。'
  185. '背景には'
  186. '赤と黄色の放射線状の模様が広がり、'
  187. '下部に「おやつタイムだ!!!!」という'
  188. '日本語のテキストが表示されている。'),
  189. image = client.com.atproto.repo.upload_blob (f).blob)
  190. client.post (Talk.main ('おやつタイムだ!!!!'),
  191. embed = models.app.bsky.embed.images.Main (
  192. images = [image]))
  193. last_posted_at = now
  194. except Exception:
  195. pass
  196. has_got_snack_time = True
  197. if now.hour == 20 and has_taken_hot_spring:
  198. has_taken_hot_spring = False
  199. if now.hour == 21 and not has_taken_hot_spring:
  200. try:
  201. with open ('./assets/hot-spring.jpg', 'rb') as f:
  202. image = models.AppBskyEmbedImages.Image (
  203. alt = ('左に喜多ちゃん、右にわさび県産滋賀県が'
  204. 'V字に並んでいる。'
  205. '喜多ちゃんは右手でピースサインをして'
  206. '片目をウインクしている。'
  207. 'わさび県産滋賀県はただ茫然と'
  208. '立ち尽くしている。'
  209. '背景には'
  210. '血と空の色をした放射線状の模様が広がり、'
  211. '下部に「温泉に入ろう!!!」という'
  212. '日本語のテキストが表示されている。'),
  213. image = client.com.atproto.repo.upload_blob (f).blob)
  214. client.post (Talk.main ('温泉に入ろう!!!'),
  215. embed = models.app.bsky.embed.images.Main (
  216. images = [image]))
  217. last_posted_at = now
  218. except Exception:
  219. pass
  220. has_taken_hot_spring = True
  221. if now - last_posted_at >= timedelta (hours = 6):
  222. client.post (Talk.main ('今どうしてる?'))
  223. last_posted_at = now
  224. time.sleep (60)
  225. def get_embed_info (
  226. url: str
  227. ) -> tuple[str, str, str]:
  228. title: str = ''
  229. description: str = ''
  230. thumbnail: str = ''
  231. try:
  232. res = requests.get (url, timeout = 60)
  233. except Timeout:
  234. return ('', '', '')
  235. if res.status_code != 200:
  236. return ('', '', '')
  237. soup = BeautifulSoup (res.text, 'html.parser')
  238. tmp = soup.find ('title')
  239. if tmp is not None:
  240. title = tmp.text
  241. tmp = soup.find ('meta', attrs = { 'name': 'description' })
  242. if tmp is not None and hasattr (tmp, 'get'):
  243. try:
  244. description = cast (str, tmp.get ('content'))
  245. except Exception:
  246. pass
  247. tmp = soup.find ('meta', attrs = { 'name': 'thumbnail' })
  248. if tmp is not None and hasattr (tmp, 'get'):
  249. try:
  250. thumbnail = cast (str, tmp.get ('content'))
  251. except Exception:
  252. pass
  253. return (title, description, thumbnail)
  254. def get_kiriban_dt_to_update (
  255. ) -> datetime:
  256. now = datetime.now ()
  257. today = now.date ()
  258. dt = datetime.combine (today, dt_time (15, 0))
  259. if dt <= now:
  260. dt += timedelta (days = 1)
  261. return dt
  262. if __name__ == '__main__':
  263. main (*sys.argv[1:])