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
@@ -21,6 +21,7 @@ from typing import Any
|
||||
|
||||
from ..config import AppConfig, ConfigStore, FolderConfig
|
||||
from ..repository import Repository
|
||||
from .up_profile import UpProfileService
|
||||
|
||||
CAPACITY_WARN = 950
|
||||
|
||||
@@ -61,6 +62,8 @@ class SyncSummary:
|
||||
finished_at: int = 0
|
||||
folders: list[FolderSyncResult] = field(default_factory=list)
|
||||
blacklist_seeded: int = 0
|
||||
followers_updated: int = 0
|
||||
followers_failed: int = 0
|
||||
|
||||
@property
|
||||
def added(self) -> int:
|
||||
@@ -100,6 +103,8 @@ class SyncSummary:
|
||||
"refreshed": self.refreshed,
|
||||
"missing": self.missing,
|
||||
"blacklist_seeded": self.blacklist_seeded,
|
||||
"followers_updated": self.followers_updated,
|
||||
"followers_failed": self.followers_failed,
|
||||
"folders": [f.as_dict() for f in self.folders],
|
||||
"errors": self.errors,
|
||||
"warnings": self.warnings,
|
||||
@@ -119,6 +124,8 @@ class FolderSyncService:
|
||||
logger=None,
|
||||
capacity_warn: int = CAPACITY_WARN,
|
||||
allow_empty_folders: bool = False,
|
||||
up_profile_service: UpProfileService | None = None,
|
||||
capture_followers: bool = True,
|
||||
) -> None:
|
||||
if client is None and client_provider is None:
|
||||
raise ValueError("必须提供 client 或 client_provider")
|
||||
@@ -130,6 +137,10 @@ class FolderSyncService:
|
||||
self.logger = logger
|
||||
self.capacity_warn = capacity_warn
|
||||
self.allow_empty_folders = allow_empty_folders
|
||||
self.capture_followers = capture_followers
|
||||
self.up_profile = up_profile_service or UpProfileService(
|
||||
repository, self._client, clock=clock, logger=logger
|
||||
)
|
||||
|
||||
def _client(self):
|
||||
if self.client_provider is not None:
|
||||
@@ -189,6 +200,26 @@ class FolderSyncService:
|
||||
)
|
||||
summary.folders.append(result)
|
||||
|
||||
if self.capture_followers:
|
||||
try:
|
||||
missing = self.repo.up_mids_missing_follower()
|
||||
if missing:
|
||||
capture = self.up_profile.capture(
|
||||
[int(row["up_mid"]) for row in missing],
|
||||
names={
|
||||
int(row["up_mid"]): str(row.get("up_name") or "")
|
||||
for row in missing
|
||||
},
|
||||
live_since=summary.started_at - 3600,
|
||||
)
|
||||
summary.followers_updated = capture.updated_videos
|
||||
summary.followers_failed = capture.failed
|
||||
except Exception as exc: # noqa: BLE001 粉丝采集失败不影响收藏夹同步
|
||||
summary.followers_failed += 1
|
||||
self._log(
|
||||
"warning", "folder_sync.followers_failed", error=str(exc)
|
||||
)
|
||||
|
||||
summary.finished_at = self._now()
|
||||
self._log(
|
||||
"info" if summary.ok else "error",
|
||||
@@ -200,7 +231,7 @@ class FolderSyncService:
|
||||
errors=len(summary.errors),
|
||||
elapsed=max(0, summary.finished_at - summary.started_at),
|
||||
)
|
||||
if summary.added or summary.removed or summary.refreshed:
|
||||
if summary.added or summary.removed or summary.refreshed or summary.followers_updated:
|
||||
self.repo.bump_cache_version("videos")
|
||||
return summary
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""UP 粉丝快照采集。
|
||||
|
||||
视频的播放、互动记在 bvid 上,而 UP 的粉丝数是视频之外的受众基数。为了让以后
|
||||
能计算“播放 / 入库时粉丝数”,本服务在视频入库时记录一次粉丝快照,并支持对历史
|
||||
数据做一次性回填。
|
||||
|
||||
``up_profile.follower_first`` 永远保留首次采集值;``video.up_follower`` 保留每个
|
||||
视频入库时的值,不会被后续刷新覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
|
||||
from ..repository import Repository
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureResult:
|
||||
requested: int = 0
|
||||
updated_videos: int = 0
|
||||
failed: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"requested": self.requested,
|
||||
"updated_videos": self.updated_videos,
|
||||
"failed": self.failed,
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
|
||||
class UpProfileService:
|
||||
def __init__(
|
||||
self,
|
||||
repository: Repository,
|
||||
client_provider: Callable[[], Any],
|
||||
*,
|
||||
clock=time.time,
|
||||
logger=None,
|
||||
) -> None:
|
||||
self.repo = repository
|
||||
self.client_provider = client_provider
|
||||
self._clock = clock
|
||||
self.logger = logger
|
||||
|
||||
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)
|
||||
|
||||
def capture(
|
||||
self,
|
||||
mids: Iterable[int],
|
||||
*,
|
||||
names: Mapping[int, str] | None = None,
|
||||
live_since: int | None = None,
|
||||
backfilled: bool = False,
|
||||
) -> CaptureResult:
|
||||
"""采集一组 UP 的当前粉丝数,并补齐其视频的空快照。
|
||||
|
||||
同一个 mid 在一次调用里只请求一次;单个失败不会中断其余 UP。
|
||||
"""
|
||||
ordered: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for mid in mids:
|
||||
if mid is None or isinstance(mid, bool):
|
||||
continue
|
||||
try:
|
||||
value = int(mid)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value <= 0 or value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
ordered.append(value)
|
||||
|
||||
result = CaptureResult(requested=len(ordered))
|
||||
if not ordered:
|
||||
return result
|
||||
now = self._now()
|
||||
names = names or {}
|
||||
for mid in ordered:
|
||||
try:
|
||||
fan = self.client_provider().relation_stat(mid)
|
||||
updated = self.repo.apply_up_follower(
|
||||
mid=mid,
|
||||
name=names.get(mid) or getattr(fan, "uname", None),
|
||||
follower=int(fan.follower),
|
||||
following=int(getattr(fan, "following", 0) or 0),
|
||||
now=now,
|
||||
backfilled=backfilled,
|
||||
live_since=live_since,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 单个 UP 失败不阻塞同步
|
||||
result.failed += 1
|
||||
result.errors.append(f"{mid}: {exc}")
|
||||
self._log(
|
||||
"warning", "up_profile.capture_failed", mid=mid, error=str(exc)
|
||||
)
|
||||
else:
|
||||
result.updated_videos += updated
|
||||
self._log(
|
||||
"info", "up_profile.captured",
|
||||
mid=mid, follower=int(fan.follower), updated=updated,
|
||||
)
|
||||
return result
|
||||
|
||||
def backfill(
|
||||
self, *, limit: int | None = None, dry_run: bool = False
|
||||
) -> CaptureResult:
|
||||
"""为历史视频回填粉丝快照;已有值的视频不会被覆盖。"""
|
||||
rows = self.repo.up_mids_missing_follower()
|
||||
if limit is not None:
|
||||
rows = rows[: max(0, int(limit))]
|
||||
if dry_run:
|
||||
return CaptureResult(requested=len(rows))
|
||||
names = {
|
||||
int(row["up_mid"]): str(row.get("up_name") or "")
|
||||
for row in rows
|
||||
}
|
||||
return self.capture(
|
||||
[int(row["up_mid"]) for row in rows],
|
||||
names=names,
|
||||
backfilled=True,
|
||||
)
|
||||
Reference in new issue
Block a user