From 2ff3e7952b72995c4d0e2833f8b86113e8d8fbea Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 14:34:23 +0800 Subject: [PATCH] feat(web): up avatars, follower count, and dedupe views/avg secondary --- DEVELOPMENT.md | 20 +++++++--- README.md | 1 + project.md | 1 + reaction_board/bilibili/models.py | 41 +++++++++++++-------- reaction_board/migrations.py | 8 ++++ reaction_board/repository.py | 17 ++++++++- reaction_board/services/library.py | 2 + reaction_board/services/snapshot.py | 2 + reaction_board/web/queries.py | 23 ++++++++++++ reaction_board/web/static/components.js | 27 ++++++++++---- reaction_board/web/static/style.css | 34 +++++++++++++++++ reaction_board/web/static/views/rankings.js | 13 ++++++- reaction_board/web/static/views/up.js | 19 ++++++---- tests/fixtures.py | 2 +- tests/test_bilibili_client.py | 2 + tests/test_cli.py | 2 +- tests/test_database.py | 8 ++-- tests/test_library.py | 3 +- tests/test_migrations.py | 18 ++++----- tests/test_snapshot.py | 3 ++ tests/test_web_queries.py | 9 +++-- tests/test_web_server.py | 2 +- tests/web_fixtures.py | 10 +++++ 23 files changed, 209 insertions(+), 58 deletions(-) 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 `
${name}
`; +} + export function videoCard(item, { rank = null, primary = "view", primaryLabel = "", primaryText = null, showStatus = false, @@ -64,27 +75,29 @@ export function videoCard(item, { export function upCard(item, { rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null, + secondaryLabel = "总播放", secondaryText = null, } = {}) { - const best = item.best_video || {}; - const displayCover = cover( - { title: best.title || item.up_name, cover: best.cover }, - "video-thumb up-thumb", - ); const value = primaryText !== null ? primaryText : item[primary]; + const secondary = secondaryText !== null ? secondaryText : formatCount(item.total_views); const hours = item.content_hours; + const follower = item.follower_latest; + const followerText = follower === null || follower === undefined + ? "粉丝 —" + : `粉丝 ${formatCount(follower)}`; return ` ${rankBadge(rank)} - ${displayCover} + ${avatar(item, "up-avatar")}

${escapeHtml(item.up_name || `UP ${item.up_mid}`)}

${formatNumber(item.video_count)} 条视频 ${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`} + ${followerText}
${escapeHtml(value ?? "—")}${escapeHtml(primaryLabel)} - 总播放 ${formatCount(item.total_views)} + ${escapeHtml(secondaryLabel)} ${escapeHtml(secondary)}
diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index ba6c986..28928ec 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -353,6 +353,25 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo .cover-placeholder img.image-error { opacity: 0; } .video-card-body { grid-column: 3; grid-row: 1; min-width: 0; } .up-card-body { grid-column: 3; grid-row: 1; min-width: 0; } +.up-avatar { + grid-column: 2; + grid-row: 1; + justify-self: center; + align-self: center; + width: 64px; + height: 64px; + border-radius: 50%; + overflow: hidden; + background: var(--surface-2); + display: grid; + place-items: center; + color: var(--muted); + font-size: 20px; + font-weight: 700; +} +.up-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; } +.up-card.no-rank > .up-avatar { grid-column: 1; } +.avatar-placeholder { text-transform: uppercase; } .video-title { display: -webkit-box; -webkit-line-clamp: 2; @@ -717,6 +736,21 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo background: var(--surface); } .hero-cover { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; } +.up-hero-head { display: flex; align-items: center; gap: 14px; } +.hero-avatar { + flex: 0 0 auto; + width: 72px; + height: 72px; + border-radius: 50%; + overflow: hidden; + background: var(--surface-2); + display: grid; + place-items: center; + color: var(--muted); + font-size: 26px; + font-weight: 700; +} +.hero-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; } .detail-hero-body h1 { font-size: 19px; line-height: 1.35; letter-spacing: -0.3px; } .detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; color: var(--muted); font-size: 12px; } .compact-hero { padding: 18px; } diff --git a/reaction_board/web/static/views/rankings.js b/reaction_board/web/static/views/rankings.js index 898dd3c..a18f839 100644 --- a/reaction_board/web/static/views/rankings.js +++ b/reaction_board/web/static/views/rankings.js @@ -133,6 +133,14 @@ function upPrimary(item, sort) { return { label: "播放/小时", text: formatCount(item.efficiency) }; } +function upSecondary(item, sort) { + // “总播放”排序时主指标已是总播放,副指标改为平均播放,避免重复。 + if (sort === "views") { + return { label: "平均播放", text: formatCount(item.avg_views) }; + } + return { label: "总播放", text: formatCount(item.total_views) }; +} + function listHtml(data, scope, sort, startRank) { if (!data.items.length) { return emptyState( @@ -143,8 +151,11 @@ function listHtml(data, scope, sort, startRank) { if (scope === "up") { return `
${data.items.map((item, index) => { const primary = upPrimary(item, sort); + const secondary = upSecondary(item, sort); return upCard(item, { - rank: startRank + index, primaryLabel: primary.label, primaryText: primary.text, + rank: startRank + index, + primaryLabel: primary.label, primaryText: primary.text, + secondaryLabel: secondary.label, secondaryText: secondary.text, }); }).join("")}
`; } diff --git a/reaction_board/web/static/views/up.js b/reaction_board/web/static/views/up.js index b5edcf0..dc5ef3a 100644 --- a/reaction_board/web/static/views/up.js +++ b/reaction_board/web/static/views/up.js @@ -1,6 +1,6 @@ import { api } from "../api.js"; import { - badge, emptyState, sectionHeader, videoCard, + avatar, badge, emptyState, sectionHeader, videoCard, } from "../components.js"; import { barChart, lineChart, themeColor } from "../charts.js"; import { @@ -43,14 +43,17 @@ export async function renderUp(root, { params, query }) {
-
-

${escapeHtml(up.up_name || `UP ${mid}`)}

-
- mid ${up.up_mid} - ${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""} - ${(up.folders || []).map((title) => badge(title)).join("")} +
+ ${avatar(up, "hero-avatar")} +
+

${escapeHtml(up.up_name || `UP ${mid}`)}

+
+ mid ${up.up_mid} + ${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""} + ${(up.folders || []).map((title) => badge(title)).join("")} +
+

首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}

-

首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}

diff --git a/tests/fixtures.py b/tests/fixtures.py index 38d8085..65fcd52 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -182,7 +182,7 @@ VIEW_PAYLOAD: dict[str, Any] = { {"cid": 43184885058, "page": 1, "from": "vupload", "part": "第一部分"}, {"cid": 43184885059, "page": 2, "from": "vupload", "part": "第二部分"}, ], - "owner": {"mid": 434442072, "name": "_-抱抱-_", "face": ""}, + "owner": {"mid": 434442072, "name": "_-抱抱-_", "face": "http://i1.hdslb.com/bfs/face/example.jpg"}, "stat": { "aid": 117530286889638, "view": 819, diff --git a/tests/test_bilibili_client.py b/tests/test_bilibili_client.py index 0ebd195..acca6f1 100644 --- a/tests/test_bilibili_client.py +++ b/tests/test_bilibili_client.py @@ -353,6 +353,7 @@ def test_view_detail_by_aid_and_bvid(): assert detail.pages == 2 assert detail.cover and detail.cover.startswith("http") assert detail.metrics()["view"] == 819 + assert detail.up_face and detail.up_face.startswith("http") assert "aid=117530286889638" in transport.calls[0]["url"] transport = QueueTransport([http_json(VIEW_PAYLOAD)]) @@ -373,6 +374,7 @@ def test_view_detail_fallbacks_and_required_arg(): assert detail.duration == 0 assert detail.pages is None assert detail.cover is None + assert detail.up_face is None client, _ = make_client(QueueTransport([])) for kwargs in ({}, {"aid": 1, "bvid": "BV1xvxi6LEZD"}): diff --git a/tests/test_cli.py b/tests/test_cli.py index 372b796..50b5f9d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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": 5' in output + assert '"schema_version": 6' in output db = Database(Path(tmp) / "app.db") db.initialize() diff --git a/tests/test_database.py b/tests/test_database.py index 798ecf1..208deda 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -24,7 +24,7 @@ def test_write_read_transaction(): with tempfile.TemporaryDirectory() as tmp: db = make_db(tmp) try: - assert db.schema_version == 5 + assert db.schema_version == 6 db.write(lambda c: c.execute("INSERT INTO kv(k, v) VALUES ('a', '1')")) assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1" @@ -94,8 +94,8 @@ def test_close_and_reinitialize(): db2 = make_db(tmp) try: - assert db2.schema_version == 5 - assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 5 + assert db2.schema_version == 6 + assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 6 finally: db2.close() @@ -123,7 +123,7 @@ def test_migration_backup_on_existing_db(): db = Database(path, backup_dir=Path(tmp) / "backup") try: applied = db.initialize() - assert applied == [1, 2, 3, 4, 5] + assert applied == [1, 2, 3, 4, 5, 6] assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1 finally: db.close() diff --git a/tests/test_library.py b/tests/test_library.py index 13e4a33..8c84af1 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -45,7 +45,7 @@ def detail( data["pubdate"] = pubdate data["ctime"] = pubdate data["duration"] = duration - data["owner"] = {"mid": up_mid, "name": up_name, "face": ""} + data["owner"] = {"mid": up_mid, "name": up_name, "face": "http://i1.hdslb.com/bfs/face/u.jpg"} data["pages"] = [{"cid": 1, "page": i + 1} for i in range(pages)] return VideoDetail.from_api(data) @@ -150,6 +150,7 @@ def test_ingest_candidate_added_and_snapshot(): assert video["source"] == "candidate:telegram" assert video["folder_id"] is None assert video["up_mid"] == 100 + assert video["up_face"] == "http://i1.hdslb.com/bfs/face/u.jpg" snap = repo.get_snapshot(DATE, 9) assert snap is not None and snap["ok"] == 1 and snap["view"] == 819 assert ups.calls == [([100], {100: "UP100"})] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 2d8186c..088c88f 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -37,8 +37,8 @@ def test_fresh_and_idempotent(): conn = sqlite3.connect(Path(tmp) / "app.db") try: applied = run_migrations(conn) - assert applied == [1, 2, 3, 4, 5] - assert current_version(conn) == 5 + assert applied == [1, 2, 3, 4, 5, 6] + assert current_version(conn) == 6 assert EXPECTED_TABLES <= table_names(conn) assert "schema_version" in table_names(conn) columns = { @@ -54,7 +54,7 @@ def test_fresh_and_idempotent(): } assert { "pages", "duration", "up_follower", "up_follower_at", - "up_follower_backfilled", + "up_follower_backfilled", "up_face", } <= video_columns profile_columns = { row[1] for row in conn.execute("PRAGMA table_info(up_profile)") @@ -66,7 +66,7 @@ def test_fresh_and_idempotent(): # 再跑一次不应重复应用 assert run_migrations(conn) == [] row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone() - assert row[0] == 5 + assert row[0] == 6 finally: conn.close() @@ -81,15 +81,15 @@ def test_backup_before_upgrade(): conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')") conn.commit() - v6 = Migration(6, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",)) + v7 = Migration(7, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",)) applied = run_migrations( conn, backup_dir=backup_dir, now=1_800_000_000, - migrations=MIGRATIONS + (v6,), + migrations=MIGRATIONS + (v7,), ) - assert applied == [6] - backups = list(backup_dir.glob("app-pre-v5-*.db")) + assert applied == [7] + backups = list(backup_dir.glob("app-pre-v6-*.db")) assert len(backups) == 1 check = sqlite3.connect(backups[0]) try: @@ -174,7 +174,7 @@ def test_v5_normalizes_unfaved(): ) conn.commit() applied = run_migrations(conn) - assert applied == [5] + assert applied == [5, 6] rows = dict(conn.execute("SELECT aid, status FROM video").fetchall()) assert rows == {1: "active", 2: "dropped"} finally: diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 7b11b47..74720af 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -153,6 +153,9 @@ def test_snapshot_success_resume_and_force(): 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" diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 2eb8839..dffc342 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -182,6 +182,8 @@ def test_up_queries(): assert ups["items"][0]["video_count"] == 2 assert ups["items"][0]["total_views"] == 6400 assert ups["items"][0]["avg_views"] == 3200.0 + assert ups["items"][0]["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg" + assert ups["items"][0]["follower_latest"] == 12345 one = q.list_ups(min_videos=2) assert one["total"] == 1 @@ -190,6 +192,7 @@ def test_up_queries(): assert detail["video_count"] == 2 assert detail["total_views"] == 6400 assert detail["blacklisted"] is False + assert detail["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg" assert detail["series"]["dates"] == list(DATES) assert detail["series"]["views"] == [6000, 6200, 6400] assert detail["space_url"].endswith("/1") @@ -454,7 +457,7 @@ def test_about_payload(): assert about["snapshot_days"] == 3 assert about["snapshot_first_date"] == DATES[0] assert about["fans_start_date"] == DATES[0] - assert about["schema_version"] == 5 + assert about["schema_version"] == 6 assert about["analytics"] == { "min_duration_seconds": 600, "maturity_days": 2, @@ -464,8 +467,8 @@ def test_about_payload(): } assert about["heat_weights"]["view"] == 0.30 assert about["followers"]["videos_total"] == 3 - assert about["followers"]["videos_covered"] == 0 - assert about["followers"]["videos_missing"] == 3 + assert about["followers"]["videos_covered"] == 3 + assert about["followers"]["videos_missing"] == 0 assert about["folders"][0]["media_id"] == 3700023351 assert about["preview"] is False finally: diff --git a/tests/test_web_server.py b/tests/test_web_server.py index ea677ba..648cd42 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -75,7 +75,7 @@ def test_readonly_routes_and_static(): assert status == 200 and payload["ok"] is True status, _, payload = running.json("/api/about") assert status == 200 - assert payload["data"]["schema_version"] == 5 + assert payload["data"]["schema_version"] == 6 assert payload["data"]["analytics"]["min_duration_seconds"] == 600 status, _, overview = running.json("/api/overview") diff --git a/tests/web_fixtures.py b/tests/web_fixtures.py index 9965a15..41dcde8 100644 --- a/tests/web_fixtures.py +++ b/tests/web_fixtures.py @@ -106,6 +106,16 @@ def build_db(tmp: str | Path) -> Path: ) repo.set_video_status(103, "unfaved") + # UP 头像与最新粉丝数(UP 列表/详情展示用) + repo.apply_up_follower( + mid=1, name="UP A", follower=12345, following=5, now=ts("2026-10-30") + ) + repo.apply_up_follower( + mid=2, name="UP B", follower=678, following=1, now=ts("2026-10-31") + ) + repo.apply_video_up_face(101, "//i0.hdslb.com/bfs/face/a.jpg") + repo.apply_video_up_face(103, "//i0.hdslb.com/bfs/face/b.jpg") + metrics = { 101: {"view": 1000, "like": 100, "coin": 20, "favorite": 30, "share": 2, "reply": 10, "danmaku": 12}, 102: {"view": 5000, "like": 200, "coin": 50, "favorite": 100, "share": 10, "reply": 30, "danmaku": 40},