feat(ingest): db-only library ingest and telegram manual ingest

This commit is contained in:
kairee committed 2026-11-01 13:50:03 +08:00
1 parent d2adb65e08
commit 62abbd7ea1
6 files changed
+865 -27

No files matched your search

+39 -2
View File
@@ -53,6 +53,43 @@ def format_duration(seconds: int) -> str:
return f"{minutes}:{secs:02d}" return f"{minutes}:{secs:02d}"
def _result_line(result: Any) -> str:
state = getattr(result, "state", "")
title = html.escape(str(getattr(result, "title", "") or ""))
message = str(getattr(result, "message", "") or "")
aid = getattr(result, "aid", None)
suffix = f"《{title}》" if title else ""
ref = f"(aid {aid})" if aid else ""
icon = {
"added": "✅", "recovered": "♻️", "exists": "ℹ️",
"error": "⚠️", "missing": "❔",
}.get(state, "•")
body = message or "完成"
return f"{icon} {body}{suffix}{ref}".strip()
def format_ingest_result(result: Any) -> str:
"""候选确认后的回执(候选确认 / 手动入库单条)。"""
return _result_line(result)
def format_manual_reply(manual_result: Any) -> str:
"""私聊文本手动入库的汇总回执。"""
refs = int(getattr(manual_result, "refs", 0) or 0)
if refs <= 0:
return "未识别到 B 站视频引用,请发送视频链接或 BV 号,例如 BV1x8x166EzY。"
results = list(getattr(manual_result, "results", []) or [])
lines = [_result_line(item) for item in results]
header = f"共识别 {refs} 个引用,入库 {int(getattr(manual_result, 'added', 0) or 0)} 条:"
return "\n".join([header, *lines])
HELP_TEXT = (
"发送 B 站视频链接、BV 号或 av 号即可手动入库并开始追踪。\n"
"例如:https://www.bilibili.com/video/BV1x8x166EzY 或 BV1x8x166EzY"
)
def format_candidate(candidate: Mapping[str, Any]) -> str: def format_candidate(candidate: Mapping[str, Any]) -> str:
title = html.escape(str(candidate.get("title") or "(无标题)")) title = html.escape(str(candidate.get("title") or "(无标题)"))
up_name = html.escape(str(candidate.get("up_name") or "未知UP")) up_name = html.escape(str(candidate.get("up_name") or "未知UP"))
@@ -184,7 +221,7 @@ class TelegramClient:
keyboard = { keyboard = {
"inline_keyboard": [ "inline_keyboard": [
[ [
{"text": "⭐ 收藏", "callback_data": f"fav:{candidate['aid']}"}, {"text": "📥 入库", "callback_data": f"add:{candidate['aid']}"},
{"text": "🙈 忽略", "callback_data": f"ign:{candidate['aid']}"}, {"text": "🙈 忽略", "callback_data": f"ign:{candidate['aid']}"},
{"text": "🚫 拉黑", "callback_data": f"blk:{candidate['aid']}"}, {"text": "🚫 拉黑", "callback_data": f"blk:{candidate['aid']}"},
] ]
@@ -199,7 +236,7 @@ class TelegramClient:
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
params: dict[str, Any] = { params: dict[str, Any] = {
"timeout": self.poll_timeout if timeout is None else int(timeout), "timeout": self.poll_timeout if timeout is None else int(timeout),
"allowed_updates": json.dumps(["callback_query"]), "allowed_updates": json.dumps(["callback_query", "message"]),
} }
if offset is not None: if offset is not None:
params["offset"] = int(offset) params["offset"] = int(offset)
+89
View File
@@ -24,6 +24,17 @@ class UpsertOutcome:
moved_folder: bool moved_folder: bool
@dataclass(frozen=True)
class IngestOutcome:
"""入库(候选确认或手动)结果。"""
aid: int
status: str
previous_status: str | None
created: bool
recovered: bool
@dataclass(frozen=True) @dataclass(frozen=True)
class FolderRow: class FolderRow:
media_id: int media_id: int
@@ -399,6 +410,84 @@ class Repository:
return [dict(row) for row in rows] return [dict(row) for row in rows]
return self.db.read(run) return self.db.read(run)
def ingest_video(
self,
*,
aid: int,
bvid: str | None,
title: str,
up_mid: int | None,
up_name: str | None,
pubdate: int,
duration: int | None = None,
pages: int | None = None,
cover: str | None = None,
source: str = "manual",
now: int | None = None,
) -> IngestOutcome:
"""把一条视频写入库并置为 ``active``(候选确认 / Telegram 手动入库共用)。
不写 B 站收藏夹、不设置 ``folder_id``;同时删除同 aid 的候选行。
既有的 ``removed`` / ``dropped`` / ``unfaved`` 状态都会被重新激活。
"""
now = self._now() if now is None else int(now)
aid = int(aid)
def run(conn: sqlite3.Connection) -> IngestOutcome:
previous = conn.execute(
"SELECT status FROM video WHERE aid = ?", (aid,)
).fetchone()
previous_status = str(previous["status"]) if previous else None
conn.execute(
"""
INSERT INTO video(aid, bvid, title, up_mid, up_name, pubdate,
duration, pages, cover, folder_id, fav_time, status,
source, first_seen, last_sync, attr)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0)
ON CONFLICT(aid) DO UPDATE SET
bvid = COALESCE(excluded.bvid, video.bvid),
title = CASE
WHEN excluded.title != '' THEN excluded.title
ELSE video.title
END,
up_mid = COALESCE(excluded.up_mid, video.up_mid),
up_name = COALESCE(excluded.up_name, video.up_name),
pubdate = CASE
WHEN excluded.pubdate > 0 THEN excluded.pubdate
ELSE video.pubdate
END,
duration = COALESCE(excluded.duration, video.duration),
pages = COALESCE(excluded.pages, video.pages),
cover = COALESCE(excluded.cover, video.cover),
status = 'active',
source = COALESCE(video.source, excluded.source),
last_sync = excluded.last_sync
""",
(
aid,
bvid,
title or "(无标题)",
up_mid,
up_name,
int(pubdate or 0),
None if duration is None else int(duration),
None if pages is None else int(pages),
cover,
source,
now,
now,
),
)
conn.execute("DELETE FROM candidate WHERE aid = ?", (aid,))
return IngestOutcome(
aid=aid,
status="active",
previous_status=previous_status,
created=previous is None,
recovered=previous_status in {"removed", "dropped", "unfaved"},
)
return self.db.write(run)
# ------------------------------------------------------- up profile # ------------------------------------------------------- up profile
def apply_up_follower( def apply_up_follower(
+322
View File
@@ -0,0 +1,322 @@
# -*- coding: utf-8 -*-
"""库内视频管理:入库(候选确认 / Telegram 手动)、忽略、拉黑。
入库只写本地 ``video`` 表并置为 ``active``,**不再调用 B 站收藏夹写接口**
(``deal`` / ``sort``)。收藏夹由人工维护,系统仅通过 ``favsort.py`` 做重排序。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from ..bilibili.client import BilibiliError, BilibiliProtocolError
from ..config import ConfigStore
from ..repository import Repository
from ..timeutil import shanghai_date
from ..video_ref import DEFAULT_MAX_REFS, VideoRef, extract_video_refs
@dataclass
class IngestResult:
ok: bool
aid: int | None
state: str
title: str = ""
up_name: str = ""
message: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"ok": self.ok,
"aid": self.aid,
"state": self.state,
"title": self.title,
"up_name": self.up_name,
"message": self.message,
}
@dataclass
class ManualIngestResult:
refs: int = 0
added: int = 0
results: list[IngestResult] = field(default_factory=list)
@property
def ok(self) -> bool:
return any(result.ok for result in self.results)
def as_dict(self) -> dict[str, Any]:
return {
"refs": self.refs,
"added": self.added,
"ok": self.ok,
"results": [result.as_dict() for result in self.results],
}
@dataclass
class ActionResult:
ok: bool
aid: int
action: str
state: str
message: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"ok": self.ok,
"aid": self.aid,
"action": self.action,
"state": self.state,
"message": self.message,
}
class LibraryService:
def __init__(
self,
config_store: ConfigStore,
repository: Repository,
read_provider,
*,
up_profile_service=None,
clock=time.time,
logger=None,
max_refs: int = DEFAULT_MAX_REFS,
) -> None:
self.config_store = config_store
self.repo = repository
self.read_provider = read_provider
self.up_profile = up_profile_service
self._clock = clock
self.logger = logger
self.max_refs = max(1, int(max_refs))
def _now(self) -> int:
return int(self._clock())
def _log(self, level: str, event: str, **fields) -> None:
if self.logger is not None:
getattr(self.logger, level)(event, **fields)
# ------------------------------------------------------------ candidate
def ingest_candidate(self, aid: int) -> IngestResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
video = self.repo.get_video(aid)
if video is not None:
return IngestResult(
ok=True, aid=aid, state="exists",
title=str(video.get("title") or ""),
up_name=str(video.get("up_name") or ""),
message="已在库,无需重复入库",
)
return IngestResult(
ok=False, aid=aid, state="missing", message="候选不存在或已处理"
)
detail = None
try:
detail = self.read_provider.get().view_detail(aid=aid)
except BilibiliError as exc: # 网络失败时用候选元数据兜底
self._log("warning", "ingest.detail_failed", aid=aid, error=str(exc))
if detail is not None:
outcome = self.repo.ingest_video(
aid=detail.aid,
bvid=detail.bvid,
title=detail.title,
up_mid=detail.up_mid,
up_name=detail.up_name,
pubdate=detail.pubdate,
duration=detail.duration,
pages=detail.pages,
cover=detail.cover,
source="candidate:telegram",
now=self._now(),
)
self._write_snapshot(detail)
result = self._result_from_outcome(
outcome, title=detail.title, up_name=detail.up_name or ""
)
up_mid = detail.up_mid
up_name = detail.up_name or ""
else:
pubdate = int(candidate.get("pubdate") or 0)
duration = int(candidate.get("duration") or 0) or None
outcome = self.repo.ingest_video(
aid=aid,
bvid=candidate.get("bvid"),
title=str(candidate.get("title") or ""),
up_mid=candidate.get("up_mid"),
up_name=candidate.get("up_name"),
pubdate=pubdate,
duration=duration,
cover=candidate.get("cover"),
source="candidate:telegram",
now=self._now(),
)
result = self._result_from_outcome(
outcome,
title=str(candidate.get("title") or ""),
up_name=str(candidate.get("up_name") or ""),
)
up_mid = candidate.get("up_mid")
up_name = str(candidate.get("up_name") or "")
self._post_ingest([(up_mid, up_name)])
self._log(
"info", "ingest.candidate",
aid=aid, state=result.state, folder=None,
)
return result
# ------------------------------------------------------------ manual
def ingest_text(self, text: object) -> ManualIngestResult:
refs = extract_video_refs(text, limit=self.max_refs)
result = ManualIngestResult(refs=len(refs))
if not refs:
return result
client = self.read_provider.get()
ups: list[tuple[int | None, str]] = []
for ref in refs:
try:
detail = self._resolve_ref(client, ref)
except BilibiliError as exc:
result.results.append(IngestResult(
ok=False, aid=None, state="error", message=str(exc),
))
self._log("warning", "ingest.manual_failed", error=str(exc))
continue
outcome = self.repo.ingest_video(
aid=detail.aid,
bvid=detail.bvid,
title=detail.title,
up_mid=detail.up_mid,
up_name=detail.up_name,
pubdate=detail.pubdate,
duration=detail.duration,
pages=detail.pages,
cover=detail.cover,
source="manual:telegram",
now=self._now(),
)
self._write_snapshot(detail)
item = self._result_from_outcome(
outcome, title=detail.title, up_name=detail.up_name or ""
)
if item.state in ("added", "recovered"):
result.added += 1
result.results.append(item)
ups.append((detail.up_mid, detail.up_name or ""))
if ups:
self._post_ingest(ups)
self._log("info", "ingest.manual", **result.as_dict())
return result
def _resolve_ref(self, client, ref: VideoRef):
if ref.kind == "bvid":
return client.view_detail(bvid=str(ref.value))
if ref.kind == "aid":
return client.view_detail(aid=int(ref.value))
if ref.kind == "short":
final = client.resolve_short_link(f"https://b23.tv/{ref.value}")
for sub in extract_video_refs(final):
if sub.kind != "short":
return self._resolve_ref(client, sub)
raise BilibiliProtocolError(f"短链未解析出视频引用: {final}")
raise ValueError(f"未知引用类型: {ref.kind}")
# ------------------------------------------------------------ actions
def ignore(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="ignore", state="missing",
message="候选不存在或已处理",
)
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="ignore", state="rejected", message="已忽略",
)
def blacklist(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="blacklist", state="missing",
message="候选不存在或已处理",
)
up_mid = candidate.get("up_mid")
if not isinstance(up_mid, int) or isinstance(up_mid, bool):
return ActionResult(
ok=False, aid=aid, action="blacklist", state="failed",
message="候选缺少 UP mid,无法拉黑",
)
self.repo.add_blacklist(
up_mid,
name=candidate.get("up_name"),
reason="telegram",
now=self._now(),
)
rejected = self.repo.reject_pending_for_up(up_mid, now=self._now())
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="blacklist", state="rejected",
message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选",
)
# ------------------------------------------------------------ internals
def _result_from_outcome(self, outcome, *, title: str, up_name: str) -> IngestResult:
if outcome.created:
state, message = "added", "已入库"
elif outcome.recovered:
state, message = "recovered", "已重新入库"
else:
state, message = "exists", "已在库"
return IngestResult(
ok=True, aid=outcome.aid, state=state,
title=title, up_name=up_name, message=message,
)
def _write_snapshot(self, detail) -> None:
"""用已取到的 ``view`` 数据立即写一条当天快照(同日已有成功记录则跳过)。"""
now = self._now()
stat_date = shanghai_date(now)
existing = self.repo.get_snapshot(stat_date, detail.aid)
if existing is not None and int(existing.get("ok") or 0) == 1:
return
self.repo.upsert_snapshot(
stat_date,
detail.aid,
metrics=detail.metrics(),
ok=True,
err=None,
captured_at=now,
)
def _post_ingest(self, ups: list[tuple[int | None, str]]) -> None:
if ups and self.up_profile is not None:
mids = [mid for mid, _ in ups if isinstance(mid, int) and not isinstance(mid, bool)]
names = {mid: name for mid, name in ups if isinstance(mid, int) and not isinstance(mid, bool)}
if mids:
try:
self.up_profile.capture(mids, names=names, live_since=self._now() - 3600)
except Exception as exc: # noqa: BLE001 粉丝采集失败不影响入库
self._log("warning", "ingest.follower_failed", error=str(exc))
self.repo.bump_cache_version("videos")
+53 -8
View File
@@ -1,11 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Telegram getUpdates long polling 与按钮回调分发。""" """Telegram getUpdates long polling 与回调/文本消息分发。
回调按钮处理候选「入库 / 忽略 / 拉黑」;私聊文本消息按视频链接、BV 号或 av 号
手动入库。两类操作都只写本地库,不写 B 站收藏夹。
"""
from __future__ import annotations from __future__ import annotations
import time import time
from typing import Any, Mapping from typing import Any, Mapping
from ..notify.telegram import HELP_TEXT, format_ingest_result, format_manual_reply
class TelegramPoller: class TelegramPoller:
def __init__(self, bot, actions, *, logger=None, sleeper=time.sleep) -> None: def __init__(self, bot, actions, *, logger=None, sleeper=time.sleep) -> None:
@@ -27,14 +33,21 @@ class TelegramPoller:
callback = update.get("callback_query") callback = update.get("callback_query")
if isinstance(callback, Mapping): if isinstance(callback, Mapping):
self.handle_callback(callback) self.handle_callback(callback)
continue
message = update.get("message")
if isinstance(message, Mapping):
self.handle_message(message)
return offset return offset
def _authorized(self, chat_id, from_id) -> bool:
return chat_id == self.bot.chat_id and from_id == self.bot.chat_id
def handle_callback(self, callback: Mapping[str, Any]) -> None: def handle_callback(self, callback: Mapping[str, Any]) -> None:
callback_id = str(callback.get("id") or "") callback_id = str(callback.get("id") or "")
message = callback.get("message") or {} message = callback.get("message") or {}
chat_id = (message.get("chat") or {}).get("id") chat_id = (message.get("chat") or {}).get("id")
from_id = (callback.get("from") or {}).get("id") from_id = (callback.get("from") or {}).get("id")
if chat_id != self.bot.chat_id or from_id != self.bot.chat_id: if not self._authorized(chat_id, from_id):
self._log( self._log(
"warning", "telegram.unauthorized", "warning", "telegram.unauthorized",
chat_id=chat_id, from_id=from_id, chat_id=chat_id, from_id=from_id,
@@ -49,8 +62,9 @@ class TelegramPoller:
self._answer(callback_id, "未知操作") self._answer(callback_id, "未知操作")
return return
if action == "fav": if action in ("add", "fav"):
result = self.actions.favorite(aid) # fav 为旧按钮回调,兼容已发出的历史消息。
result = self.actions.ingest_candidate(aid)
elif action == "ign": elif action == "ign":
result = self.actions.ignore(aid) result = self.actions.ignore(aid)
elif action == "blk": elif action == "blk":
@@ -59,13 +73,10 @@ class TelegramPoller:
self._answer(callback_id, "未知操作") self._answer(callback_id, "未知操作")
return return
text = result.message text = format_ingest_result(result)
if result.ok and result.position is not None:
text = f"{text}(位置 #{result.position})"
self._log( self._log(
"info", "telegram.action", "info", "telegram.action",
action=action, aid=aid, ok=result.ok, state=result.state, action=action, aid=aid, ok=result.ok, state=result.state,
position=result.position,
) )
if not self._answer(callback_id, text): if not self._answer(callback_id, text):
# 回调 ID 过期时 answerCallbackQuery 会 400;改用普通消息告知结果。 # 回调 ID 过期时 answerCallbackQuery 会 400;改用普通消息告知结果。
@@ -80,6 +91,40 @@ class TelegramPoller:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._log("warning", "telegram.edit_failed", error=str(exc)) self._log("warning", "telegram.edit_failed", error=str(exc))
def handle_message(self, message: Mapping[str, Any]) -> None:
chat_id = (message.get("chat") or {}).get("id")
from_id = (message.get("from") or {}).get("id")
if not self._authorized(chat_id, from_id):
self._log(
"warning", "telegram.unauthorized_message",
chat_id=chat_id, from_id=from_id,
)
return
text = message.get("text")
if not isinstance(text, str) or not text.strip():
return
text = text.strip()
if text.startswith("/start") or text.startswith("/help"):
self._reply(HELP_TEXT)
return
try:
result = self.actions.ingest_text(text)
except Exception as exc: # noqa: BLE001 单条消息失败不影响长轮询
self._log("error", "telegram.ingest_error", error=str(exc))
self._reply(f"入库失败:{exc}")
return
self._log(
"info", "telegram.manual_ingest",
refs=result.refs, added=result.added,
)
self._reply(format_manual_reply(result))
def _reply(self, text: str) -> None:
try:
self.bot.send_text(text)
except Exception as exc: # noqa: BLE001
self._log("warning", "telegram.reply_failed", error=str(exc))
def _answer(self, callback_id: str, text: str) -> bool: def _answer(self, callback_id: str, text: str) -> bool:
try: try:
self.bot.answer_callback(callback_id, text) self.bot.answer_callback(callback_id, text)
+296
View File
@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
"""入库(候选确认 / 手动)、忽略、拉黑动作测试(假只读客户端,不联网):
uv run python tests/test_library.py
"""
import json
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from fixtures import view_for # noqa: E402
from reaction_board.bilibili.client import BilibiliError # noqa: E402
from reaction_board.bilibili.models import VideoDetail # noqa: E402
from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402
from reaction_board.services.library import LibraryService # noqa: E402
from reaction_board.timeutil import shanghai_date # noqa: E402
NOW = 1_793_444_393
DATE = shanghai_date(NOW)
BV = "BV1xvxi6LEZD"
def detail(
aid,
*,
bvid=BV,
title="咻咻满 reaction",
up_mid=100,
up_name="UP100",
pubdate=NOW - 3600,
view=819,
duration=9504,
pages=2,
):
payload = view_for(aid, view=view)
data = payload["data"]
data["bvid"] = bvid
data["title"] = title
data["pubdate"] = pubdate
data["ctime"] = pubdate
data["duration"] = duration
data["owner"] = {"mid": up_mid, "name": up_name, "face": ""}
data["pages"] = [{"cid": 1, "page": i + 1} for i in range(pages)]
return VideoDetail.from_api(data)
class FakeReadClient:
def __init__(self, by_bvid=None, by_aid=None, short_map=None, fail_bvids=()):
self.by_bvid = by_bvid or {}
self.by_aid = by_aid or {}
self.short_map = short_map or {}
self.fail_bvids = set(fail_bvids)
def view_detail(self, *, aid=None, bvid=None):
if bvid is not None:
if bvid in self.fail_bvids:
raise BilibiliError(f"view 失败: {bvid}")
if bvid in self.by_bvid:
return self.by_bvid[bvid]
raise BilibiliError(f"未知 bvid: {bvid}")
if aid in self.by_aid:
return self.by_aid[aid]
raise BilibiliError(f"未知 aid: {aid}")
def resolve_short_link(self, url):
if url not in self.short_map:
raise BilibiliError(f"未知短链: {url}")
return self.short_map[url]
class FakeProvider:
def __init__(self, client):
self.client = client
def get(self):
return self.client
class FakeUpProfile:
def __init__(self):
self.calls = []
def capture(self, mids, *, names=None, live_since=None, backfilled=False):
self.calls.append((list(mids), dict(names or {})))
class Result:
requested = len(mids)
updated_videos = len(mids)
failed = 0
return Result()
def write_config(tmp):
data = {
"bilibili": {
"read": {"mid": 1, "csrf": "r", "cookie": "read-cookie"},
"write": {"mid": 2, "csrf": "w", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
},
"discover": {"keywords": ["x"], "pages": 1, "window_days": 14},
}
path = Path(tmp) / "config.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path
def setup(tmp, client, up_profile=None):
store = ConfigStore(write_config(tmp), env={})
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: NOW)
service = LibraryService(
store, repo, FakeProvider(client),
up_profile_service=up_profile, clock=lambda: NOW,
)
return db, repo, service
def add_candidate(repo, aid, *, up_mid=100, pubdate=NOW - 3600):
return repo.upsert_candidate(
aid=aid, bvid=BV, title="咻咻满 reaction", up_mid=up_mid,
up_name=f"UP{up_mid}", pubdate=pubdate, cover=None, score=3,
channel="search:test#p1", now=NOW,
)
def test_ingest_candidate_added_and_snapshot():
with tempfile.TemporaryDirectory() as tmp:
ups = FakeUpProfile()
client = FakeReadClient(by_aid={9: detail(9)})
db, repo, service = setup(tmp, client, ups)
try:
add_candidate(repo, 9)
result = service.ingest_candidate(9)
assert result.ok is True and result.state == "added"
assert result.aid == 9
assert repo.get_candidate(9) is None
video = repo.get_video(9)
assert video["status"] == "active"
assert video["source"] == "candidate:telegram"
assert video["folder_id"] is None
assert video["up_mid"] == 100
snap = repo.get_snapshot(DATE, 9)
assert snap is not None and snap["ok"] == 1 and snap["view"] == 819
assert ups.calls == [([100], {100: "UP100"})]
finally:
db.close()
def test_ingest_candidate_exists_and_missing():
with tempfile.TemporaryDirectory() as tmp:
client = FakeReadClient(by_aid={9: detail(9)})
db, repo, service = setup(tmp, client)
try:
add_candidate(repo, 9)
assert service.ingest_candidate(9).state == "added"
# 候选已被删除,但视频已在库
again = service.ingest_candidate(9)
assert again.ok is True and again.state == "exists"
missing = service.ingest_candidate(999)
assert missing.ok is False and missing.state == "missing"
finally:
db.close()
def test_ingest_candidate_recovers_dropped():
with tempfile.TemporaryDirectory() as tmp:
client = FakeReadClient(by_aid={9: detail(9)})
db, repo, service = setup(tmp, client)
try:
add_candidate(repo, 9)
assert service.ingest_candidate(9).state == "added"
repo.set_video_status(9, "dropped", now=NOW)
add_candidate(repo, 9)
result = service.ingest_candidate(9)
assert result.ok is True and result.state == "recovered"
assert repo.get_video(9)["status"] == "active"
finally:
db.close()
def test_ingest_candidate_falls_back_when_detail_fails():
with tempfile.TemporaryDirectory() as tmp:
client = FakeReadClient(fail_bvids={BV})
db, repo, service = setup(tmp, client)
try:
add_candidate(repo, 9)
result = service.ingest_candidate(9)
assert result.ok is True and result.state == "added"
assert repo.get_video(9)["title"] == "咻咻满 reaction"
assert repo.get_snapshot(DATE, 9) is None
finally:
db.close()
def test_manual_ingest_forms_and_multiple():
with tempfile.TemporaryDirectory() as tmp:
ups = FakeUpProfile()
client = FakeReadClient(
by_bvid={BV: detail(9)},
by_aid={12345: detail(77, bvid="BV77", up_mid=200, up_name="UP200")},
short_map={"https://b23.tv/abc": f"https://www.bilibili.com/video/{BV}"},
)
db, repo, service = setup(tmp, client, ups)
try:
result = service.ingest_text(
f"看这个 https://www.bilibili.com/video/{BV} 还有 av12345"
)
assert result.refs == 2 and result.added == 2
assert [r.aid for r in result.results] == [9, 77]
assert repo.get_video(9)["source"] == "manual:telegram"
assert repo.get_video(77)["status"] == "active"
short = service.ingest_text("https://b23.tv/abc")
assert short.added == 0
assert short.results[0].state == "exists"
none = service.ingest_text("没有视频")
assert none.refs == 0 and none.results == []
finally:
db.close()
def test_manual_ingest_error_isolation():
with tempfile.TemporaryDirectory() as tmp:
client = FakeReadClient(
by_bvid={BV: detail(9)},
fail_bvids={"BVfail000000"},
)
db, repo, service = setup(tmp, client)
try:
result = service.ingest_text(f"{BV} BVfail000000")
assert result.added == 1
states = [r.state for r in result.results]
assert states == ["added", "error"]
finally:
db.close()
def test_snapshot_not_overwritten():
with tempfile.TemporaryDirectory() as tmp:
client = FakeReadClient(by_bvid={BV: detail(9, view=819)})
db, repo, service = setup(tmp, client)
try:
repo.upsert_snapshot(
DATE, 9, metrics={"view": 5000}, ok=True, err=None, captured_at=NOW,
)
service.ingest_text(BV)
snap = repo.get_snapshot(DATE, 9)
assert snap["view"] == 5000 # 已有成功快照不覆盖
finally:
db.close()
def test_ignore_and_blacklist():
with tempfile.TemporaryDirectory() as tmp:
client = FakeReadClient()
db, repo, service = setup(tmp, client)
try:
add_candidate(repo, 11, up_mid=300)
add_candidate(repo, 12, up_mid=300)
assert service.ignore(11).state == "rejected"
assert repo.get_candidate(11)["decision"] == "rejected"
result = service.blacklist(12)
assert result.ok is True
assert repo.is_blacklisted(300)
assert repo.get_candidate(12)["decision"] == "rejected"
assert service.ignore(999).state == "missing"
finally:
db.close()
def main():
test_ingest_candidate_added_and_snapshot()
test_ingest_candidate_exists_and_missing()
test_ingest_candidate_recovers_dropped()
test_ingest_candidate_falls_back_when_detail_fails()
test_manual_ingest_forms_and_multiple()
test_manual_ingest_error_isolation()
test_snapshot_not_overwritten()
test_ignore_and_blacklist()
print("OK: 入库、手动入库、忽略、拉黑测试全部通过")
if __name__ == "__main__":
main()
+66 -17
View File
@@ -11,11 +11,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.notify.telegram import ( # noqa: E402 from reaction_board.notify.telegram import ( # noqa: E402
HELP_TEXT,
TelegramClient, TelegramClient,
TelegramError, TelegramError,
format_duration, format_duration,
) )
from reaction_board.services.favorites import ActionResult # noqa: E402 from reaction_board.services.library import ( # noqa: E402
ActionResult,
IngestResult,
ManualIngestResult,
)
from reaction_board.services.telegram_poller import TelegramPoller # noqa: E402 from reaction_board.services.telegram_poller import TelegramPoller # noqa: E402
@@ -58,7 +63,7 @@ def test_send_candidates_and_chunking():
keyboard = json.loads(call["data"]["reply_markup"]) keyboard = json.loads(call["data"]["reply_markup"])
assert len(keyboard["inline_keyboard"]) == 2 assert len(keyboard["inline_keyboard"]) == 2
assert [button["callback_data"] for button in keyboard["inline_keyboard"][0]] == [ assert [button["callback_data"] for button in keyboard["inline_keyboard"][0]] == [
"fav:1", "ign:1", "blk:1", "add:1", "ign:1", "blk:1",
] ]
transport = FakeTransport() transport = FakeTransport()
@@ -76,7 +81,7 @@ def test_send_candidates_dedupes_by_aid():
keyboard = json.loads(transport.calls[0]["data"]["reply_markup"]) keyboard = json.loads(transport.calls[0]["data"]["reply_markup"])
assert len(keyboard["inline_keyboard"]) == 2 assert len(keyboard["inline_keyboard"]) == 2
assert [row[0]["callback_data"] for row in keyboard["inline_keyboard"]] == [ assert [row[0]["callback_data"] for row in keyboard["inline_keyboard"]] == [
"fav:1", "fav:2", "add:1", "add:2",
] ]
@@ -88,7 +93,7 @@ def test_get_updates_and_answer():
assert data["offset"] == 99 assert data["offset"] == 99
assert data["timeout"] == 17 assert data["timeout"] == 17
assert transport.calls[0]["timeout"] == 32 assert transport.calls[0]["timeout"] == 32
assert json.loads(data["allowed_updates"]) == ["callback_query"] assert json.loads(data["allowed_updates"]) == ["callback_query", "message"]
transport = FakeTransport(result=True) transport = FakeTransport(result=True)
bot = TelegramClient("token", 42, transport=transport) bot = TelegramClient("token", 42, transport=transport)
@@ -148,10 +153,13 @@ class FakeBot:
class FakeActions: class FakeActions:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
self.manual = []
def favorite(self, aid): def ingest_candidate(self, aid):
self.calls.append(("fav", aid)) self.calls.append(("add", aid))
return ActionResult(True, aid, "favorite", "done", position=3, message="已加入收藏夹") return IngestResult(
True, aid, "added", title="咻咻满 reaction", message="已入库"
)
def ignore(self, aid): def ignore(self, aid):
self.calls.append(("ign", aid)) self.calls.append(("ign", aid))
@@ -161,6 +169,16 @@ class FakeActions:
self.calls.append(("blk", aid)) self.calls.append(("blk", aid))
return ActionResult(True, aid, "blacklist", "rejected", message="已拉黑") return ActionResult(True, aid, "blacklist", "rejected", message="已拉黑")
def ingest_text(self, text):
self.manual.append(text)
return ManualIngestResult(
refs=1, added=1,
results=[IngestResult(True, 9, "added", title="咻咻满 reaction", message="已入库")],
)
ADD_REPLY = "✅ 已入库《咻咻满 reaction》(aid 9)"
def update(update_id, data, *, chat_id=42, from_id=42): def update(update_id, data, *, chat_id=42, from_id=42):
return { return {
@@ -174,33 +192,63 @@ def update(update_id, data, *, chat_id=42, from_id=42):
} }
def message_update(update_id, text, *, chat_id=42, from_id=42):
return {
"update_id": update_id,
"message": {
"message_id": update_id * 10,
"chat": {"id": chat_id},
"from": {"id": from_id},
"text": text,
},
}
def test_poller_dispatch_and_authorization(): def test_poller_dispatch_and_authorization():
bot = FakeBot([ bot = FakeBot([
update(1, "fav:9"), update(1, "add:9"),
update(2, "ign:10"), update(2, "ign:10"),
update(3, "blk:11"), update(3, "blk:11"),
update(4, "fav:12", chat_id=999), update(4, "add:12", chat_id=999),
update(5, "bad"), update(5, "bad"),
update(6, "fav:13"),
]) ])
actions = FakeActions() actions = FakeActions()
poller = TelegramPoller(bot, actions) poller = TelegramPoller(bot, actions)
offset = poller.run_once(None) offset = poller.run_once(None)
assert offset == 6 assert offset == 7
assert actions.calls == [("fav", 9), ("ign", 10), ("blk", 11)] assert actions.calls == [("add", 9), ("ign", 10), ("blk", 11), ("add", 13)]
assert bot.answers[0] == ("cb1", "已加入收藏夹(位置 #3)") assert bot.answers[0] == ("cb1", ADD_REPLY)
assert bot.answers[1] == ("cb2", "• 已忽略(aid 10)")
assert bot.answers[2] == ("cb3", "• 已拉黑(aid 11)")
assert bot.answers[3] == ("cb4", "无权限") assert bot.answers[3] == ("cb4", "无权限")
assert bot.answers[4] == ("cb5", "未知操作") assert bot.answers[4] == ("cb5", "未知操作")
assert bot.edits == [10, 20, 30] assert bot.edits == [10, 20, 30, 60]
def test_answer_failure_falls_back_to_text(): def test_poller_manual_message():
bot = FakeBot([update(1, "fav:9")], fail_answer=True) bot = FakeBot([
message_update(1, "https://www.bilibili.com/video/BV1x8x166EzY"),
message_update(2, "hello", chat_id=999),
message_update(3, "/help"),
])
actions = FakeActions() actions = FakeActions()
poller = TelegramPoller(bot, actions) poller = TelegramPoller(bot, actions)
poller.run_once(None) poller.run_once(None)
assert actions.calls == [("fav", 9)] assert actions.manual == ["https://www.bilibili.com/video/BV1x8x166EzY"]
assert "入库" in bot.sent[0]
assert HELP_TEXT in bot.sent
assert len(bot.sent) == 2
def test_answer_failure_falls_back_to_text():
bot = FakeBot([update(1, "add:9")], fail_answer=True)
actions = FakeActions()
poller = TelegramPoller(bot, actions)
poller.run_once(None)
assert actions.calls == [("add", 9)]
assert bot.answers == [] assert bot.answers == []
assert bot.sent == ["已加入收藏夹(位置 #3)"] assert bot.sent == [ADD_REPLY]
assert bot.edits == [10] assert bot.edits == [10]
@@ -257,6 +305,7 @@ def main():
test_get_updates_and_answer() test_get_updates_and_answer()
test_telegram_error_description_preserved() test_telegram_error_description_preserved()
test_poller_dispatch_and_authorization() test_poller_dispatch_and_authorization()
test_poller_manual_message()
test_answer_failure_falls_back_to_text() test_answer_failure_falls_back_to_text()
test_poller_backoff_and_recovery() test_poller_backoff_and_recovery()
print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过") print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过")