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

+146 -1
View File
@@ -4,6 +4,8 @@
python app.py init # 建库 / 迁移 / 写入黑名单种子(离线)
python app.py sync # 收藏夹只读同步(首次导入或增量)
python app.py snapshot # 采集当日七项快照与粉丝数
python app.py discover # 执行一次搜索发现并写入候选池
python app.py candidates # 查看候选池(离线)
python app.py auth-check # 检查 Cookie 登录态
python app.py run # 按配置周期常驻运行(--once 只跑一次)
python app.py status # 查看库内状态(离线)
@@ -16,19 +18,26 @@ from __future__ import annotations
import argparse
import json
import sys
import threading
from pathlib import Path
from typing import Sequence
from .bilibili.auth_flow import AuthService
from .bilibili.client import BilibiliClient, Pacer
from .bilibili.provider import ClientProvider
from .bilibili.search import SearchClient
from .bilibili.write_client import BilibiliWriteClient
from .config import ConfigError, ConfigStore
from .database import Database
from .logging_json import JsonLogger
from .notify.telegram import TelegramClient
from .repository import Repository
from .scheduler import ScheduledTask, Scheduler
from .services.discovery import DiscoveryService
from .services.favorites import FavoriteActionService
from .services.folder_sync import FolderSyncService
from .services.snapshot import SnapshotService
from .services.telegram_poller import TelegramPoller
from .timeutil import next_daily_run, parse_hhmm, shanghai_date
ROOT = Path(__file__).resolve().parent.parent
@@ -65,6 +74,23 @@ def _build_parser() -> argparse.ArgumentParser:
snapshot.add_argument(
"--force", action="store_true", help="忽略当日完成标记,重新采集"
)
discover = sub.add_parser(
"discover", parents=[common], help="执行一次搜索发现并写入候选池"
)
discover.add_argument(
"--no-notify",
action="store_true",
help="只写候选池,不发送 Telegram 通知",
)
candidates = sub.add_parser(
"candidates", parents=[common], help="查看候选池(离线)"
)
candidates.add_argument(
"--decision",
choices=("accepted", "rejected"),
default=None,
help="只查看指定决策状态的候选",
)
auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie")
auth.add_argument("--account", choices=("read", "write"), default="read",
help="要授权的账号角色(默认 read)")
@@ -179,6 +205,64 @@ def cmd_snapshot(args, logger: JsonLogger) -> int:
db.close()
def _telegram(cfg, logger: JsonLogger):
if not cfg.telegram.enabled:
return None
if not cfg.telegram.bot_token or not cfg.telegram.chat_id:
raise ConfigError(
"telegram.enabled=true 时必须配置 bot_token 和 chat_id"
)
return TelegramClient(
cfg.telegram.bot_token,
cfg.telegram.chat_id,
logger=logger,
)
def cmd_discover(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=True)
cfg = store.get()
db = _open_db(cfg, logger)
try:
repo = Repository(db)
provider = ClientProvider(
store, "read", lambda credentials: _client(credentials, logger)
)
service = DiscoveryService(
store,
repo,
SearchClient(provider.get),
notifier=None if args.no_notify else _telegram(cfg, logger),
logger=logger,
)
summary = service.run()
payload = summary.as_dict()
payload["candidate_total"] = len(repo.list_candidates())
payload["database"] = str(cfg.storage.database_path)
_print(payload)
return 0 if not summary.errors else 1
finally:
db.close()
def cmd_candidates(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=False)
cfg = store.get()
db = _open_db(cfg, logger)
try:
repo = Repository(db)
rows = repo.list_candidates(args.decision)
_print({
"ok": True,
"count": len(rows),
"candidates": rows,
"database": str(cfg.storage.database_path),
})
return 0
finally:
db.close()
def cmd_auth(args, logger: JsonLogger) -> int:
if args.timeout < 1:
raise ConfigError("auth --timeout 必须至少为 1 秒")
@@ -242,6 +326,47 @@ def cmd_run(args, logger: JsonLogger) -> int:
snapshot_service = SnapshotService(
store, repo, client_provider=snapshot_provider, logger=logger
)
write_provider = ClientProvider(
store,
"write",
lambda credentials: BilibiliWriteClient(
cookie=credentials.cookie,
csrf=credentials.csrf,
mid=credentials.mid,
logger=logger,
),
)
favorite_service = FavoriteActionService(
store, repo, write_provider, logger=logger
)
notifier = _telegram(cfg, logger)
discovery_service = DiscoveryService(
store,
repo,
SearchClient(folder_provider.get),
notifier=notifier,
logger=logger,
)
def run_discover():
summary = discovery_service.run()
last["discover"] = summary
logger.info("discovery.summary", **summary.as_dict())
return summary
def run_cleanup():
removed = repo.delete_expired_candidates(
ttl_days=cfg.schedule.candidate_ttl_days
)
expired = repo.expire_fav_actions()
logger.info(
"cleanup.done",
candidates_removed=removed, fav_actions_expired=expired,
)
return {
"candidates_removed": removed,
"fav_actions_expired": expired,
}
def run_snapshot():
summary = snapshot_service.run_if_needed()
@@ -281,10 +406,24 @@ def cmd_run(args, logger: JsonLogger) -> int:
next_run=0.0,
next_run_fn=lambda started: next_daily_run(started, snapshot_at),
),
ScheduledTask(
"discovery",
float(cfg.schedule.discover_minutes * 60),
run_discover,
),
ScheduledTask("candidate_cleanup", 86400.0, run_cleanup),
ScheduledTask("config_watch", 60.0, run_config_watch),
],
logger=logger,
)
if notifier is not None and not args.once:
poller = TelegramPoller(notifier, favorite_service, logger=logger)
threading.Thread(
target=poller.run_forever,
name="telegram-poller",
daemon=True,
).start()
logger.info("telegram.polling_started")
if args.once:
results = scheduler.run_due()
_print([result.as_dict() for result in results])
@@ -298,9 +437,11 @@ def cmd_run(args, logger: JsonLogger) -> int:
folder_summary = last.get("folder")
snapshot_summary = last.get("snapshot")
discover_summary = last.get("discover")
folder_ok = folder_summary is None or getattr(folder_summary, "ok", True)
snapshot_ok = snapshot_summary is None or getattr(snapshot_summary, "completed", True)
return 0 if folder_ok and snapshot_ok else 1
discover_ok = discover_summary is None or not getattr(discover_summary, "errors", [])
return 0 if folder_ok and snapshot_ok and discover_ok else 1
finally:
db.close()
@@ -356,6 +497,10 @@ def main(argv: Sequence[str] | None = None) -> int:
return cmd_sync(args, logger)
if args.command == "snapshot":
return cmd_snapshot(args, logger)
if args.command == "discover":
return cmd_discover(args, logger)
if args.command == "candidates":
return cmd_candidates(args, logger)
if args.command == "auth":
return cmd_auth(args, logger)
if args.command == "auth-check":