feat(p3): add discovery, telegram notifications, and one-click favorites

Implement WBI video search with legacy fallback, classification scoring, candidate pool, permanent seen-aid dedupe, blacklist filtering, and batched Telegram notifications. Add write client for fav deal/sort, target-folder selection, insertion position calculation, read-back verification, fav_action locking, retry on failure, ignore and blacklist actions. Wire discovery, candidate cleanup, Telegram long polling, and new discover/candidates CLI commands into the scheduler. Add migration v3 for candidate duration and index stats.
This commit is contained in:
kairee committed 2026-10-31 21:23:12 +08:00
1 parent 6a21b5d905
commit d971e718e9
31 files changed
+2886 -55

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""发现任务测试(假搜索客户端,不联网): uv run python tests/test_discovery.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 reaction_board.bilibili.client import BilibiliError # noqa: E402
from reaction_board.bilibili.search import SearchItem # 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.discovery import DiscoveryService # noqa: E402
NOW = 1_800_000_000
def write_config(tmp, **discover):
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"),
},
"folders": [{"year": 2026, "media_id": 3700023351, "title": "2026"}],
"discover": {
"keywords": ["咻咻满 reaction"],
"pages": 1,
"window_days": 14,
**discover,
},
"classify": {
"require_title": "咻咻满",
"self_mid": 37754047,
"positive": ["reaction"],
"charge": ["充电"],
"negative": ["切片"],
"duration_boost": {"gt_30min": 1, "gt_60min": 2},
},
"notify": {"min_score": 2, "max_items_per_message": 10},
}
path = Path(tmp) / "config.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path
class FakeSearch:
def __init__(self, items, error=None):
self.items = items
self.error = error
self.calls = []
def search(self, keyword, page):
self.calls.append((keyword, page))
if self.error:
raise self.error
return list(self.items)
class FakeNotifier:
def __init__(self, ok=True):
self.ok = ok
self.batches = []
def send_candidates(self, candidates):
self.batches.append(list(candidates))
return self.ok
def item(aid, *, title="咻咻满 reaction", up_mid=100, pubdate=NOW - 3600,
duration=600):
return SearchItem(
aid=aid,
bvid=f"BV{aid}",
title=title,
up_mid=up_mid,
up_name="UP",
pubdate=pubdate,
duration_seconds=duration,
cover="https://example/cover.jpg",
)
def make_service(tmp, items, *, notifier=None):
path = write_config(tmp)
store = ConfigStore(path, env={})
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: NOW)
service = DiscoveryService(
store, repo, FakeSearch(items),
notifier=notifier,
clock=lambda: NOW,
sleeper=lambda seconds: None,
logger=None,
)
return db, repo, service
def test_filters_and_scoring():
with tempfile.TemporaryDirectory() as tmp:
notifier = FakeNotifier()
db, repo, service = make_service(tmp, [
item(1), # 通知
item(2, title="咻咻满 reaction 切片"), # 负分丢弃
item(3, title="其他 UP reaction"), # 标题不含
item(4, pubdate=NOW - 20 * 86400), # 超时间窗
item(5, up_mid=999), # 黑名单
item(6, title="咻咻满 日常"), # 0 分入池不通知
], notifier=notifier)
try:
repo.seed_blacklist([999], now=NOW)
repo.record_seen(1, source="old", now=NOW)
# aid=1 已 seen,改为验证已知跳过;另用 7 验证通知
summary = service.run()
assert summary.items == 6
assert summary.known == 1
assert summary.title_miss == 1
assert summary.old == 1
assert summary.blacklisted == 1
assert summary.candidates == 1 # aid 6
assert summary.notified == 0
assert repo.get_candidate(6)["score"] == 0
assert repo.get_candidate(2) is None
assert repo.get_candidate(4) is None
assert repo.get_candidate(5) is None
assert notifier.batches == []
finally:
db.close()
def test_notification_batch_and_failure():
with tempfile.TemporaryDirectory() as tmp:
notifier = FakeNotifier()
db, repo, service = make_service(tmp, [item(1), item(2)], notifier=notifier)
try:
summary = service.run()
assert summary.candidates == 2
assert summary.notified == 2
assert len(notifier.batches) == 1
assert {c["aid"] for c in notifier.batches[0]} == {1, 2}
assert repo.get_candidate(1)["notified_at"] == NOW
finally:
db.close()
with tempfile.TemporaryDirectory() as tmp:
notifier = FakeNotifier(ok=False)
db, repo, service = make_service(tmp, [item(3)], notifier=notifier)
try:
summary = service.run()
assert summary.notify_failed == 1
assert repo.get_candidate(3)["notified_at"] is None
assert repo.get_candidate(3)["decision"] is None
finally:
db.close()
def test_search_error_does_not_abort_other_keywords():
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, keywords=["a", "b"])
store = ConfigStore(path, env={})
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: NOW)
search = FakeSearch([item(1)], error=BilibiliError("network"))
service = DiscoveryService(
store, repo, search, clock=lambda: NOW, sleeper=lambda s: None
)
try:
summary = service.run()
assert summary.searches == 0
assert len(summary.errors) == 2
finally:
db.close()
def test_pending_candidate_notified_on_later_run():
with tempfile.TemporaryDirectory() as tmp:
db, repo, service = make_service(tmp, [item(1)], notifier=None)
try:
first = service.run()
assert first.candidates == 1
assert repo.get_candidate(1)["notified_at"] is None
notifier = FakeNotifier()
service.notifier = notifier
second = service.run()
assert second.notified == 1
assert len(notifier.batches) == 1
assert repo.get_candidate(1)["notified_at"] == NOW
finally:
db.close()
def test_duplicate_search_results_notified_once():
with tempfile.TemporaryDirectory() as tmp:
notifier = FakeNotifier()
db, repo, service = make_service(
tmp, [item(1), item(1)], notifier=notifier
)
try:
summary = service.run()
assert summary.notified == 1
assert len(notifier.batches) == 1
assert len(notifier.batches[0]) == 1
finally:
db.close()
def main():
test_filters_and_scoring()
test_notification_batch_and_failure()
test_search_error_does_not_abort_other_keywords()
test_pending_candidate_notified_on_later_run()
test_duplicate_search_results_notified_once()
print("OK: 发现过滤、打分、候选池与通知批次测试全部通过")
if __name__ == "__main__":
main()