Files
re-xxm/tests/test_discovery.py
T
kairee c6790f0ba1 feat(classify): zero title-only titles, penalize <10min, add discover.candidate_min_score
标题仅含 require_title 不再计分(必须咻咻满+任一正向词);时长 <10 分钟 -1(时长未知不扣);新增 discover.candidate_min_score(默认=notify.min_score)控制是否进候选池,只过标题关的不再入池。库内 796 条:仅过标题关的 8 条从 1/3 分降为 0/-1,其余不变。
2026-11-01 20:18:43 +08:00

255 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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,
"candidate_min_score": 0,
**discover,
},
"classify": {
"require_title": "咻咻满",
"self_mid": 37754047,
"reaction": ["reaction"],
"charge": ["充电"],
"weak_positive": [],
"negative": ["切片"],
"date_negative": True,
"duration_boost": {"gt_30min": 1, "gt_60min": 2},
},
"notify": {"min_score": 4, "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, **discover):
path = write_config(tmp, **discover)
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 == 2 # aid 6(0 分)与 aid 2(2 分)
assert summary.notified == 0
assert repo.get_candidate(6)["score"] == 0 # 只过标题关:0 分
assert repo.get_candidate(2)["score"] == 2 # reaction 命中后单个「切片」不再清零
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 test_candidate_min_score_floor():
with tempfile.TemporaryDirectory() as tmp:
db, repo, service = make_service(
tmp,
[
item(1, title="咻咻满 reaction 充电"), # 9 分 -> 入池
item(2, title="咻咻满 日常"), # 0 分 -> 只过标题关,不入池
item(3, title="咻咻满 reaction 切片"), # 2 分 -> 低于候选线,不入池
],
candidate_min_score=4,
)
try:
summary = service.run()
assert summary.scored == 3
assert summary.candidates == 1
assert repo.get_candidate(1) is not None
assert repo.get_candidate(2) is None
assert repo.get_candidate(3) is None
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()
test_candidate_min_score_floor()
print("OK: 发现过滤、打分、候选池与通知批次测试全部通过")
if __name__ == "__main__":
main()