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:
1 parent
6a21b5d905
commit
d971e718e9
31 files changed
+2886
-55
No files matched your search
+146
-1
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user