feat(p1): add daily snapshots and fan tracking

Implement P1: seven-metric view snapshots, relation/stat fan snapshots, failure placeholders, resume by daily completion markers, Cookie login warnings, daily SQLite backups, Shanghai daily scheduling, and CLI snapshot/auth-check commands. Real read-only run captured 795/795 videos successfully over 1124s with a verified daily backup.
This commit is contained in:
kairee committed 2026-10-31 19:19:24 +08:00
1 parent ee289bfa56
commit 5c2ab7b187
20 files changed
+1680 -54

No files matched your search

+72 -1
View File
@@ -18,11 +18,21 @@ from dataclasses import dataclass
from typing import Any, Mapping, Protocol, Sequence
from .auth import make_headers
from .models import FavId, FavResource, FolderInfo, InfosResult
from .models import (
FanStat,
FavId,
FavResource,
FolderInfo,
InfosResult,
LoginState,
ViewStat,
)
BASE = "https://api.bilibili.com"
DEFAULT_CHUNK = 200
MAX_CHUNK = 400
# 视频不可访问/已删除:-404 为旧口径,62002/62012 为当前实测返回。
VIDEO_UNAVAILABLE_CODES = frozenset({-404, 62002, 62012})
class BilibiliError(RuntimeError):
@@ -360,3 +370,64 @@ class BilibiliClient:
media_count=count,
))
return out
# ------------------------------------------------------------ view / fan
def view(self, aid: int) -> ViewStat:
url = f"{BASE}/x/web-interface/view?aid={int(aid)}"
payload = self.get_json(url)
data = payload.get("data")
if not isinstance(data, Mapping):
raise BilibiliProtocolError("view data 不是对象")
try:
return ViewStat.from_api(data)
except (TypeError, ValueError) as exc:
raise BilibiliProtocolError(f"view 解析失败: {exc}") from exc
def relation_stat(self, mid: int) -> FanStat:
url = f"{BASE}/x/relation/stat?vmid={int(mid)}"
payload = self.get_json(url)
data = payload.get("data")
if not isinstance(data, Mapping):
raise BilibiliProtocolError("relation/stat data 不是对象")
try:
return FanStat.from_api(data)
except (TypeError, ValueError) as exc:
raise BilibiliProtocolError(f"relation/stat 解析失败: {exc}") from exc
def check_login(self) -> LoginState:
"""检查 Cookie 登录态;未配置 Cookie 时不发请求。"""
if not self.cookie:
return LoginState(
is_login=False,
mid=None,
uname=None,
code=0,
message="未配置 cookie",
)
url = f"{BASE}/x/web-interface/nav"
try:
payload = self.get_json(url, auth=True)
except BilibiliAPIError as exc:
return LoginState(
is_login=False, mid=None, uname=None,
code=exc.code, message=exc.message,
)
except BilibiliError as exc:
return LoginState(
is_login=False, mid=None, uname=None,
code=-1, message=str(exc),
)
data = payload.get("data") or {}
if not isinstance(data, Mapping):
raise BilibiliProtocolError("nav data 不是对象")
raw_mid = data.get("mid")
mid = raw_mid if isinstance(raw_mid, int) and not isinstance(raw_mid, bool) else None
uname = data.get("uname")
return LoginState(
is_login=bool(data.get("isLogin")),
mid=mid,
uname=str(uname) if isinstance(uname, str) else None,
code=0,
message=str(payload.get("message") or ""),
)
+80
View File
@@ -85,3 +85,83 @@ class InfosResult:
requested: tuple[int, ...]
items: dict[int, FavResource]
missing: tuple[int, ...]
@dataclass(frozen=True)
class ViewStat:
"""``x/web-interface/view`` 的七项统计。"""
aid: int
bvid: str | None
title: str
view: int
like: int
favorite: int
coin: int
share: int
reply: int
danmaku: int
def metrics(self) -> dict[str, int]:
return {
"view": self.view,
"like": self.like,
"favorite": self.favorite,
"coin": self.coin,
"share": self.share,
"reply": self.reply,
"danmaku": self.danmaku,
}
@classmethod
def from_api(cls, item: Mapping[str, Any]) -> "ViewStat":
aid = item.get("aid")
if isinstance(aid, bool) or not isinstance(aid, int):
raise ValueError("view data.aid 不是整数")
stat = item.get("stat")
if not isinstance(stat, Mapping):
raise ValueError("view data.stat 不是对象")
metrics: dict[str, int] = {}
for key in ("view", "like", "favorite", "coin", "share", "reply", "danmaku"):
value = stat.get(key)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"view stat.{key} 不是非负整数")
metrics[key] = value
bvid = item.get("bvid")
if not isinstance(bvid, str) or not bvid:
bvid = None
return cls(
aid=aid,
bvid=bvid,
title=str(item.get("title") or ""),
**metrics,
)
@dataclass(frozen=True)
class FanStat:
mid: int
follower: int
following: int
@classmethod
def from_api(cls, item: Mapping[str, Any]) -> "FanStat":
mid = item.get("mid")
follower = item.get("follower")
following = item.get("following", 0)
if isinstance(mid, bool) or not isinstance(mid, int):
raise ValueError("relation/stat data.mid 不是整数")
if isinstance(follower, bool) or not isinstance(follower, int) or follower < 0:
raise ValueError("relation/stat data.follower 不是非负整数")
if isinstance(following, bool) or not isinstance(following, int) or following < 0:
raise ValueError("relation/stat data.following 不是非负整数")
return cls(mid=mid, follower=follower, following=following)
@dataclass(frozen=True)
class LoginState:
is_login: bool
mid: int | None
uname: str | None
code: int
message: str
+121 -31
View File
@@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
"""P0 命令行入口。
"""看板命令行入口。
python app.py init # 建库 / 迁移 / 写入黑名单种子(离线)
python app.py sync # 收藏夹只读同步(首次导入或增量)
python app.py run # 按 folder_sync_minutes 常驻周期同步(--once 只跑一次)
python app.py status # 查看库内状态(离线)
python app.py init # 建库 / 迁移 / 写入黑名单种子(离线)
python app.py sync # 收藏夹只读同步(首次导入或增量)
python app.py snapshot # 采集当日七项快照与粉丝数
python app.py auth-check # 检查 Cookie 登录态
python app.py run # 按配置周期常驻运行(--once 只跑一次)
python app.py status # 查看库内状态(离线)
P2 起 ``python app.py`` 将在此基础上启动本地看板服务。
"""
@@ -17,13 +19,15 @@ import sys
from pathlib import Path
from typing import Sequence
from .bilibili.client import BilibiliClient
from .bilibili.client import BilibiliClient, Pacer
from .config import ConfigError, ConfigStore
from .database import Database
from .logging_json import JsonLogger
from .repository import Repository
from .scheduler import ScheduledTask, Scheduler
from .services.folder_sync import FolderSyncService
from .services.snapshot import SnapshotService
from .timeutil import next_daily_run, parse_hhmm, shanghai_date
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG = ROOT / "config.json"
@@ -39,7 +43,7 @@ def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="app.py",
description="咻咻满 Reaction 数据看板 —— P0 数据底座",
description="咻咻满 Reaction 数据看板 —— 数据底座、同步与每日快照",
)
sub = parser.add_subparsers(dest="command", required=True)
@@ -48,7 +52,14 @@ def _build_parser() -> argparse.ArgumentParser:
sync.add_argument(
"--full", action="store_true", help="强制全量刷新元数据(忽略 6 小时周期)"
)
run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行收藏夹同步")
snapshot = sub.add_parser(
"snapshot", parents=[common], help="采集当日七项快照与粉丝数"
)
snapshot.add_argument(
"--force", action="store_true", help="忽略当日完成标记,重新采集"
)
sub.add_parser("auth-check", parents=[common], help="检查 Cookie 登录态")
run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行同步与快照")
run.add_argument(
"--once", action="store_true", help="只执行一次到期任务后退出"
)
@@ -71,6 +82,17 @@ def _print(payload) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2), flush=True)
def _client(cfg, logger: JsonLogger, *, pacer=None, retries: int = 3) -> BilibiliClient:
return BilibiliClient(
cookie=cfg.bilibili.cookie,
csrf=cfg.bilibili.csrf,
mid=cfg.bilibili.mid,
pacer=pacer,
retries=retries,
logger=logger,
)
def cmd_init(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=False)
cfg = store.get()
@@ -98,12 +120,7 @@ def cmd_sync(args, logger: JsonLogger) -> int:
db = _open_db(cfg, logger)
try:
repo = Repository(db)
client = BilibiliClient(
cookie=cfg.bilibili.cookie,
csrf=cfg.bilibili.csrf,
mid=cfg.bilibili.mid,
logger=logger,
)
client = _client(cfg, logger)
service = FolderSyncService(store, repo, client, logger=logger)
summary = service.run(full_refresh=args.full)
payload = summary.as_dict()
@@ -115,6 +132,46 @@ def cmd_sync(args, logger: JsonLogger) -> int:
db.close()
def cmd_snapshot(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=False)
cfg = store.get()
db = _open_db(cfg, logger)
try:
repo = Repository(db)
client = _client(
cfg, logger, pacer=Pacer(1.0, 1.6), retries=1
)
service = SnapshotService(store, repo, client, logger=logger)
summary = service.run_if_needed(force=args.force)
payload = summary.as_dict()
payload["database"] = str(cfg.storage.database_path)
payload["snapshot_counts"] = repo.snapshot_counts(summary.stat_date)
payload["counts"] = repo.counts()
_print(payload)
return 0 if summary.completed else 1
finally:
db.close()
def cmd_auth_check(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=False)
cfg = store.get()
if not cfg.bilibili.cookie:
_print({"ok": False, "is_login": False, "message": "未配置 cookie"})
return 1
client = _client(cfg, logger, retries=1)
state = client.check_login()
_print({
"ok": state.is_login,
"is_login": state.is_login,
"mid": state.mid,
"uname": state.uname,
"code": state.code,
"message": state.message,
})
return 0 if state.is_login else 1
def cmd_run(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=True)
cfg = store.get()
@@ -122,26 +179,40 @@ def cmd_run(args, logger: JsonLogger) -> int:
last: dict[str, object] = {}
try:
repo = Repository(db)
client = BilibiliClient(
cookie=cfg.bilibili.cookie,
csrf=cfg.bilibili.csrf,
mid=cfg.bilibili.mid,
logger=logger,
)
service = FolderSyncService(store, repo, client, logger=logger)
folder_client = _client(cfg, logger)
folder_service = FolderSyncService(store, repo, folder_client, logger=logger)
snapshot_client = _client(cfg, logger, pacer=Pacer(1.0, 1.6), retries=1)
snapshot_service = SnapshotService(store, repo, snapshot_client, logger=logger)
def run_sync():
summary = service.run()
last["summary"] = summary
summary = folder_service.run()
last["folder"] = summary
logger.info("folder_sync.summary", **summary.as_dict())
return summary
def run_snapshot():
summary = snapshot_service.run_if_needed()
last["snapshot"] = summary
logger.info("snapshot.summary", **summary.as_dict())
return summary
hh, mm = parse_hhmm(cfg.schedule.snapshot_at)
snapshot_at = f"{hh:02d}:{mm:02d}"
scheduler = Scheduler(
[ScheduledTask(
"folder_sync",
float(cfg.schedule.folder_sync_minutes * 60),
run_sync,
)],
[
ScheduledTask(
"folder_sync",
float(cfg.schedule.folder_sync_minutes * 60),
run_sync,
),
ScheduledTask(
"snapshot",
86400.0,
run_snapshot,
next_run=0.0,
next_run_fn=lambda started: next_daily_run(started, snapshot_at),
),
],
logger=logger,
)
if args.once:
@@ -150,11 +221,16 @@ def cmd_run(args, logger: JsonLogger) -> int:
else:
logger.info(
"scheduler.start",
interval_seconds=cfg.schedule.folder_sync_minutes * 60,
folder_sync_seconds=cfg.schedule.folder_sync_minutes * 60,
snapshot_at=snapshot_at,
)
scheduler.run_forever()
summary = last.get("summary")
return 0 if summary is None or getattr(summary, "ok", True) else 1
folder_summary = last.get("folder")
snapshot_summary = last.get("snapshot")
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
finally:
db.close()
@@ -165,6 +241,15 @@ def cmd_status(args, logger: JsonLogger) -> int:
db = _open_db(cfg, logger)
try:
repo = Repository(db)
today = shanghai_date()
snapshot = repo.snapshot_counts(today)
snapshot.update({
"date": today,
"last_attempt": repo.kv_get("snapshot:last_attempt"),
"last_completed": repo.kv_get("snapshot:last_completed"),
"auth_ok": repo.kv_get("auth:ok"),
"auth_message": repo.kv_get("auth:message"),
})
_print({
"ok": True,
"schema_version": db.schema_version,
@@ -181,6 +266,7 @@ def cmd_status(args, logger: JsonLogger) -> int:
}
for row in repo.list_folders()
],
"snapshot": snapshot,
**repo.counts(),
})
finally:
@@ -196,6 +282,10 @@ def main(argv: Sequence[str] | None = None) -> int:
return cmd_init(args, logger)
if args.command == "sync":
return cmd_sync(args, logger)
if args.command == "snapshot":
return cmd_snapshot(args, logger)
if args.command == "auth-check":
return cmd_auth_check(args, logger)
if args.command == "run":
return cmd_run(args, logger)
if args.command == "status":
+20
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import os
import queue
import sqlite3
import threading
@@ -165,6 +166,25 @@ class Database:
finally:
conn.close()
def backup_to(self, target: str | Path) -> Path:
"""用 SQLite backup API 生成一致性备份;先写临时文件再原子替换。"""
target = Path(target)
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_name(target.name + ".tmp")
if tmp.exists():
tmp.unlink()
def run(conn: sqlite3.Connection) -> None:
dst = sqlite3.connect(tmp)
try:
conn.backup(dst)
finally:
dst.close()
self.write(run)
os.replace(tmp, target)
return target
def close(self) -> None:
if self._closed:
return
+131 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import sqlite3
import time
from dataclasses import dataclass
from typing import Any, Iterable
from typing import Any, Iterable, Mapping
from .config import FolderConfig
from .database import Database
@@ -368,6 +368,136 @@ class Repository:
return [dict(row) for row in rows]
return self.db.read(run)
# ------------------------------------------------------------ snapshot
def video_aids_for_snapshot(self) -> list[int]:
def run(conn: sqlite3.Connection) -> list[int]:
rows = conn.execute(
"SELECT aid FROM video WHERE status IN ('active', 'unfaved') "
"ORDER BY aid"
).fetchall()
return [int(row["aid"]) for row in rows]
return self.db.read(run)
def get_snapshot(self, stat_date: str, aid: int) -> dict[str, Any] | None:
def run(conn: sqlite3.Connection) -> dict[str, Any] | None:
row = conn.execute(
"SELECT * FROM snapshot WHERE stat_date = ? AND aid = ?",
(stat_date, int(aid)),
).fetchone()
return dict(row) if row else None
return self.db.read(run)
def upsert_snapshot(
self,
stat_date: str,
aid: int,
*,
metrics: Mapping[str, int] | None,
ok: bool,
err: str | None,
captured_at: int,
src: str = "view",
) -> None:
values = metrics or {}
def run(conn: sqlite3.Connection) -> None:
conn.execute(
"""
INSERT INTO snapshot(stat_date, aid, "view", "like", favorite, coin,
share, reply, danmaku, captured_at, ok, err, src)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stat_date, aid) DO UPDATE SET
"view" = excluded."view",
"like" = excluded."like",
favorite = excluded.favorite,
coin = excluded.coin,
share = excluded.share,
reply = excluded.reply,
danmaku = excluded.danmaku,
captured_at = excluded.captured_at,
ok = excluded.ok,
err = excluded.err,
src = excluded.src
""",
(
stat_date,
int(aid),
values.get("view"),
values.get("like"),
values.get("favorite"),
values.get("coin"),
values.get("share"),
values.get("reply"),
values.get("danmaku"),
int(captured_at),
1 if ok else 0,
err,
src,
),
)
self.db.write(run)
def snapshot_counts(self, stat_date: str) -> dict[str, int]:
def run(conn: sqlite3.Connection) -> dict[str, int]:
rows = conn.execute(
"SELECT ok, COUNT(*) AS n FROM snapshot WHERE stat_date = ? GROUP BY ok",
(stat_date,),
).fetchall()
by_ok = {int(row["ok"]): int(row["n"]) for row in rows}
return {
"total": sum(by_ok.values()),
"ok": by_ok.get(1, 0),
"failed": by_ok.get(0, 0),
}
return self.db.read(run)
def upsert_up_stat(
self,
stat_date: str,
mid: int,
*,
follower: int | None,
following: int | None,
ok: bool,
captured_at: int,
) -> None:
self.db.write(lambda conn: conn.execute(
"""
INSERT INTO up_stat(stat_date, mid, follower, following, captured_at, ok)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(stat_date, mid) DO UPDATE SET
follower = excluded.follower,
following = excluded.following,
captured_at = excluded.captured_at,
ok = excluded.ok
""",
(stat_date, int(mid), follower, following, int(captured_at), 1 if ok else 0),
))
def get_up_stat(self, stat_date: str, mid: int) -> dict[str, Any] | None:
def run(conn: sqlite3.Connection) -> dict[str, Any] | None:
row = conn.execute(
"SELECT * FROM up_stat WHERE stat_date = ? AND mid = ?",
(stat_date, int(mid)),
).fetchone()
return dict(row) if row else None
return self.db.read(run)
# ------------------------------------------------------------ kv
def kv_get(self, key: str) -> str | None:
def run(conn: sqlite3.Connection) -> str | None:
row = conn.execute("SELECT v FROM kv WHERE k = ?", (key,)).fetchone()
return None if row is None else str(row["v"])
return self.db.read(run)
def kv_set(self, key: str, value: str) -> None:
self.db.write(lambda conn: conn.execute(
"INSERT INTO kv(k, v) VALUES (?, ?) "
"ON CONFLICT(k) DO UPDATE SET v = excluded.v",
(key, str(value)),
))
# ------------------------------------------------------------ blacklist
def seed_blacklist(self, mids: Iterable[int], *, now: int | None = None) -> int:
+5 -1
View File
@@ -18,6 +18,7 @@ class ScheduledTask:
interval: float
fn: Callable[[], Any]
next_run: float = 0.0
next_run_fn: Callable[[float], float] | None = None
@dataclass
@@ -77,7 +78,10 @@ class Scheduler:
else:
self._log("info", "scheduler.task_ok", task=task.name)
finished = self._clock()
task.next_run = started + task.interval
if task.next_run_fn is not None:
task.next_run = task.next_run_fn(started)
else:
task.next_run = started + task.interval
result = TaskResult(
name=task.name,
started_at=started,
+50
View File
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""备份与保留期清理。"""
from __future__ import annotations
import time
from pathlib import Path
from ..database import Database
def daily_backup_path(backup_dir: str | Path, stat_date: str) -> Path:
compact = stat_date.replace("-", "")
return Path(backup_dir) / f"app-{compact}.db"
def backup_daily(
db: Database,
backup_dir: str | Path,
stat_date: str,
*,
logger=None,
) -> Path:
target = daily_backup_path(backup_dir, stat_date)
db.backup_to(target)
if logger is not None:
logger.info("backup.daily", path=str(target), stat_date=stat_date)
return target
def prune_daily_backups(
backup_dir: str | Path,
*,
keep_days: int,
now: float | None = None,
) -> list[Path]:
"""只清理 ``app-YYYYMMDD.db`` 日备份,不动迁移备份。"""
target_dir = Path(backup_dir)
if not target_dir.is_dir():
return []
cutoff = (time.time() if now is None else now) - keep_days * 86400
removed: list[Path] = []
for path in target_dir.glob("app-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].db"):
try:
if path.stat().st_mtime < cutoff:
path.unlink()
removed.append(path)
except OSError:
continue
return removed
+313
View File
@@ -0,0 +1,313 @@
# -*- coding: utf-8 -*-
"""每日视频七项快照与粉丝数采集。
规则(DEVELOPMENT.md §7.4):
* 对 ``active`` / ``unfaved`` 视频逐条请求 ``view``;
* 成功写入七项指标;``ok=1`` 的同日快照直接跳过(断点续跑);
* 视频不可访问时标记 ``removed``;
* 其他错误最多重试 3 次,仍失败写 ``ok=0`` 占位,不写假零值;
* 对 ``track_fans`` 逐个请求 ``relation/stat``;
* 完成后写入当日完成标记并生成日备份。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from ..bilibili.client import (
VIDEO_UNAVAILABLE_CODES,
BilibiliAPIError,
BilibiliClient,
BilibiliError,
BilibiliHTTPError,
)
from ..config import AppConfig, ConfigStore
from ..repository import Repository
from ..timeutil import is_date, shanghai_date
from .maintenance import backup_daily, prune_daily_backups
@dataclass
class SnapshotSummary:
stat_date: str
started_at: int
total: int = 0
success: int = 0
failed: int = 0
skipped: int = 0
removed: int = 0
fans_ok: int = 0
fans_failed: int = 0
auth_ok: bool | None = None
auth_message: str = ""
backup_path: str | None = None
backup_error: str | None = None
completed: bool = False
already_completed: bool = False
finished_at: int = 0
errors: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"stat_date": self.stat_date,
"started_at": self.started_at,
"finished_at": self.finished_at,
"elapsed": max(0, self.finished_at - self.started_at),
"total": self.total,
"success": self.success,
"failed": self.failed,
"skipped": self.skipped,
"removed": self.removed,
"fans_ok": self.fans_ok,
"fans_failed": self.fans_failed,
"auth_ok": self.auth_ok,
"auth_message": self.auth_message,
"backup_path": self.backup_path,
"backup_error": self.backup_error,
"completed": self.completed,
"already_completed": self.already_completed,
"errors": self.errors,
}
class SnapshotService:
def __init__(
self,
config_store: ConfigStore,
repository: Repository,
client: BilibiliClient,
*,
clock=time.time,
sleeper=time.sleep,
logger=None,
retries: int = 3,
backoff: float = 5.0,
progress_every: int = 50,
) -> None:
if retries < 1:
raise ValueError("retries 必须 >= 1")
self.config_store = config_store
self.repo = repository
self.client = client
self._clock = clock
self._sleep = sleeper
self.logger = logger
self.retries = retries
self.backoff = backoff
self.progress_every = progress_every
def _now(self) -> int:
return int(self._clock())
def _log(self, level: str, event: str, **fields) -> None:
if self.logger is not None:
getattr(self.logger, level)(event, **fields)
@staticmethod
def completed_key(stat_date: str) -> str:
return f"snapshot:completed:{stat_date}"
def run_if_needed(
self, *, date: str | None = None, force: bool = False
) -> SnapshotSummary:
now = self._now()
stat_date = date or shanghai_date(now)
if not is_date(stat_date):
raise ValueError(f"日期格式必须是 YYYY-MM-DD: {stat_date!r}")
if not force and self.repo.kv_get(self.completed_key(stat_date)) == "1":
self._log("info", "snapshot.skip", stat_date=stat_date, reason="already_completed")
return SnapshotSummary(
stat_date=stat_date,
started_at=now,
finished_at=now,
completed=True,
already_completed=True,
)
return self.run(date=stat_date, force=force)
def run(self, *, date: str | None = None, force: bool = False) -> SnapshotSummary:
cfg = self.config_store.get()
now = self._now()
stat_date = date or shanghai_date(now)
if not is_date(stat_date):
raise ValueError(f"日期格式必须是 YYYY-MM-DD: {stat_date!r}")
summary = SnapshotSummary(stat_date=stat_date, started_at=now)
self._log("info", "snapshot.start", stat_date=stat_date, force=force)
summary.auth_ok, summary.auth_message = self._check_auth(cfg, now)
videos = self.repo.video_aids_for_snapshot()
summary.total = len(videos)
for index, aid in enumerate(videos, 1):
existing = self.repo.get_snapshot(stat_date, aid)
if existing is not None and int(existing.get("ok") or 0) == 1 and not force:
summary.skipped += 1
continue
stat, error, unavailable = self._fetch_view(aid)
if unavailable:
summary.removed += 1
self.repo.set_video_status(aid, "removed", now=now)
self._log(
"warning", "snapshot.removed",
stat_date=stat_date, aid=aid, error=error,
)
elif stat is not None:
self.repo.upsert_snapshot(
stat_date,
aid,
metrics=stat.metrics(),
ok=True,
err=None,
captured_at=self._now(),
)
summary.success += 1
else:
summary.failed += 1
self.repo.upsert_snapshot(
stat_date,
aid,
metrics=None,
ok=False,
err=error,
captured_at=self._now(),
)
summary.errors.append(f"aid {aid}: {error}")
self._log(
"error", "snapshot.failed",
stat_date=stat_date, aid=aid, error=error,
)
if self.progress_every > 0 and (
index % self.progress_every == 0 or index == summary.total
):
self._log(
"info", "snapshot.progress",
stat_date=stat_date, done=index, total=summary.total,
success=summary.success, failed=summary.failed,
skipped=summary.skipped, removed=summary.removed,
)
for mid in cfg.track_fans:
stat, error = self._fetch_fan(mid)
if stat is not None:
self.repo.upsert_up_stat(
stat_date,
mid,
follower=stat.follower,
following=stat.following,
ok=True,
captured_at=self._now(),
)
summary.fans_ok += 1
else:
summary.fans_failed += 1
self.repo.upsert_up_stat(
stat_date,
mid,
follower=None,
following=None,
ok=False,
captured_at=self._now(),
)
summary.errors.append(f"fans mid {mid}: {error}")
self._log(
"error", "snapshot.fan_failed",
stat_date=stat_date, mid=mid, error=error,
)
summary.completed = summary.failed == 0 and summary.fans_failed == 0
self.repo.kv_set(self.completed_key(stat_date), "1" if summary.completed else "0")
self.repo.kv_set("snapshot:last_attempt", stat_date)
if summary.completed:
self.repo.kv_set("snapshot:last_completed", stat_date)
elif self.repo.kv_get("snapshot:last_completed") == stat_date:
self.repo.kv_set("snapshot:last_completed", "")
try:
path = backup_daily(
self.repo.db,
cfg.storage.backup_dir,
stat_date,
logger=self.logger,
)
prune_daily_backups(
cfg.storage.backup_dir,
keep_days=cfg.storage.backup_keep_days,
)
summary.backup_path = str(path)
except Exception as exc: # noqa: BLE001 备份失败不推翻已采集数据
summary.backup_error = str(exc)
self._log("error", "backup.failed", error=str(exc), stat_date=stat_date)
summary.finished_at = self._now()
self._log(
"info" if summary.completed else "warning",
"snapshot.done",
**summary.as_dict(),
)
return summary
# ------------------------------------------------------------ internals
def _check_auth(self, cfg: AppConfig, now: int) -> tuple[bool, str]:
if not cfg.bilibili.cookie:
self.repo.kv_set("auth:last_check", str(now))
self.repo.kv_set("auth:ok", "0")
self.repo.kv_set("auth:message", "未配置 cookie")
return False, "未配置 cookie"
try:
state = self.client.check_login()
except BilibiliError as exc:
message = str(exc)
self.repo.kv_set("auth:last_check", str(now))
self.repo.kv_set("auth:ok", "0")
self.repo.kv_set("auth:message", message)
self._log("warning", "auth.invalid", message=message)
return False, message
self.repo.kv_set("auth:last_check", str(now))
self.repo.kv_set("auth:ok", "1" if state.is_login else "0")
self.repo.kv_set("auth:message", state.message)
if state.mid is not None:
self.repo.kv_set("auth:mid", str(state.mid))
if not state.is_login:
self._log(
"warning", "auth.invalid",
code=state.code, message=state.message,
)
return state.is_login, state.message
def _fetch_view(self, aid: int):
last_error = ""
for attempt in range(1, self.retries + 1):
try:
return self.client.view(aid), None, False
except BilibiliAPIError as exc:
last_error = str(exc)
if exc.code in VIDEO_UNAVAILABLE_CODES:
return None, last_error, True
except BilibiliHTTPError as exc:
last_error = str(exc)
if exc.status == 404:
return None, last_error, True
except BilibiliError as exc:
last_error = str(exc)
if attempt < self.retries:
self._sleep(self.backoff * (2 ** (attempt - 1)))
return None, last_error or "未知错误", False
def _fetch_fan(self, mid: int):
last_error = ""
for attempt in range(1, self.retries + 1):
try:
return self.client.relation_stat(mid), None
except BilibiliError as exc:
last_error = str(exc)
if attempt < self.retries:
self._sleep(self.backoff * (2 ** (attempt - 1)))
return None, last_error or "未知错误"
+59
View File
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""上海时区与每日任务时间计算。
B 站数据口径使用 ``Asia/Shanghai``。中国自 1991 年起不再使用夏令时,因此这里
用固定 UTC+8,避免 Windows 上依赖未安装的 ``tzdata`` 包。
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timedelta, timezone
SHANGHAI_TZ = timezone(timedelta(hours=8))
_HHMM_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def shanghai_datetime(ts: float | None = None) -> datetime:
return datetime.fromtimestamp(time.time() if ts is None else ts, SHANGHAI_TZ)
def shanghai_date(ts: float | None = None) -> str:
return shanghai_datetime(ts).strftime("%Y-%m-%d")
def parse_hhmm(value: str) -> tuple[int, int]:
match = _HHMM_RE.match(value or "")
if not match:
raise ValueError(f"时间必须是 HH:MM(24 小时制): {value!r}")
return int(match.group(1)), int(match.group(2))
def next_daily_run(now: float, hhmm: str) -> float:
"""返回 ``now`` 之后(严格大于)最近的 ``hh:mm`` 上海时间时间戳。"""
hour, minute = parse_hhmm(hhmm)
current = shanghai_datetime(now)
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target.timestamp() <= now:
target += timedelta(days=1)
return target.timestamp()
def is_date(value: str) -> bool:
if not _DATE_RE.match(value or ""):
return False
try:
datetime.strptime(value, "%Y-%m-%d")
except ValueError:
return False
return True
def day_bounds(date: str) -> tuple[float, float]:
"""返回上海时区某一天的 [start, end) 时间戳。"""
if not is_date(date):
raise ValueError(f"日期格式必须是 YYYY-MM-DD: {date!r}")
start = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=SHANGHAI_TZ)
return start.timestamp(), (start + timedelta(days=1)).timestamp()