diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 37630d3..3ec97d1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -431,7 +431,7 @@ P3 实现说明: 每天 `00:01`(`Asia/Shanghai`)执行: - 对 `status IN ('active', 'unfaved')` 的视频逐条请求 `view?aid=`; -- 成功写入播放、点赞、收藏、投币、转发、评论、弹幕七项数据; +- 成功写入播放、点赞、收藏、投币、转发、评论、弹幕七项数据,并把 `view` 的 `owner.face` 写入 `video.up_face`(UP 头像,供 UP 列表/详情展示); - `-404` / `62002` / `62012` 时标记 `removed`(62002/62012 为当前实测返回); - 其他错误最多重试 3 次,仍失败则写入 `ok=0` 与错误信息,不写伪造的零值; - 每条视频请求之间随机等待 1.0~1.6 秒; @@ -519,8 +519,8 @@ P3 实现说明: | `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) | | `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 | | `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜(`year` 可用) | -| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率;`sort` / `within_days` / `year`) | -| `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势和视频列表 | +| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率、UP 头像 `up_face`、最新粉丝数 `follower_latest`;`sort` / `within_days` / `year`) | +| `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势、头像和视频列表 | | `GET` | `/api/fans` | 粉丝曲线与增量 | | `GET` | `/api/candidates` | 候选池(只读) | | `GET` | `/api/system` | 最近任务、下次任务、备份、黑名单 | @@ -532,10 +532,10 @@ P3 实现说明: |---|---| | 首页 | 最新动态优先:顶部四个可点击数据卡片(今日入库→视频榜按入库排序、播放增量→视频榜按 1 日增长排序、粉丝日增→粉丝页、待确认候选→候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态。 | | 视频(`/videos`) | 内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量、播放增速;工具条为「排序」下拉 + 时间下拉 + 右侧放大镜搜索框(一行展示),时间支持全部/7 天/30 天/90 天/1 年/年份。 | -| UP主(`/ups`) | 独立入口,不再与视频混排;排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;不提供按视频数筛选。 | +| UP主(`/ups`) | 独立入口,不再与视频混排;卡片显示 UP 头像、视频数与总时长、最新粉丝数,以及当前排序的主/副指标(「总播放」排序时副指标为「平均播放」);排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;不提供按视频数筛选。 | | 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 | | 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 | -| UP 页 | 该 UP 的全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 | +| UP 页 | 该 UP 的头像、全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 | | 粉丝页 | 粉丝总数、日增、7 日净增、累计增长(标注起始日期);支持预设和自选日期,范围超过一年时按每月峰值日期聚合;最近记录默认 14 天并显示“较上一成功日”增量;不叠加视频数据。 | | 候选池 | 待确认候选、规则命中、剩余保留时间;操作指向 Telegram/CLI。 | | 系统状态 | 最近扫描、同步、快照、下次运行、备份、鉴权和黑名单。 | @@ -620,6 +620,7 @@ uv run python tests/test_moves.py - 标题归一化、时长解析、分类打分; - 视频引用解析(BV/av/URL/`b23.tv` 短链)与 `view?bvid=` 契约; +- UP 头像与粉丝数列:`view`/`view_detail` 的 `owner.face` 解析、入库与快照写入 `video.up_face`、`/api/ups` 的 `up_face`/`follower_latest` 映射; - 黑名单只影响发现; - `infos` 静默缺失资源的识别; - `active`、`unfaved`、`removed`、`dropped` 状态迁移; @@ -747,6 +748,15 @@ uv run python tests/test_moves.py - 看板:系统页与首页把「收藏夹同步/容量」改为「历史收藏夹」;候选页文案改为「入库」。 - 自动化测试:新增 `tests/test_video_ref.py`、`tests/test_library.py`,重写 `test_telegram.py`,删除 `test_folder_sync.py` / `test_fav_position.py` / `test_favorites.py`,并更新 `test_bilibili_client.py`、`test_cli.py`、`test_migrations.py`、`test_database.py`、`test_web_queries.py`、`test_web_server.py`、`web_fixtures.py`。 +### 第 11 轮:UP 头像与 UP 列表信息完善 ✅(2026-11-01) + +- 迁移 v6:`video.up_face`(来自 `view` 的 `owner.face`);入库与每日快照都会刷新该字段; +- `ViewStat` / `VideoDetail` 增加 UP `mid/name/face` 解析; +- UP 列表卡片由视频封面改为 UP 头像(无头像时显示首字母占位)、常驻显示最新粉丝数;「总播放」排序时副指标显示「平均播放」,避免与主指标重复; +- UP 详情页补充头像; +- `/api/ups` 返回 `up_face` 与 `follower_latest`,`/api/ups/{mid}` 返回 `up_face`; +- 自动化测试更新:`test_migrations.py`、`test_database.py`、`test_web_queries.py`、`test_bilibili_client.py`、`test_library.py`、`test_snapshot.py`、`web_fixtures.py`。 + ### P4:可选部署 - 反向代理、TLS、远程访问鉴权; diff --git a/README.md b/README.md index 035490d..7dfb487 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ uv run python app.py serve --preview --config data/preview/config.json - 首页默认“最新动态”:顶部四个数据卡片(今日入库 / 播放增量 / 粉丝日增 / 待确认候选)可点击跳转(视频榜按入库或增长排序、粉丝页、候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态; - 「视频」与「UP主」是两个独立入口;工具条顺序为「排序」下拉 → 时间下拉(无需 label)→ 右侧自适应搜索框(最大 320px,放大镜按钮在输入框内),三者在 PC 与移动端都保持一行; - 视频排序:内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 排序:内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布; +- UP 卡片显示 UP 头像(无头像时显示首字母占位)、视频数/总时长与最新粉丝数;「总播放」排序时副指标显示「平均播放」(相当于平均播放榜,主副指标对调);UP 详情页也显示头像; - 时间筛选:全部 / 近 7 天 / 近 30 天 / 近 90 天 / 近 1 年 / 已入库的年份;不再提供按收藏夹过滤,UP 页也不再按视频数筛选; - 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜; - 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取; diff --git a/project.md b/project.md index e36d8e1..b605d69 100644 --- a/project.md +++ b/project.md @@ -523,6 +523,7 @@ CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT); - `video.pages`:分 P 数(来自 `infos` 的 `page`),只用于详情展示与包装诊断,不参与排序; - `video.up_follower` / `up_follower_at` / `up_follower_backfilled`:视频**入库时**的 UP 粉丝数快照; +- `video.up_face`(第 11 轮,schema v6):UP 头像 URL,来自 `view` 的 `owner.face`;入库与每日快照都会刷新,UP 列表/详情据此显示头像; - `up_profile(mid, name, follower_first, following_first, first_captured_at, follower_latest, last_refreshed)`:每个 UP 的首次/最近粉丝数。`follower_first` 不可被覆盖; - 采集:收藏夹同步结束时对仍缺粉丝数的 UP 调一次 `relation/stat`(匿名可用);`first_seen >= 本次运行开始前 1 小时` 的视频记为实时采集,更早的记为回填; - 回填:`python app.py backfill-followers [--limit N] [--dry-run]`,只补 `up_follower IS NULL` 的追踪视频; diff --git a/reaction_board/bilibili/models.py b/reaction_board/bilibili/models.py index 691c1c3..4d07551 100644 --- a/reaction_board/bilibili/models.py +++ b/reaction_board/bilibili/models.py @@ -97,7 +97,7 @@ class InfosResult: @dataclass(frozen=True) class ViewStat: - """``x/web-interface/view`` 的七项统计。""" + """``x/web-interface/view`` 的七项统计(含 UP 元数据)。""" aid: int bvid: str | None @@ -109,6 +109,9 @@ class ViewStat: share: int reply: int danmaku: int + up_mid: int | None = None + up_name: str | None = None + up_face: str | None = None def metrics(self) -> dict[str, int]: return { @@ -138,10 +141,28 @@ class ViewStat: bvid = item.get("bvid") if not isinstance(bvid, str) or not bvid: bvid = None + owner = item.get("owner") or {} + if not isinstance(owner, Mapping): + owner = {} + raw_mid = owner.get("mid") + up_mid = ( + raw_mid + if isinstance(raw_mid, int) and not isinstance(raw_mid, bool) + else None + ) + up_name = owner.get("name") + if not isinstance(up_name, str) or not up_name: + up_name = None + up_face = owner.get("face") + if not isinstance(up_face, str) or not up_face: + up_face = None return cls( aid=aid, bvid=bvid, title=str(item.get("title") or ""), + up_mid=up_mid, + up_name=up_name, + up_face=up_face, **metrics, ) @@ -163,6 +184,7 @@ class VideoDetail: pages: int | None cover: str | None stat: ViewStat + up_face: str | None = None def metrics(self) -> dict[str, int]: return self.stat.metrics() @@ -170,18 +192,6 @@ class VideoDetail: @classmethod def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail": stat = ViewStat.from_api(item) - owner = item.get("owner") or {} - if not isinstance(owner, Mapping): - owner = {} - raw_mid = owner.get("mid") - up_mid = ( - raw_mid - if isinstance(raw_mid, int) and not isinstance(raw_mid, bool) - else None - ) - up_name = owner.get("name") - if not isinstance(up_name, str) or not up_name: - up_name = None raw_duration = item.get("duration") duration = ( @@ -219,13 +229,14 @@ class VideoDetail: aid=stat.aid, bvid=stat.bvid, title=stat.title, - up_mid=up_mid, - up_name=up_name, + up_mid=stat.up_mid, + up_name=stat.up_name, pubdate=pubdate, duration=duration, pages=pages, cover=cover, stat=stat, + up_face=stat.up_face, ) diff --git a/reaction_board/migrations.py b/reaction_board/migrations.py index 4030adf..ef89702 100644 --- a/reaction_board/migrations.py +++ b/reaction_board/migrations.py @@ -216,6 +216,14 @@ MIGRATIONS: tuple[Migration, ...] = ( "UPDATE video SET status = 'active' WHERE status = 'unfaved'", ), ), + Migration( + version=6, + name="video up face", + statements=( + # UP 头像 URL(来自 view 的 owner.face),用于 UP 列表/详情展示。 + "ALTER TABLE video ADD COLUMN up_face TEXT", + ), + ), ) diff --git a/reaction_board/repository.py b/reaction_board/repository.py index b8a25ee..e3d67d2 100644 --- a/reaction_board/repository.py +++ b/reaction_board/repository.py @@ -422,6 +422,7 @@ class Repository: duration: int | None = None, pages: int | None = None, cover: str | None = None, + up_face: str | None = None, source: str = "manual", now: int | None = None, ) -> IngestOutcome: @@ -441,9 +442,9 @@ class Repository: conn.execute( """ INSERT INTO video(aid, bvid, title, up_mid, up_name, pubdate, - duration, pages, cover, folder_id, fav_time, status, + duration, pages, cover, up_face, folder_id, fav_time, status, source, first_seen, last_sync, attr) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0) ON CONFLICT(aid) DO UPDATE SET bvid = COALESCE(excluded.bvid, video.bvid), title = CASE @@ -459,6 +460,7 @@ class Repository: duration = COALESCE(excluded.duration, video.duration), pages = COALESCE(excluded.pages, video.pages), cover = COALESCE(excluded.cover, video.cover), + up_face = COALESCE(excluded.up_face, video.up_face), status = 'active', source = COALESCE(video.source, excluded.source), last_sync = excluded.last_sync @@ -473,6 +475,7 @@ class Repository: None if duration is None else int(duration), None if pages is None else int(pages), cover, + up_face, source, now, now, @@ -488,6 +491,16 @@ class Repository: ) return self.db.write(run) + def apply_video_up_face(self, aid: int, face: str | None) -> None: + """用 ``view`` 返回的 UP 头像刷新视频行;仅在变化时写入。""" + if not isinstance(face, str) or not face.strip(): + return + face = face.strip() + self.db.write(lambda conn: conn.execute( + "UPDATE video SET up_face = ? WHERE aid = ? AND (up_face IS NULL OR up_face != ?)", + (face, int(aid), face), + )) + # ------------------------------------------------------- up profile def apply_up_follower( diff --git a/reaction_board/services/library.py b/reaction_board/services/library.py index 78ddc2b..22ac868 100644 --- a/reaction_board/services/library.py +++ b/reaction_board/services/library.py @@ -137,6 +137,7 @@ class LibraryService: duration=detail.duration, pages=detail.pages, cover=detail.cover, + up_face=detail.up_face, source="candidate:telegram", now=self._now(), ) @@ -206,6 +207,7 @@ class LibraryService: duration=detail.duration, pages=detail.pages, cover=detail.cover, + up_face=detail.up_face, source="manual:telegram", now=self._now(), ) diff --git a/reaction_board/services/snapshot.py b/reaction_board/services/snapshot.py index 879fa26..8736b02 100644 --- a/reaction_board/services/snapshot.py +++ b/reaction_board/services/snapshot.py @@ -211,6 +211,8 @@ class SnapshotService: err=None, captured_at=self._now(), ) + if getattr(stat, "up_face", None): + self.repo.apply_video_up_face(aid, stat.up_face) summary.success += 1 else: summary.failed += 1 diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index eb76f72..5afbf07 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -387,6 +387,7 @@ class DashboardQueries: "up_follower_at": video.get("up_follower_at"), "up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0), "cover": normalize_cover(video.get("cover")), + "up_face": normalize_cover(video.get("up_face")), "folder_id": video.get("folder_id"), "folder_title": video.get("folder_title"), "folder_year": video.get("folder_year"), @@ -1074,7 +1075,10 @@ class DashboardQueries: "up_mid": mid, "up_name": video.get("up_name") or f"UP {mid}", "videos": [], + "face": None, }) + if group["face"] is None and video.get("up_face"): + group["face"] = video.get("up_face") metrics = _metric_map(snapshots.get(int(video["aid"]))) group["videos"].append((video, metrics)) items = [] @@ -1105,6 +1109,8 @@ class DashboardQueries: items.append({ "up_mid": group["up_mid"], "up_name": group["up_name"], + "up_face": normalize_cover(group["face"]), + "follower_latest": None, "video_count": len(group_videos), "total_views": total_views, "avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None, @@ -1166,6 +1172,20 @@ class DashboardQueries: if minutes > 0 else None ) + mids = [item["up_mid"] for item in items] + if mids: + placeholders = ",".join("?" for _ in mids) + profile_rows = conn.execute( + f"SELECT mid, follower_latest FROM up_profile " + f"WHERE mid IN ({placeholders})", + mids, + ).fetchall() + followers = { + int(row["mid"]): row["follower_latest"] for row in profile_rows + } + for item in items: + item["follower_latest"] = followers.get(item["up_mid"]) + items = [item for item in items if item["video_count"] >= min_videos] sort_map = { "views": lambda item: item["total_views"], @@ -1256,6 +1276,9 @@ class DashboardQueries: return { "up_mid": mid, "up_name": items[0].get("up_name") or f"UP {mid}", + "up_face": next( + (item["up_face"] for item in items if item.get("up_face")), None + ), "video_count": len(items), "total_views": total_views, "avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None, diff --git a/reaction_board/web/static/components.js b/reaction_board/web/static/components.js index 063370d..244c792 100644 --- a/reaction_board/web/static/components.js +++ b/reaction_board/web/static/components.js @@ -19,6 +19,17 @@ export function rankBadge(rank) { return `${rank}`; } +export function avatar(item, className = "avatar") { + const url = coverUrl(item?.up_face || item?.avatar); + const name = escapeHtml(item?.up_name || "UP"); + if (!url) { + const initial = String(item?.up_name || "U").trim().slice(0, 1).toUpperCase() || "U"; + return `
`; + } + return `