166 lines
6.7 KiB
Python
166 lines
6.7 KiB
Python
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()
|