import json, re, subprocess, time, requests from pathlib import Path from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from logger import get_system_logger, log_exception import shutil # --- 配置 --- SESSION_DIR = Path("./session") COOKIE_FILE = Path("./cookies.json") # BILIUP_PATH = Path("./biliup") BILIUP_PATH = shutil.which("biliup") or "./biliup" MAX_RETRIES, BASE_DELAY, POLL_INTERVAL = 5, 180, 10 ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") # 初始化系统日志 logger = get_system_logger("session_top_comment") def strip_ansi(text: str) -> str: return ANSI_ESCAPE.sub("", text or "") class TopCommentClient: def __init__(self): with open(COOKIE_FILE, "r", encoding="utf-8") as f: data = json.load(f) ck = {c["name"]: c["value"] for c in data.get("cookie_info", {}).get("cookies", [])} self.csrf = ck.get("bili_jct") if not self.csrf: raise ValueError("Cookie 中缺少 bili_jct") self.session = requests.Session() self.session.cookies.update(ck) self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.bilibili.com/", "Origin": "https://www.bilibili.com" }) def get_aid(self, bvid): res = self.session.get("https://api.bilibili.com/x/web-interface/view", params={"bvid": bvid}).json() if res.get('code') != 0: raise RuntimeError(f"View API: {res.get('message')}") return res['data']['aid'] def post_and_top(self, aid, msg): # 1. 发表评论 r = self.session.post("https://api.bilibili.com/x/v2/reply/add", data={"type": 1, "oid": aid, "message": msg, "plat": 1, "csrf": self.csrf}).json() if r.get('code') != 0: raise RuntimeError(f"Post API: {r.get('message')}") rpid = r['data']['rpid'] # 2. 等待 3s 数据库同步 logger.info(f"评论已发布(rpid={rpid}),等待 3s 置顶...") time.sleep(3) # 3. 置顶 r = self.session.post("https://api.bilibili.com/x/v2/reply/top", data={"type": 1, "oid": aid, "rpid": rpid, "action": 1, "csrf": self.csrf}).json() if r.get('code') != 0: raise RuntimeError(f"Top API: {r.get('message')}") class CommentManager: def __init__(self): self.client = TopCommentClient() self.pending = {} # {folder_path: {'attempts': 0, 'next_run': 0}} self._cache = {"time": 0, "videos": []} def fetch_videos(self): """调用 biliup list 获取最近上传视频""" if time.time() - self._cache["time"] < 60: return self._cache["videos"] try: res = subprocess.run([str(BILIUP_PATH), "list", "--max-pages", "1"], capture_output=True, text=True, encoding='utf-8') videos = [] for line in strip_ansi(res.stdout).splitlines(): if line.startswith("BV"): parts = line.split("\t") if len(parts) >= 2: videos.append({"bvid": parts[0].strip(), "title": parts[1].strip()}) self._cache = {"time": time.time(), "videos": videos} return videos except Exception as e: logger.error(f"biliup list 失败: {e}") return [] def scan_and_add(self, folder: Path): if not folder.is_dir() or (folder / "comment_done.flag").exists(): return if (folder / "songs.txt").exists() and folder not in self.pending: logger.info(f"发现待处理任务: {folder.name}") self.pending[folder] = {'attempts': 0, 'next_run': 0} def process_queue(self): now = time.time() for folder in list(self.pending.keys()): task = self.pending[folder] if task['next_run'] > now: continue try: # 1. 查找匹配视频 videos = self.fetch_videos() # 模糊匹配:文件夹名包含在视频标题中,或视频标题包含在文件夹名中 matched_bvid = next((v['bvid'] for v in videos if folder.name in v['title'] or v['title'] in folder.name), None) # 如果没找到,也尝试从文件夹名提取 [BV...] if not matched_bvid: bv_match = re.search(r"\[(BV[0-9A-Za-z]+)\]", folder.name) if bv_match: matched_bvid = bv_match.group(1) if not matched_bvid: raise RuntimeError("未在最近上传列表中找到匹配视频") # 2. 读取内容 content = (folder / "songs.txt").read_text(encoding="utf-8").strip() if not content: logger.warning(f"songs.txt 内容为空,取消任务: {folder.name}") self.pending.pop(folder); continue # 3. 执行发布和置顶 aid = self.client.get_aid(matched_bvid) self.client.post_and_top(aid, content) # 4. 成功标记 (folder / "comment_done.flag").touch() logger.info(f"任务完成: {folder.name} -> {matched_bvid}") self.pending.pop(folder) except Exception as e: task['attempts'] += 1 if task['attempts'] >= MAX_RETRIES: logger.error(f"任务最终失败: {folder.name} - {e}") self.pending.pop(folder) else: delay = BASE_DELAY * (2 ** (task['attempts']-1)) task['next_run'] = now + delay logger.warning(f"任务推迟({task['attempts']}/{MAX_RETRIES}): {folder.name} - {e}. {delay}s 后重试") def main(): logger.info("="*50) logger.info("置顶评论模块启动") logger.info("="*50) try: mgr = CommentManager() except Exception as e: logger.error(f"初始化失败: {e}") return # 1. 初始扫描 for f in SESSION_DIR.iterdir(): mgr.scan_and_add(f) # 2. 启动 Watchdog class Handler(FileSystemEventHandler): def on_created(self, event): p = Path(event.src_path) if p.name == "songs.txt": mgr.scan_and_add(p.parent) observer = Observer() observer.schedule(Handler(), str(SESSION_DIR), recursive=True) observer.start() logger.info(f"开始监控目录: {SESSION_DIR}") try: while True: mgr.process_queue() time.sleep(POLL_INTERVAL) except KeyboardInterrupt: observer.stop() observer.join() logger.info("置顶评论模块已停止") if __name__ == "__main__": main()