ファイル

415 行
12 KiB
Python

"""
AI ニジカ常時稼動バッチ
"""
from __future__ import annotations
import asyncio
import json
import os
import random
import subprocess
from asyncio import Lock
from datetime import date, datetime, time, timedelta
from typing import Any, Callable, TypedDict, cast
import nicolib
import queries_to_answers as q2a
from nicolib import VideoInfo
from nizika_ai.config import DB
from nizika_ai.consts import Character, GPTModel, QueryType
from nizika_ai.models import Query
KIRIBAN_VIEWS_COUNTS: list[int] = sorted ({ *range (1_000, 10_000, 1_000),
*range (10_000, 1_000_001, 10_000),
114_514, 1_940, 2_450, 5_100,
19_400, 24_500, 51_000, 93_194, 2_424, 242_424, 1_919,
4_545, 194_245, 245_194, 510_245 },
reverse = True)
kiriban_list: list[tuple[int, VideoInfo, datetime]] = []
watched_videos: set[str] = set ()
lock = Lock ()
async def main (
) -> None:
"""
メーン処理
"""
await asyncio.gather (
queries_to_answers (),
report_kiriban (),
report_nico (),
update_kiriban_list (),
report_snack_time (),
report_hot_spring_time (),
reconnect_db ())
async def queries_to_answers (
) -> None:
"""
クエリ処理
"""
while True:
loop = asyncio.get_running_loop ()
await loop.run_in_executor (None, q2a.main)
await asyncio.sleep (10)
async def report_kiriban (
) -> None:
"""
キリ番祝ひ
"""
while True:
if not kiriban_list:
await wait_until (time (15, 0))
continue
# キリ番祝ひ
async with lock:
(views_count, video_info, uploaded_at) = (
kiriban_list.pop (random.randint (0, len (kiriban_list) - 1)))
video_code = video_info['contentId']
comments = fetch_comments (video_code)
popular_comments = sorted (comments,
key = lambda c: c['nico_count'],
reverse = True)[:10]
latest_comments = sorted (comments,
key = lambda c: c['posted_at'],
reverse = True)[:10]
prompt = (f"{ _format_elapsed (uploaded_at) }前にニコニコに投稿された"
f"{ video_info['title'] }』という動画が{ views_count }再生を突破しました。\n"
f"コメント数は{ len (comments) }件です。\n")
if video_info['tags']:
prompt += f"つけられたタグは「{ '」、「'.join (video_info['tags']) }」です。\n"
if comments:
prompt += f"人気のコメントは次の通りです:「{ '」、「'.join (c['content'] for c in popular_comments) }\n"
if latest_comments != popular_comments:
prompt += f"最新のコメントは次の通りです:「{ '」、「'.join (c['content'] for c in latest_comments) }\n"
prompt += f"""
概要には次のように書かれています:
```html
{ video_info['description'] }
```
このことについて、何かお祝いメッセージを下さい。
ただし、そのメッセージ内には再生数の数値を添えてください。
また、つけられたタグ、コメントからどのような動画か想像し、説明してください。"""
_add_query (prompt, QueryType.KIRIBAN, { 'video_code': video_code })
# 待ち時間計算
dt = datetime.now ()
d = dt.date ()
if dt.hour >= 15:
d += timedelta (days = 1)
remain = max (len (kiriban_list), 1)
td = (datetime.combine (d, time (15, 0)) - dt) / remain
# まれに時刻跨ぎでマイナスになるため
if td.total_seconds () < 0:
td = timedelta (seconds = 0)
await asyncio.sleep (td.total_seconds ())
async def update_kiriban_list (
) -> None:
"""
キリ番リストの更新
"""
while True:
await wait_until (time (15, 0))
new_list = fetch_kiriban_list (datetime.now ().date ())
if not new_list:
continue
async with lock:
have = { k[1]['contentId'] for k in kiriban_list }
for item in new_list:
if item[1]['contentId'] not in have:
kiriban_list.append (item)
have.add (item[1]['contentId'])
def fetch_kiriban_list (
base_date: date,
) -> list[tuple[int, VideoInfo, datetime]]:
"""
キリ番を迎へた動画のリストを取得する.
Parameters
----------
base_date: date
基準日
Return
------
list[tuple[int, VideoInfo, datetime]]
動画リスト(キリ番基準再生数、対象動画情報、投稿日時のタプル)
"""
result = subprocess.run (
['python3', '/root/nizika_nico/get_kiriban_list.py',
str (base_date), *map (str, KIRIBAN_VIEWS_COUNTS)],
cwd = '/root/nizika_nico',
env = os.environ,
capture_output = True,
text = True)
kl: list[list[int | str]]
try:
kl = json.loads (result.stdout)
except Exception:
kl = []
return [(cast (int, k[0]), video_info, str_to_datetime (cast (str, k[2])))
for k in kl
if (video_info := nicolib.fetch_video_info (cast (str, k[1]))) is not None]
def fetch_comments (
video_code: str,
) -> list[CommentDict]:
"""
動画のコメント・リストを取得する.
Parameters
----------
video_code: str
ニコニコの動画コード
Return
------
list[CommentDict]
コメント・リスト
"""
result = subprocess.run (
['python3', 'get_comments_by_video_code.py', video_code],
cwd = '/root/nizika_nico',
env = os.environ,
capture_output = True,
text = True)
rows: list[dict[str, Any]] = json.loads (result.stdout)
comments: list[CommentDict] = []
for row in rows:
row['posted_at'] = str_to_datetime (row['posted_at'])
comments.append (cast (CommentDict, row))
return comments
def fetch_latest_deerjika (
) -> VideoInfo | None:
"""
最新のぼざクリ動画を取得する.
Return
------
VideoInfo | None
動画情報
"""
return nicolib.fetch_latest_video (['伊地知ニジカ',
'ぼざろクリーチャーシリーズ',
'ぼざろクリーチャーシリーズ外伝'])
async def report_nico (
) -> None:
"""
ニコニコから最新のぼざクリを取得し,まだ報知してゐなかったら報知する.
"""
while True:
latest_deerjika = fetch_latest_deerjika ()
if latest_deerjika and latest_deerjika['contentId'] not in watched_videos:
video = latest_deerjika
watched_videos.add (video['contentId'])
prompt = f"""ニコニコに『{ video['title'] }』という動画がアップされました。
つけられたタグは「{ '」、「'.join (video['tags']) }」です。
概要には次のように書かれています:
```html
{ video['description'] }
```
このことについて、みんなに告知するとともに、ニジカちゃんの感想を教えてください。"""
_add_query (prompt, QueryType.NICO_REPORT, { 'video_code': video['contentId'] })
await asyncio.sleep (60)
async def wait_until (
t: time,
) -> None:
"""
指定した時刻まで待つ.
Parameters
----------
t: time
次に実行を続行するまでの時刻
"""
dt = datetime.now ()
d = dt.date ()
if dt.time () >= t:
d += timedelta (days = 1)
await asyncio.sleep ((datetime.combine (d, t) - dt).total_seconds ())
async def report_snack_time (
) -> None:
"""
おやつタイムを報知する.
"""
while True:
await wait_until (time (15, 0))
_add_query ('おやつタイムだ!!!!', QueryType.SNACK_TIME)
async def report_hot_spring_time (
) -> None:
"""
温泉タイムを報知する.
"""
while True:
await wait_until (time (21, 0))
_add_query ('温泉に入ろう!!!', QueryType.HOT_SPRING)
async def reconnect_db (
) -> None:
while True:
await asyncio.sleep (600)
try:
ensure_mysql_alive ()
except Exception as ex:
if getattr (ex, 'args', [None])[0] not in (2006, 2013):
raise
print (f"[reconnect_db] { type (ex).__name__ }: { ex }")
safe_reconnect ()
def ensure_mysql_alive (
) -> None:
conn = DB.connection ('mysql').get_connection ()
conn.ping ()
def safe_reconnect (
) -> None:
try:
DB.reconnect ('mysql')
except Exception as ex:
if getattr (ex, 'args', [None])[0] not in (2006, 2013):
raise
print (f"[safe_reconnect] { type (ex).__name__ }: { ex }")
def run_with_mysql_retry (
fn: Callable[..., Any],
*args,
**kwargs,
) -> Any:
last = None
for _ in range (2):
try:
ensure_mysql_alive ()
return fn (*args, **kwargs)
except Exception as ex:
if getattr (ex, 'args', [None])[0] not in (2006, 2013):
raise
last = ex
print (f"[run_with_mysql_retry] { type (ex).__name__ }: { ex }")
safe_reconnect ()
if last:
raise last
def _add_query (
content: str,
query_type: QueryType,
transfer_data: dict | None = None,
) -> None:
query = Query ()
query.user_id = None
query.target_character = Character.DEERJIKA.value
query.content = content
query.query_type = query_type.value
query.model = GPTModel.GPT4_O.value
query.sent_at = datetime.now ()
query.answered = False
if transfer_data is not None:
query.transfer_data = transfer_data
run_with_mysql_retry (query.save)
def _format_elapsed (
uploaded_at: datetime,
) -> str:
"""
指定した時刻から現在までの時間を見やすぃ文字列に変換する.
Parameters
----------
uploaded_at: datetime
基準日時
Return
------
str
変換後文字列
"""
delta = datetime.now () - uploaded_at
days = delta.days
seconds = delta.seconds
(hours, seconds) = divmod (seconds, 3600)
(mins, seconds) = divmod (seconds, 60)
return f"{ days }{ hours }時間{ mins }{ seconds }"
def str_to_datetime (
s: str,
) -> datetime:
formats: list[str] = [
'%Y-%m-%d %H:%M:%S.%f',
'%Y-%m-%d %H:%M:%S']
for f in formats:
try:
return datetime.strptime (s, f)
except ValueError:
pass
raise ValueError ('うんち!w')
class CommentDict (TypedDict):
id: int
video_id: int
comment_no: int
user_id: int
content: str
posted_at: datetime
nico_count: int
vpos_ms: int
kiriban_list = (
fetch_kiriban_list ((now := datetime.now ()).date ()
- timedelta (days = 1 if now.hour < 15 else 0)))
if __name__ == '__main__':
asyncio.run (main ())