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

+194
View File
@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""候选池、去重、通知记录与 fav_action 状态机测试(不联网):
uv run python tests/test_candidates.py
"""
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.models import FavResource # noqa: E402
from reaction_board.config import FolderConfig # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402
FOLDER = FolderConfig(year=2026, media_id=3700023351, title="2026")
def setup(tmp):
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: 1_800_000_000)
repo.ensure_folder(FOLDER)
return db, repo
def candidate(repo, aid=1, *, score=3, now=1_800_000_000):
return repo.upsert_candidate(
aid=aid,
bvid=f"BV{aid}",
title=f"咻咻满 reaction {aid}",
up_mid=100,
up_name="UP",
pubdate=1_799_000_000,
cover="https://example/cover.jpg",
score=score,
channel="search:test#p1",
now=now,
)
def test_seen_and_known_aid():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
assert repo.known_aid(1) is False
repo.record_seen(1, source="search:test#p1")
repo.record_seen(1, source="search:test#p2") # 幂等
assert repo.known_aid(1) is True
repo.upsert_from_fav(
FavResource(
aid=2, bvid="BV2", title="视频", up_mid=100, up_name="UP",
pubdate=1, duration=60, cover=None, attr=0, fav_time=None,
),
FOLDER.media_id,
was_added=True,
)
assert repo.known_aid(2) is True
finally:
db.close()
def test_candidate_upsert_preserves_decision_and_notified():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
first = candidate(repo, 1, score=2)
assert first["decision"] is None and first["notified_at"] is None
repo.mark_notified(
1, channel="telegram", batch_id="b1", ok=True, now=1_800_000_100
)
repo.mark_candidate_decision(1, "rejected", now=1_800_000_200)
second = candidate(repo, 1, score=4, now=1_800_000_300)
assert second["score"] == 4
assert second["decision"] == "rejected"
assert second["notified_at"] == 1_800_000_100
assert second["first_seen"] == 1_800_000_000
assert len(repo.list_candidates()) == 1
assert len(repo.list_candidates("rejected")) == 1
assert repo.list_candidates("accepted") == []
finally:
db.close()
def test_notify_and_reject_for_up():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
candidate(repo, 1)
candidate(repo, 2)
repo.mark_notified(
1, channel="telegram", batch_id="b1", ok=False, err="boom",
now=1_800_000_100,
)
assert repo.get_candidate(1)["notified_at"] is None
repo.mark_notified(
1, channel="telegram", batch_id="b2", ok=True, now=1_800_000_200
)
assert repo.get_candidate(1)["notified_at"] == 1_800_000_200
assert repo.reject_pending_for_up(100, now=1_800_000_300) == 2
assert repo.get_candidate(2)["decision"] == "rejected"
finally:
db.close()
def test_delete_expired_candidates_only_pending():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
candidate(repo, 1, now=1_800_000_000)
candidate(repo, 2, now=1_800_000_000)
candidate(repo, 3, now=1_800_000_000)
repo.mark_candidate_decision(3, "rejected")
deleted = repo.delete_expired_candidates(
ttl_days=7, now=1_800_000_000 + 8 * 86400
)
assert deleted == 2
assert repo.get_candidate(1) is None
assert repo.get_candidate(3) is not None
finally:
db.close()
def test_folder_for_up_uses_earliest_video():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
for aid, pubdate in ((1, 1_700_000_500), (2, 1_700_000_100)):
repo.upsert_from_fav(
FavResource(
aid=aid, bvid=f"BV{aid}", title="视频", up_mid=100,
up_name="UP", pubdate=pubdate, duration=60,
cover=None, attr=0, fav_time=None,
),
FOLDER.media_id,
was_added=True,
)
assert repo.folder_for_up(100) == FOLDER.media_id
assert repo.folder_for_up(999) is None
finally:
db.close()
def test_fav_action_state_machine():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
token, state = repo.begin_fav_action(1, FOLDER.media_id)
assert token == "fav:1"
assert state == "processing"
token2, state2 = repo.begin_fav_action(1, FOLDER.media_id)
assert token2 == token and state2 == "busy"
repo.finish_fav_action(token, position=3)
assert repo.get_fav_action(token)["state"] == "done"
assert repo.get_fav_action(token)["position"] == 3
assert repo.begin_fav_action(1, FOLDER.media_id)[1] == "done"
token3, state3 = repo.begin_fav_action(2, FOLDER.media_id)
assert state3 == "processing"
repo.fail_fav_action(token3, error="夹满")
assert repo.get_fav_action(token3)["state"] == "failed"
assert repo.get_fav_action(token3)["err"] == "夹满"
# failed 可重试
_, retry = repo.begin_fav_action(2, FOLDER.media_id)
assert retry == "processing"
# 过期的 processing 自动失败
assert repo.expire_fav_actions(now=1_800_000_000 + 2 * 86400) >= 1
assert repo.get_fav_action("fav:2")["state"] == "failed"
finally:
db.close()
def main():
test_seen_and_known_aid()
test_candidate_upsert_preserves_decision_and_notified()
test_notify_and_reject_for_up()
test_delete_expired_candidates_only_pending()
test_folder_for_up_uses_earliest_video()
test_fav_action_state_machine()
print("OK: 候选池、去重、通知记录与 fav_action 状态机测试全部通过")
if __name__ == "__main__":
main()