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.
229 lines
7.6 KiB
Python
229 lines
7.6 KiB
Python
# -*- 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()
|