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
@@ -0,0 +1,331 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""每日快照与粉丝采集测试(transport 边界 Mock,不联网):
|
||||
uv run python tests/test_snapshot.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from fixtures import ( # noqa: E402
|
||||
NAV_PAYLOAD,
|
||||
RELATION_PAYLOAD,
|
||||
VIEW_UNAVAILABLE,
|
||||
view_for,
|
||||
)
|
||||
from reaction_board.bilibili.client import ( # noqa: E402
|
||||
BilibiliClient,
|
||||
HTTPResponse,
|
||||
Pacer,
|
||||
)
|
||||
from reaction_board.bilibili.models import FavResource # noqa: E402
|
||||
from reaction_board.config import ConfigStore, FolderConfig # noqa: E402
|
||||
from reaction_board.database import Database # noqa: E402
|
||||
from reaction_board.logging_json import JsonLogger # noqa: E402
|
||||
from reaction_board.repository import Repository # noqa: E402
|
||||
from reaction_board.services.snapshot import SnapshotService # noqa: E402
|
||||
|
||||
DATE = "2026-10-31"
|
||||
FOLDER = FolderConfig(year=2026, media_id=3700023351, title="2026")
|
||||
|
||||
|
||||
def http_json(payload):
|
||||
return HTTPResponse(
|
||||
200, {"content-type": "application/json"},
|
||||
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
class SnapshotTransport:
|
||||
def __init__(self, views, *, nav=None, fan=None):
|
||||
self.views = views
|
||||
self.nav = NAV_PAYLOAD if nav is None else nav
|
||||
self.fan = RELATION_PAYLOAD if fan is None else fan
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, headers, data=None, timeout=30):
|
||||
self.calls.append(url)
|
||||
if "/x/web-interface/nav" in url:
|
||||
return http_json(self.nav)
|
||||
if "/x/web-interface/view" in url:
|
||||
aid = int(urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(url).query
|
||||
)["aid"][0])
|
||||
payload = self.views[aid]
|
||||
if isinstance(payload, HTTPResponse):
|
||||
return payload
|
||||
return http_json(payload)
|
||||
if "/x/relation/stat" in url:
|
||||
return http_json(self.fan)
|
||||
raise AssertionError(f"未预期的 URL: {url}")
|
||||
|
||||
def count(self, part):
|
||||
return sum(1 for url in self.calls if part in url)
|
||||
|
||||
|
||||
def make_service(tmp, transport, *, track_fans=(37754047,), logger=None,
|
||||
retries=3, cookie="SESSDATA=secret; buvid3=device"):
|
||||
path = Path(tmp) / "config.json"
|
||||
path.write_text(json.dumps({
|
||||
"mid": 28802351,
|
||||
"csrf": "csrf",
|
||||
"cookie": cookie,
|
||||
"storage": {
|
||||
"database_path": str(Path(tmp) / "app.db"),
|
||||
"backup_dir": str(Path(tmp) / "backup"),
|
||||
"backup_keep_days": 14,
|
||||
},
|
||||
"folders": [],
|
||||
"track_fans": list(track_fans),
|
||||
}, ensure_ascii=False), encoding="utf-8")
|
||||
store = ConfigStore(path, env={}, require_auth=False)
|
||||
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)
|
||||
client = BilibiliClient(
|
||||
cookie=cookie,
|
||||
csrf="csrf",
|
||||
mid=28802351,
|
||||
transport=transport,
|
||||
pacer=Pacer(0, 0),
|
||||
retries=1,
|
||||
)
|
||||
service = SnapshotService(
|
||||
store, repo, client,
|
||||
clock=lambda: 1_800_000_000,
|
||||
sleeper=lambda seconds: None,
|
||||
logger=logger,
|
||||
retries=retries,
|
||||
backoff=0,
|
||||
)
|
||||
return db, repo, service
|
||||
|
||||
|
||||
def add_video(repo, aid, *, status="active"):
|
||||
repo.upsert_from_fav(
|
||||
FavResource(
|
||||
aid=aid, bvid=f"BV{aid}", title=f"视频{aid}", up_mid=100,
|
||||
up_name="UP", pubdate=1_700_000_000, duration=600,
|
||||
cover=None, attr=0, fav_time=None,
|
||||
),
|
||||
FOLDER.media_id,
|
||||
was_added=True,
|
||||
)
|
||||
if status != "active":
|
||||
repo.set_video_status(aid, status)
|
||||
|
||||
|
||||
def test_snapshot_success_resume_and_force():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({
|
||||
1: view_for(1, view=100, like=10, coin=2, favorite=3,
|
||||
share=1, reply=4, danmaku=5),
|
||||
2: view_for(2, view=200, like=20, coin=4, favorite=6,
|
||||
share=2, reply=8, danmaku=10),
|
||||
})
|
||||
db, repo, service = make_service(tmp, transport)
|
||||
try:
|
||||
add_video(repo, 1)
|
||||
add_video(repo, 2)
|
||||
result = service.run(date=DATE)
|
||||
assert result.completed is True
|
||||
assert result.total == 2
|
||||
assert result.success == 2
|
||||
assert result.failed == 0
|
||||
assert result.fans_ok == 1
|
||||
assert result.fans_failed == 0
|
||||
assert result.auth_ok is True
|
||||
assert Path(result.backup_path or "").exists()
|
||||
|
||||
row = repo.get_snapshot(DATE, 1)
|
||||
assert (row["view"], row["like"], row["favorite"], row["coin"],
|
||||
row["share"], row["reply"], row["danmaku"]) == (100, 10, 3, 2, 1, 4, 5)
|
||||
assert row["ok"] == 1 and row["src"] == "view"
|
||||
fan = repo.get_up_stat(DATE, 37754047)
|
||||
assert fan["follower"] == 2767268 and fan["ok"] == 1
|
||||
assert repo.kv_get(service.completed_key(DATE)) == "1"
|
||||
assert repo.kv_get("snapshot:last_completed") == DATE
|
||||
assert repo.snapshot_counts(DATE) == {"total": 2, "ok": 2, "failed": 0}
|
||||
|
||||
# 已完成 -> 不再请求
|
||||
view_before = transport.count("/web-interface/view")
|
||||
again = service.run_if_needed(date=DATE)
|
||||
assert again.already_completed is True
|
||||
assert transport.count("/web-interface/view") == view_before
|
||||
|
||||
# force 重新采集
|
||||
forced = service.run(date=DATE, force=True)
|
||||
assert forced.success == 2
|
||||
assert transport.count("/web-interface/view") == view_before + 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_resume_skips_ok_and_retries_failed():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({
|
||||
1: view_for(1, view=111),
|
||||
2: view_for(2, view=222),
|
||||
})
|
||||
db, repo, service = make_service(tmp, transport)
|
||||
try:
|
||||
add_video(repo, 1)
|
||||
add_video(repo, 2)
|
||||
repo.upsert_snapshot(
|
||||
DATE, 1, metrics={"view": 1, "like": 1, "favorite": 1,
|
||||
"coin": 1, "share": 1, "reply": 1, "danmaku": 1},
|
||||
ok=True, err=None, captured_at=1,
|
||||
)
|
||||
repo.upsert_snapshot(
|
||||
DATE, 2, metrics=None, ok=False, err="previous error",
|
||||
captured_at=1,
|
||||
)
|
||||
result = service.run(date=DATE)
|
||||
assert result.skipped == 1
|
||||
assert result.success == 1
|
||||
assert result.completed is True
|
||||
assert transport.count("/web-interface/view") == 1
|
||||
assert repo.get_snapshot(DATE, 1)["view"] == 1 # 未被覆盖
|
||||
assert repo.get_snapshot(DATE, 2)["view"] == 222
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_unavailable_marks_removed():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({
|
||||
1: VIEW_UNAVAILABLE,
|
||||
2: HTTPResponse(404, {}, b"not found"),
|
||||
})
|
||||
db, repo, service = make_service(tmp, transport, track_fans=())
|
||||
try:
|
||||
add_video(repo, 1)
|
||||
add_video(repo, 2)
|
||||
result = service.run(date=DATE)
|
||||
assert result.removed == 2
|
||||
assert result.failed == 0
|
||||
assert result.completed is True
|
||||
assert repo.get_video(1)["status"] == "removed"
|
||||
assert repo.get_video(2)["status"] == "removed"
|
||||
assert repo.get_snapshot(DATE, 1) is None
|
||||
assert repo.get_snapshot(DATE, 2) is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_other_error_writes_failed_placeholder():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({
|
||||
1: {"code": -500, "message": "服务异常", "data": None},
|
||||
})
|
||||
db, repo, service = make_service(tmp, transport, track_fans=(), retries=3)
|
||||
try:
|
||||
add_video(repo, 1)
|
||||
result = service.run(date=DATE)
|
||||
assert result.failed == 1
|
||||
assert result.success == 0
|
||||
assert result.completed is False
|
||||
row = repo.get_snapshot(DATE, 1)
|
||||
assert row["ok"] == 0
|
||||
assert row["view"] is None and row["like"] is None
|
||||
assert "code=-500" in row["err"]
|
||||
assert repo.kv_get(service.completed_key(DATE)) == "0"
|
||||
# 每次业务失败会走匿名+完整 Cookie 两条请求,服务层重试 3 次
|
||||
assert transport.count("/web-interface/view") == 6
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_fan_failure_placeholder():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({}, fan={"code": -500, "message": "x", "data": None})
|
||||
db, repo, service = make_service(tmp, transport, retries=2)
|
||||
try:
|
||||
result = service.run(date=DATE)
|
||||
assert result.total == 0
|
||||
assert result.fans_failed == 1
|
||||
assert result.completed is False
|
||||
row = repo.get_up_stat(DATE, 37754047)
|
||||
assert row["ok"] == 0
|
||||
assert row["follower"] is None and row["following"] is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_auth_invalid_is_warned_but_snapshot_continues():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
stream = io.StringIO()
|
||||
logger = JsonLogger(stream)
|
||||
transport = SnapshotTransport(
|
||||
{}, nav={"code": 0, "message": "0", "ttl": 1,
|
||||
"data": {"isLogin": False, "mid": 0, "uname": ""}},
|
||||
fan=RELATION_PAYLOAD,
|
||||
)
|
||||
db, repo, service = make_service(tmp, transport, logger=logger)
|
||||
try:
|
||||
result = service.run(date=DATE)
|
||||
assert result.auth_ok is False
|
||||
assert result.completed is True
|
||||
assert repo.kv_get("auth:ok") == "0"
|
||||
events = [json.loads(line) for line in stream.getvalue().splitlines()]
|
||||
assert any(e["event"] == "auth.invalid" for e in events)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_no_cookie_skips_nav_request():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({}, fan=RELATION_PAYLOAD)
|
||||
db, repo, service = make_service(tmp, transport, cookie="")
|
||||
try:
|
||||
result = service.run(date=DATE)
|
||||
assert result.auth_ok is False
|
||||
assert result.auth_message == "未配置 cookie"
|
||||
assert transport.count("/web-interface/nav") == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_force_failure_clears_last_completed():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({1: view_for(1, view=100)})
|
||||
db, repo, service = make_service(tmp, transport, track_fans=(), retries=1)
|
||||
try:
|
||||
add_video(repo, 1)
|
||||
first = service.run(date=DATE)
|
||||
assert first.completed is True
|
||||
assert repo.kv_get("snapshot:last_completed") == DATE
|
||||
|
||||
transport.views[1] = {"code": -500, "message": "x", "data": None}
|
||||
second = service.run(date=DATE, force=True)
|
||||
assert second.failed == 1
|
||||
assert second.completed is False
|
||||
assert repo.kv_get("snapshot:last_completed") == ""
|
||||
assert repo.get_snapshot(DATE, 1)["ok"] == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_snapshot_success_resume_and_force()
|
||||
test_resume_skips_ok_and_retries_failed()
|
||||
test_unavailable_marks_removed()
|
||||
test_other_error_writes_failed_placeholder()
|
||||
test_fan_failure_placeholder()
|
||||
test_auth_invalid_is_warned_but_snapshot_continues()
|
||||
test_no_cookie_skips_nav_request()
|
||||
test_force_failure_clears_last_completed()
|
||||
print("OK: 每日快照、断点续跑、失败占位与粉丝采集测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user