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:
1 parent
ee289bfa56
commit
5c2ab7b187
20 files changed
+1680
-54
No files matched your search
@@ -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 ""),
|
||||
)
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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 "未知错误"
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user