ニジカのスカトロ,ニジカトロ. 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.

351 lines
15 KiB

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