feat(up): capture follower snapshots at ingest and backfill history

This commit is contained in:
kairee committed 2026-11-01 11:18:06 +08:00
1 parent 4942d1d161
commit 519fc39fca
8 files changed
+401 -14

No files matched your search

+16 -2
View File
@@ -51,7 +51,7 @@ def test_init_and_status():
path = write_config(tmp)
code, output = run_cli(["init", "--config", str(path)])
assert code == 0
assert '"schema_version": 3' in output
assert '"schema_version": 4' in output
db = Database(Path(tmp) / "app.db")
db.initialize()
@@ -319,6 +319,19 @@ def test_candidates_and_discover_commands():
assert '"notified": 1' in output
def test_backfill_followers_dry_run():
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="")
code, output = run_cli([
"backfill-followers", "--dry-run", "--config", str(path),
])
assert code == 0
assert '"requested": 0' in output
assert '"failed": 0' in output
assert '"dry_run": true' in output
assert '"total": 0' in output
def main_test():
test_init_and_status()
test_missing_config()
@@ -331,7 +344,8 @@ def main_test():
test_default_command_starts_web_and_scheduler()
test_snapshot_backup_failure_returns_error_and_retries()
test_candidates_and_discover_commands()
print("OK: CLI init/sync/snapshot/auth-check/run/status/错误路径测试全部通过")
test_backfill_followers_dry_run()
print("OK: CLI init/sync/snapshot/auth-check/run/status/backfill/错误路径测试全部通过")
if __name__ == "__main__":
+17
View File
@@ -45,9 +45,17 @@ class FakeFavTransport:
self.omit_ids_data = omit_ids_data
self.ids_calls = []
self.info_calls = []
self.relation_calls = []
def request(self, method, url, headers, data=None, timeout=30):
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
if "/x/relation/stat" in url:
mid = int(query["vmid"][0])
self.relation_calls.append(mid)
return http_json({
"code": 0, "message": "0", "ttl": 1,
"data": {"mid": mid, "following": 5, "follower": 1000 + mid},
})
if "/x/v3/fav/resource/ids" in url:
media_id = int(query["media_id"][0])
self.ids_calls.append(media_id)
@@ -165,6 +173,13 @@ def test_initial_import_and_incremental_skip():
assert repo.get_video(1104)["bvid"] == "BV1104"
assert repo.folder_active_aids(F1) == {1101, 1102, 1103, 1104}
assert repo.is_blacklisted(999)
# 新入库视频记录入库时粉丝数;失效视频不再采集
assert summary.followers_updated == 2
assert summary.followers_failed == 0
assert repo.get_video(1101)["up_follower"] == 1100
assert repo.get_video(1101)["up_follower_backfilled"] == 0
assert repo.get_video(1102)["up_follower"] == 1999
assert sorted(transport.relation_calls) == [100, 999]
folder = repo.get_folder(F1)
assert folder.media_count == 4
@@ -173,10 +188,12 @@ def test_initial_import_and_incremental_skip():
# 6 小时内再次运行:没有新增,不请求 infos
clock[0] += 60
info_before = len(transport.info_calls)
relation_before = len(transport.relation_calls)
summary2 = service.run()
assert summary2.added == 0
assert summary2.refreshed == 0
assert len(transport.info_calls) == info_before
assert len(transport.relation_calls) == relation_before
assert summary2.folders[0].full_refresh is False
finally:
db.close()
+9 -1
View File
@@ -339,9 +339,17 @@ def test_up_follower_snapshot_and_backfill():
assert repo.get_video(3)["up_follower"] == 88
assert repo.get_video(3)["up_follower_backfilled"] == 1
# live_since 之前的视频标为回填,之后的视为实时采集
repo.upsert_from_fav(resource(5, up_mid=300), F1.media_id, was_added=True)
repo.apply_up_follower(
mid=300, name="UP3", follower=50, now=now + 180,
live_since=now + 1,
)
assert repo.get_video(5)["up_follower_backfilled"] == 1
coverage = repo.up_follower_coverage()
assert coverage == {
"total": 4, "covered": 4, "missing": 0, "ups_covered": 2,
"total": 5, "covered": 5, "missing": 0, "ups_covered": 3,
}
finally:
db.close()
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""UP 粉丝快照采集测试(不联网): uv run python tests/test_up_profile.py"""
import os
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.bilibili.models import FavResource # noqa: E402
from reaction_board.config import FolderConfig # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402
from reaction_board.services.up_profile import UpProfileService # noqa: E402
FOLDER = FolderConfig(year=2026, media_id=3700023351, title="2026")
NOW = 1_800_000_000
def resource(aid, up_mid):
return FavResource(
aid=aid,
bvid=f"BV{aid}",
title=f"视频{aid}",
up_mid=up_mid,
up_name=f"UP{up_mid}",
pubdate=NOW - 86400,
duration=7200,
cover=None,
attr=0,
fav_time=None,
pages=2,
)
class FakeClient:
def __init__(self, *, failing=()):
self.calls = []
self.failing = set(failing)
def relation_stat(self, mid):
self.calls.append(int(mid))
if int(mid) in self.failing:
raise RuntimeError("simulated network error")
return SimpleNamespace(mid=int(mid), follower=1000 + int(mid), following=5)
def setup(tmp):
db = Database(Path(tmp) / "app.db")
db.initialize()
repo = Repository(db, clock=lambda: NOW)
repo.ensure_folder(FOLDER)
for aid, mid in ((1, 100), (2, 100), (3, 200)):
repo.upsert_from_fav(resource(aid, mid), FOLDER.media_id, was_added=True)
return db, repo
def test_capture_dedupes_and_continues_after_failure():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
client = FakeClient(failing={200})
service = UpProfileService(repo, lambda: client, clock=lambda: NOW)
result = service.capture(
[100, 100, 200, None, 0], live_since=NOW - 3600
)
assert result.requested == 2
assert client.calls == [100, 200]
assert result.updated_videos == 2
assert result.failed == 1
assert "200" in result.errors[0]
assert repo.get_video(1)["up_follower"] == 1100
assert repo.get_video(2)["up_follower"] == 1100
assert repo.get_video(3)["up_follower"] is None
assert repo.get_up_profile(100)["follower_first"] == 1100
assert repo.get_up_profile(200) is None
finally:
db.close()
def test_capture_marks_old_videos_as_backfilled():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
client = FakeClient()
service = UpProfileService(repo, lambda: client, clock=lambda: NOW)
service.capture([100], live_since=NOW + 1)
assert repo.get_video(1)["up_follower_backfilled"] == 1
assert repo.get_video(2)["up_follower_backfilled"] == 1
finally:
db.close()
def test_backfill_dry_run_and_limit():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
client = FakeClient()
service = UpProfileService(repo, lambda: client, clock=lambda: NOW)
dry = service.backfill(dry_run=True)
assert dry.requested == 2
assert client.calls == []
assert repo.up_follower_coverage()["missing"] == 3
limited = service.backfill(limit=1)
assert limited.requested == 1
assert client.calls == [100]
assert repo.get_video(1)["up_follower_backfilled"] == 1
assert repo.get_video(3)["up_follower"] is None
rest = service.backfill()
assert rest.requested == 1
assert client.calls == [100, 200]
assert repo.up_follower_coverage()["missing"] == 0
finally:
db.close()
def main():
test_capture_dedupes_and_continues_after_failure()
test_capture_marks_old_videos_as_backfilled()
test_backfill_dry_run_and_limit()
print("OK: UP 粉丝快照采集与回填测试全部通过")
if __name__ == "__main__":
main()