feat(up): capture follower snapshots at ingest and backfill history
This commit is contained in:
1 parent
4942d1d161
commit
519fc39fca
8 files changed
+401
-14
No files matched your search
+16
-2
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user