Files
re-xxm/tests/test_snapshot.py
T
bot-ci 8a9cb21408 feat: EOD 23:15 seal + midnight seed shell for video/fans
Move daily snapshot/fans to 23:15 and add 00:01 seed that clones
last_completed ok=1 rows into today without advancing completed.
Evening runs force through seed markers; growth previous is per-aid
adjacent. Home shows short-update clock + 全站数据 date, and 收录视频
counts.
2026-11-02 12:38:55 +08:00

648 lines
25 KiB
Python

# -*- 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, BaseException):
raise payload
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({
"bilibili": {
"read": {"mid": 28802351, "csrf": "csrf", "cookie": cookie},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-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"
assert repo.get_video(1)["up_face"] == (
"http://i1.hdslb.com/bfs/face/example.jpg"
)
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 True
assert repo.kv_get("snapshot:last_completed") == DATE
row = repo.get_up_stat(DATE, 37754047)
assert row["ok"] == 0
assert row["follower"] is None and row["following"] is None
fans_only = service.run_fans(date=DATE, force=True)
assert fans_only.fans_failed == 1
assert fans_only.completed is False
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 test_new_video_after_completion_backfills_same_day():
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({1: view_for(1, view=100)})
db, repo, service = make_service(tmp, transport, track_fans=())
try:
add_video(repo, 1)
first = service.run(date=DATE)
assert first.completed is True
assert transport.count("/web-interface/view") == 1
# 当天 11:00 新入库:完成标记仍是 1,但必须补采新视频
add_video(repo, 2)
transport.views[2] = view_for(2, view=222)
second = service.run_if_needed(date=DATE)
assert second.already_completed is False
assert second.success == 1
assert second.skipped == 1
assert transport.count("/web-interface/view") == 2
assert repo.get_snapshot(DATE, 2)["view"] == 222
assert repo.get_snapshot(DATE, 1)["view"] == 100
finally:
db.close()
def test_fan_resume_preserves_successful_value():
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport(
{1: {"code": -500, "message": "x", "data": None}},
)
db, repo, service = make_service(tmp, transport, retries=1)
try:
add_video(repo, 1)
first = service.run(date=DATE)
assert first.failed == 1
assert first.fans_ok == 1
assert repo.get_up_stat(DATE, 37754047)["follower"] == 2767268
transport.views[1] = view_for(1, view=111)
transport.fan = {"code": -500, "message": "x", "data": None}
second = service.run(date=DATE)
assert second.fans_skipped == 1
assert second.fans_failed == 0
fan = repo.get_up_stat(DATE, 37754047)
assert fan["follower"] == 2767268
assert fan["ok"] == 1
finally:
db.close()
def test_force_interrupt_leaves_incomplete_marker():
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({
1: view_for(1),
2: RuntimeError("simulated interrupt"),
})
db, repo, service = make_service(tmp, transport, track_fans=())
try:
add_video(repo, 1)
add_video(repo, 2)
repo.kv_set(service.completed_key(DATE), "1")
repo.kv_set("snapshot:last_completed", DATE)
try:
service.run(date=DATE, force=True)
except RuntimeError:
pass
else:
raise AssertionError("应模拟中断")
assert repo.kv_get(service.completed_key(DATE)) == "0"
assert repo.kv_get("snapshot:last_completed") == ""
transport.views[2] = view_for(2)
resumed = service.run_if_needed(date=DATE)
assert resumed.completed is True
assert resumed.success == 1
assert resumed.skipped == 1
finally:
db.close()
def test_backup_failure_retries_without_api():
import reaction_board.services.snapshot as snapshot_module
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({1: view_for(1)})
db, repo, service = make_service(tmp, transport, track_fans=())
original = snapshot_module.backup_daily
def fail_backup(*args, **kwargs):
raise OSError("disk full")
snapshot_module.backup_daily = fail_backup
try:
first = service.run(date=DATE)
finally:
snapshot_module.backup_daily = original
assert first.completed is True
assert first.backup_error
assert repo.kv_get(service.backup_key(DATE)) == "0"
view_calls = transport.count("/web-interface/view")
retried = service.run_if_needed(date=DATE)
assert retried.already_completed is True
assert retried.backup_error is None
assert Path(retried.backup_path or "").exists()
assert repo.kv_get(service.backup_key(DATE)) == "1"
assert transport.count("/web-interface/view") == view_calls
db.close()
def test_run_recent_videos_refreshes_without_completion_markers():
"""小时任务只刷新近窗新片,不写完成日 / 备份。"""
clock = 1_800_000_000
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({
1: view_for(1, view=111),
2: view_for(2, view=222),
3: view_for(3, view=333),
})
db, repo, service = make_service(tmp, transport, track_fans=())
try:
# 1/2 在墙钟近 2 天内;3 为旧片
add_video(repo, 1)
add_video(repo, 2)
add_video(repo, 3)
repo.db.write(lambda conn: conn.executemany(
"UPDATE video SET pubdate = ? WHERE aid = ?",
[
(clock - 3600, 1),
(clock - 86400, 2),
(clock - 10 * 86400, 3),
],
))
# 预置同日 ok=1,验证小时任务可覆盖刷新
repo.upsert_snapshot(
DATE, 1,
metrics={"view": 1, "like": 0, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0},
ok=True, err=None, captured_at=clock - 100,
)
assert repo.kv_get(service.completed_key(DATE)) is None
summary = service.run_recent_videos(date=DATE, recent_days=2)
assert summary.total == 2
assert summary.success == 2
assert summary.failed == 0
assert set(repo.video_aids_recent_for_snapshot(
since_pubdate=clock - 2 * 86400
)) == {1, 2}
row = repo.db.read(lambda conn: dict(conn.execute(
"SELECT view, ok, captured_at FROM snapshot WHERE stat_date=? AND aid=1",
(DATE,),
).fetchone()))
assert row["view"] == 111
assert row["ok"] == 1
assert row["captured_at"] == clock
# 完成日 / 备份标记保持原样
assert repo.kv_get(service.completed_key(DATE)) is None
assert repo.kv_get("snapshot:last_completed") is None
assert repo.kv_get(service.backup_key(DATE)) is None
assert transport.count("/web-interface/view") == 2
# 全量任务随后才写完成日;且已 ok=1 的跳过
full = service.run_videos(date=DATE)
assert full.skipped >= 2 # 1、2 已被小时任务写过
assert full.success >= 1 # 至少旧片 3
assert repo.kv_get(service.completed_key(DATE)) == "1"
assert repo.kv_get("snapshot:last_completed") == DATE
finally:
db.close()
def test_full_snapshot_skips_ok_rows_including_hourly_refresh():
"""晚间全量(未 force):同日 ok=1(含小时任务刚刷)直接跳过。"""
clock = 1_800_000_000
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({1: view_for(1), 2: view_for(2)})
db, repo, service = make_service(tmp, transport, track_fans=())
try:
add_video(repo, 1)
add_video(repo, 2)
repo.upsert_snapshot(
DATE, 1,
metrics={"view": 9, "like": 0, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0},
ok=True, err=None, captured_at=clock - 30, # 「1 小时内」
)
result = service.run_videos(date=DATE)
assert result.skipped == 1
assert result.success == 1
# aid=1 保持小时任务写入的 view=9,未被全量覆盖
row = repo.db.read(lambda conn: dict(conn.execute(
"SELECT view FROM snapshot WHERE stat_date=? AND aid=1",
(DATE,),
).fetchone()))
assert row["view"] == 9
assert transport.count("/web-interface/view") == 1
finally:
db.close()
def test_seed_from_last_completed_does_not_mark_completed():
clock = 1_800_000_000
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({1: view_for(1)})
db, repo, service = make_service(tmp, transport, track_fans=(37754047,))
try:
add_video(repo, 1)
yday = "2026-10-30"
today = "2026-10-31"
repo.upsert_snapshot(
yday, 1,
metrics={"view": 10, "like": 1, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0},
ok=True, err=None, captured_at=clock - 86400,
)
repo.upsert_up_stat(
yday, 37754047, follower=100, following=1, ok=True,
captured_at=clock - 86400,
)
repo.kv_set("snapshot:last_completed", yday)
repo.kv_set(service.completed_key(yday), "1")
summary = service.run_seed_from_last_completed(date=today)
assert summary.success == 1
assert summary.fans_ok == 1
assert repo.kv_get(service.seeded_key(today)) == "1"
assert repo.kv_get(service.fans_seeded_key(today)) == "1"
assert repo.kv_get(service.completed_key(today)) is None
assert repo.kv_get("snapshot:last_completed") == yday
row = repo.get_snapshot(today, 1)
assert row is not None
assert int(row["view"]) == 10
assert row["src"] == "seed"
fan = repo.get_up_stat(today, 37754047)
assert fan is not None and int(fan["follower"]) == 100
# 幂等:再 seed 不重复
again = service.run_seed_from_last_completed(date=today)
assert again.already_completed is True
assert transport.count("/web-interface/view") == 0
finally:
db.close()
def test_seeded_day_skips_without_force_and_seals_with_force():
clock = 1_800_000_000
with tempfile.TemporaryDirectory() as tmp:
fan_payload = dict(RELATION_PAYLOAD)
fan_payload["data"] = dict(RELATION_PAYLOAD["data"])
fan_payload["data"]["follower"] = 999
transport = SnapshotTransport({1: view_for(1, view=555)}, fan=fan_payload)
db, repo, service = make_service(tmp, transport, track_fans=(37754047,))
try:
add_video(repo, 1)
yday = "2026-10-30"
today = DATE
repo.upsert_snapshot(
yday, 1,
metrics={"view": 10, "like": 1, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0},
ok=True, err=None, captured_at=clock - 86400,
)
repo.upsert_up_stat(
yday, 37754047, follower=100, following=1, ok=True,
captured_at=clock - 86400,
)
repo.kv_set("snapshot:last_completed", yday)
service.run_seed_from_last_completed(date=today)
pending = service.run_if_needed(date=today)
assert pending.completed is False
assert pending.already_completed is False
assert repo.kv_get(service.seeded_key(today)) == "1"
assert transport.count("/web-interface/view") == 0
sealed = service.run_if_needed(date=today, force=True)
assert sealed.completed is True
assert sealed.success >= 1
assert repo.kv_get(service.completed_key(today)) == "1"
assert repo.kv_get("snapshot:last_completed") == today
assert repo.kv_get(service.seeded_key(today)) is None
row = repo.get_snapshot(today, 1)
assert int(row["view"]) == 555 # 被真实 view 覆盖
assert row["src"] == "view"
fans = service.run_fans_if_needed(date=today, force=True)
assert fans.fans_ok >= 1
assert repo.kv_get(service.fans_seeded_key(today)) is None
assert int(repo.get_up_stat(today, 37754047)["follower"]) == 999
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()
test_new_video_after_completion_backfills_same_day()
test_fan_resume_preserves_successful_value()
test_force_interrupt_leaves_incomplete_marker()
test_backup_failure_retries_without_api()
test_run_recent_videos_refreshes_without_completion_markers()
test_full_snapshot_skips_ok_rows_including_hourly_refresh()
test_seed_from_last_completed_does_not_mark_completed()
test_seeded_day_skips_without_force_and_seals_with_force()
print("OK: 每日快照、断点续跑、失败占位与粉丝采集测试全部通过")
if __name__ == "__main__":
main()