feat(web): up avatars, follower count, and dedupe views/avg secondary

This commit is contained in:
kairee committed 2026-11-01 14:34:23 +08:00
1 parent 1ed3746f4b
commit 2ff3e7952b
23 files changed
+202 -51

No files matched your search

+15 -5
View File
@@ -431,7 +431,7 @@ P3 实现说明:
每天 `00:01`(`Asia/Shanghai`)执行: 每天 `00:01`(`Asia/Shanghai`)执行:
- 对 `status IN ('active', 'unfaved')` 的视频逐条请求 `view?aid=`; - 对 `status IN ('active', 'unfaved')` 的视频逐条请求 `view?aid=`;
- 成功写入播放、点赞、收藏、投币、转发、评论、弹幕七项数据; - 成功写入播放、点赞、收藏、投币、转发、评论、弹幕七项数据,并把 `view` 的 `owner.face` 写入 `video.up_face`(UP 头像,供 UP 列表/详情展示);
- `-404` / `62002` / `62012` 时标记 `removed`(62002/62012 为当前实测返回); - `-404` / `62002` / `62012` 时标记 `removed`(62002/62012 为当前实测返回);
- 其他错误最多重试 3 次,仍失败则写入 `ok=0` 与错误信息,不写伪造的零值; - 其他错误最多重试 3 次,仍失败则写入 `ok=0` 与错误信息,不写伪造的零值;
- 每条视频请求之间随机等待 1.0~1.6 秒; - 每条视频请求之间随机等待 1.0~1.6 秒;
@@ -519,8 +519,8 @@ P3 实现说明:
| `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) | | `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) |
| `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 | | `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 |
| `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜(`year` 可用) | | `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜(`year` 可用) |
| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率;`sort` / `within_days` / `year`) | | `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率、UP 头像 `up_face`、最新粉丝数 `follower_latest`;`sort` / `within_days` / `year`) |
| `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势和视频列表 | | `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势、头像和视频列表 |
| `GET` | `/api/fans` | 粉丝曲线与增量 | | `GET` | `/api/fans` | 粉丝曲线与增量 |
| `GET` | `/api/candidates` | 候选池(只读) | | `GET` | `/api/candidates` | 候选池(只读) |
| `GET` | `/api/system` | 最近任务、下次任务、备份、黑名单 | | `GET` | `/api/system` | 最近任务、下次任务、备份、黑名单 |
@@ -532,10 +532,10 @@ P3 实现说明:
|---|---| |---|---|
| 首页 | 最新动态优先:顶部四个可点击数据卡片(今日入库→视频榜按入库排序、播放增量→视频榜按 1 日增长排序、粉丝日增→粉丝页、待确认候选→候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态。 | | 首页 | 最新动态优先:顶部四个可点击数据卡片(今日入库→视频榜按入库排序、播放增量→视频榜按 1 日增长排序、粉丝日增→粉丝页、待确认候选→候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态。 |
| 视频(`/videos`) | 内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量、播放增速;工具条为「排序」下拉 + 时间下拉 + 右侧放大镜搜索框(一行展示),时间支持全部/7 天/30 天/90 天/1 年/年份。 | | 视频(`/videos`) | 内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量、播放增速;工具条为「排序」下拉 + 时间下拉 + 右侧放大镜搜索框(一行展示),时间支持全部/7 天/30 天/90 天/1 年/年份。 |
| UP主(`/ups`) | 独立入口,不再与视频混排;排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;不提供按视频数筛选。 | | UP主(`/ups`) | 独立入口,不再与视频混排;卡片显示 UP 头像、视频数与总时长、最新粉丝数,以及当前排序的主/副指标(「总播放」排序时副指标为「平均播放」);排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;不提供按视频数筛选。 |
| 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 | | 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 |
| 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 | | 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 |
| UP 页 | 该 UP 的全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 | | UP 页 | 该 UP 的头像、全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 |
| 粉丝页 | 粉丝总数、日增、7 日净增、累计增长(标注起始日期);支持预设和自选日期,范围超过一年时按每月峰值日期聚合;最近记录默认 14 天并显示“较上一成功日”增量;不叠加视频数据。 | | 粉丝页 | 粉丝总数、日增、7 日净增、累计增长(标注起始日期);支持预设和自选日期,范围超过一年时按每月峰值日期聚合;最近记录默认 14 天并显示“较上一成功日”增量;不叠加视频数据。 |
| 候选池 | 待确认候选、规则命中、剩余保留时间;操作指向 Telegram/CLI。 | | 候选池 | 待确认候选、规则命中、剩余保留时间;操作指向 Telegram/CLI。 |
| 系统状态 | 最近扫描、同步、快照、下次运行、备份、鉴权和黑名单。 | | 系统状态 | 最近扫描、同步、快照、下次运行、备份、鉴权和黑名单。 |
@@ -620,6 +620,7 @@ uv run python tests/test_moves.py
- 标题归一化、时长解析、分类打分; - 标题归一化、时长解析、分类打分;
- 视频引用解析(BV/av/URL/`b23.tv` 短链)与 `view?bvid=` 契约; - 视频引用解析(BV/av/URL/`b23.tv` 短链)与 `view?bvid=` 契约;
- UP 头像与粉丝数列:`view`/`view_detail` 的 `owner.face` 解析、入库与快照写入 `video.up_face`、`/api/ups` 的 `up_face`/`follower_latest` 映射;
- 黑名单只影响发现; - 黑名单只影响发现;
- `infos` 静默缺失资源的识别; - `infos` 静默缺失资源的识别;
- `active`、`unfaved`、`removed`、`dropped` 状态迁移; - `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`。 - 自动化测试:新增 `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:可选部署 ### P4:可选部署
- 反向代理、TLS、远程访问鉴权; - 反向代理、TLS、远程访问鉴权;
+1
View File
@@ -168,6 +168,7 @@ uv run python app.py serve --preview --config data/preview/config.json
- 首页默认“最新动态”:顶部四个数据卡片(今日入库 / 播放增量 / 粉丝日增 / 待确认候选)可点击跳转(视频榜按入库或增长排序、粉丝页、候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态; - 首页默认“最新动态”:顶部四个数据卡片(今日入库 / 播放增量 / 粉丝日增 / 待确认候选)可点击跳转(视频榜按入库或增长排序、粉丝页、候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态;
- 「视频」与「UP主」是两个独立入口;工具条顺序为「排序」下拉 → 时间下拉(无需 label)→ 右侧自适应搜索框(最大 320px,放大镜按钮在输入框内),三者在 PC 与移动端都保持一行; - 「视频」与「UP主」是两个独立入口;工具条顺序为「排序」下拉 → 时间下拉(无需 label)→ 右侧自适应搜索框(最大 320px,放大镜按钮在输入框内),三者在 PC 与移动端都保持一行;
- 视频排序:内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 排序:内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布; - 视频排序:内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 排序:内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;
- UP 卡片显示 UP 头像(无头像时显示首字母占位)、视频数/总时长与最新粉丝数;「总播放」排序时副指标显示「平均播放」(相当于平均播放榜,主副指标对调);UP 详情页也显示头像;
- 时间筛选:全部 / 近 7 天 / 近 30 天 / 近 90 天 / 近 1 年 / 已入库的年份;不再提供按收藏夹过滤,UP 页也不再按视频数筛选; - 时间筛选:全部 / 近 7 天 / 近 30 天 / 近 90 天 / 近 1 年 / 已入库的年份;不再提供按收藏夹过滤,UP 页也不再按视频数筛选;
- 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜; - 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜;
- 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取; - 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取;
+1
View File
@@ -523,6 +523,7 @@ CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT);
- `video.pages`:分 P 数(来自 `infos` 的 `page`),只用于详情展示与包装诊断,不参与排序; - `video.pages`:分 P 数(来自 `infos` 的 `page`),只用于详情展示与包装诊断,不参与排序;
- `video.up_follower` / `up_follower_at` / `up_follower_backfilled`:视频**入库时**的 UP 粉丝数快照; - `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_profile(mid, name, follower_first, following_first, first_captured_at, follower_latest, last_refreshed)`:每个 UP 的首次/最近粉丝数。`follower_first` 不可被覆盖;
- 采集:收藏夹同步结束时对仍缺粉丝数的 UP 调一次 `relation/stat`(匿名可用);`first_seen >= 本次运行开始前 1 小时` 的视频记为实时采集,更早的记为回填; - 采集:收藏夹同步结束时对仍缺粉丝数的 UP 调一次 `relation/stat`(匿名可用);`first_seen >= 本次运行开始前 1 小时` 的视频记为实时采集,更早的记为回填;
- 回填:`python app.py backfill-followers [--limit N] [--dry-run]`,只补 `up_follower IS NULL` 的追踪视频; - 回填:`python app.py backfill-followers [--limit N] [--dry-run]`,只补 `up_follower IS NULL` 的追踪视频;
+26 -15
View File
@@ -97,7 +97,7 @@ class InfosResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class ViewStat: class ViewStat:
"""``x/web-interface/view`` 的七项统计。""" """``x/web-interface/view`` 的七项统计(含 UP 元数据)。"""
aid: int aid: int
bvid: str | None bvid: str | None
@@ -109,6 +109,9 @@ class ViewStat:
share: int share: int
reply: int reply: int
danmaku: int danmaku: int
up_mid: int | None = None
up_name: str | None = None
up_face: str | None = None
def metrics(self) -> dict[str, int]: def metrics(self) -> dict[str, int]:
return { return {
@@ -138,10 +141,28 @@ class ViewStat:
bvid = item.get("bvid") bvid = item.get("bvid")
if not isinstance(bvid, str) or not bvid: if not isinstance(bvid, str) or not bvid:
bvid = None 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( return cls(
aid=aid, aid=aid,
bvid=bvid, bvid=bvid,
title=str(item.get("title") or ""), title=str(item.get("title") or ""),
up_mid=up_mid,
up_name=up_name,
up_face=up_face,
**metrics, **metrics,
) )
@@ -163,6 +184,7 @@ class VideoDetail:
pages: int | None pages: int | None
cover: str | None cover: str | None
stat: ViewStat stat: ViewStat
up_face: str | None = None
def metrics(self) -> dict[str, int]: def metrics(self) -> dict[str, int]:
return self.stat.metrics() return self.stat.metrics()
@@ -170,18 +192,6 @@ class VideoDetail:
@classmethod @classmethod
def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail": def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail":
stat = ViewStat.from_api(item) 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") raw_duration = item.get("duration")
duration = ( duration = (
@@ -219,13 +229,14 @@ class VideoDetail:
aid=stat.aid, aid=stat.aid,
bvid=stat.bvid, bvid=stat.bvid,
title=stat.title, title=stat.title,
up_mid=up_mid, up_mid=stat.up_mid,
up_name=up_name, up_name=stat.up_name,
pubdate=pubdate, pubdate=pubdate,
duration=duration, duration=duration,
pages=pages, pages=pages,
cover=cover, cover=cover,
stat=stat, stat=stat,
up_face=stat.up_face,
) )
+8
View File
@@ -216,6 +216,14 @@ MIGRATIONS: tuple[Migration, ...] = (
"UPDATE video SET status = 'active' WHERE status = 'unfaved'", "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",
),
),
) )
+15 -2
View File
@@ -422,6 +422,7 @@ class Repository:
duration: int | None = None, duration: int | None = None,
pages: int | None = None, pages: int | None = None,
cover: str | None = None, cover: str | None = None,
up_face: str | None = None,
source: str = "manual", source: str = "manual",
now: int | None = None, now: int | None = None,
) -> IngestOutcome: ) -> IngestOutcome:
@@ -441,9 +442,9 @@ class Repository:
conn.execute( conn.execute(
""" """
INSERT INTO video(aid, bvid, title, up_mid, up_name, pubdate, 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) source, first_seen, last_sync, attr)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0)
ON CONFLICT(aid) DO UPDATE SET ON CONFLICT(aid) DO UPDATE SET
bvid = COALESCE(excluded.bvid, video.bvid), bvid = COALESCE(excluded.bvid, video.bvid),
title = CASE title = CASE
@@ -459,6 +460,7 @@ class Repository:
duration = COALESCE(excluded.duration, video.duration), duration = COALESCE(excluded.duration, video.duration),
pages = COALESCE(excluded.pages, video.pages), pages = COALESCE(excluded.pages, video.pages),
cover = COALESCE(excluded.cover, video.cover), cover = COALESCE(excluded.cover, video.cover),
up_face = COALESCE(excluded.up_face, video.up_face),
status = 'active', status = 'active',
source = COALESCE(video.source, excluded.source), source = COALESCE(video.source, excluded.source),
last_sync = excluded.last_sync last_sync = excluded.last_sync
@@ -473,6 +475,7 @@ class Repository:
None if duration is None else int(duration), None if duration is None else int(duration),
None if pages is None else int(pages), None if pages is None else int(pages),
cover, cover,
up_face,
source, source,
now, now,
now, now,
@@ -488,6 +491,16 @@ class Repository:
) )
return self.db.write(run) 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 # ------------------------------------------------------- up profile
def apply_up_follower( def apply_up_follower(
+2
View File
@@ -137,6 +137,7 @@ class LibraryService:
duration=detail.duration, duration=detail.duration,
pages=detail.pages, pages=detail.pages,
cover=detail.cover, cover=detail.cover,
up_face=detail.up_face,
source="candidate:telegram", source="candidate:telegram",
now=self._now(), now=self._now(),
) )
@@ -206,6 +207,7 @@ class LibraryService:
duration=detail.duration, duration=detail.duration,
pages=detail.pages, pages=detail.pages,
cover=detail.cover, cover=detail.cover,
up_face=detail.up_face,
source="manual:telegram", source="manual:telegram",
now=self._now(), now=self._now(),
) )
+2
View File
@@ -211,6 +211,8 @@ class SnapshotService:
err=None, err=None,
captured_at=self._now(), captured_at=self._now(),
) )
if getattr(stat, "up_face", None):
self.repo.apply_video_up_face(aid, stat.up_face)
summary.success += 1 summary.success += 1
else: else:
summary.failed += 1 summary.failed += 1
+23
View File
@@ -387,6 +387,7 @@ class DashboardQueries:
"up_follower_at": video.get("up_follower_at"), "up_follower_at": video.get("up_follower_at"),
"up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0), "up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0),
"cover": normalize_cover(video.get("cover")), "cover": normalize_cover(video.get("cover")),
"up_face": normalize_cover(video.get("up_face")),
"folder_id": video.get("folder_id"), "folder_id": video.get("folder_id"),
"folder_title": video.get("folder_title"), "folder_title": video.get("folder_title"),
"folder_year": video.get("folder_year"), "folder_year": video.get("folder_year"),
@@ -1074,7 +1075,10 @@ class DashboardQueries:
"up_mid": mid, "up_mid": mid,
"up_name": video.get("up_name") or f"UP {mid}", "up_name": video.get("up_name") or f"UP {mid}",
"videos": [], "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"]))) metrics = _metric_map(snapshots.get(int(video["aid"])))
group["videos"].append((video, metrics)) group["videos"].append((video, metrics))
items = [] items = []
@@ -1105,6 +1109,8 @@ class DashboardQueries:
items.append({ items.append({
"up_mid": group["up_mid"], "up_mid": group["up_mid"],
"up_name": group["up_name"], "up_name": group["up_name"],
"up_face": normalize_cover(group["face"]),
"follower_latest": None,
"video_count": len(group_videos), "video_count": len(group_videos),
"total_views": total_views, "total_views": total_views,
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None, "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 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] items = [item for item in items if item["video_count"] >= min_videos]
sort_map = { sort_map = {
"views": lambda item: item["total_views"], "views": lambda item: item["total_views"],
@@ -1256,6 +1276,9 @@ class DashboardQueries:
return { return {
"up_mid": mid, "up_mid": mid,
"up_name": items[0].get("up_name") or f"UP {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), "video_count": len(items),
"total_views": total_views, "total_views": total_views,
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None, "avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
+20 -7
View File
@@ -19,6 +19,17 @@ export function rankBadge(rank) {
return `<span class="rank-badge${top}">${rank}</span>`; return `<span class="rank-badge${top}">${rank}</span>`;
} }
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 `<div class="${className} avatar-placeholder" aria-hidden="true">${escapeHtml(initial)}</div>`;
}
return `<div class="${className}"><img src="${escapeAttr(url)}" alt="${name}"
loading="lazy" referrerpolicy="no-referrer"></div>`;
}
export function videoCard(item, { export function videoCard(item, {
rank = null, primary = "view", primaryLabel = "", primaryText = null, rank = null, primary = "view", primaryLabel = "", primaryText = null,
showStatus = false, showStatus = false,
@@ -64,27 +75,29 @@ export function videoCard(item, {
export function upCard(item, { export function upCard(item, {
rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null, 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 value = primaryText !== null ? primaryText : item[primary];
const secondary = secondaryText !== null ? secondaryText : formatCount(item.total_views);
const hours = item.content_hours; const hours = item.content_hours;
const follower = item.follower_latest;
const followerText = follower === null || follower === undefined
? "粉丝 —"
: `粉丝 ${formatCount(follower)}`;
return ` return `
<a class="up-card" href="/up/${item.up_mid}"> <a class="up-card" href="/up/${item.up_mid}">
${rankBadge(rank)} ${rankBadge(rank)}
${displayCover} ${avatar(item, "up-avatar")}
<div class="up-card-body"> <div class="up-card-body">
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3> <h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
<div class="video-meta"> <div class="video-meta">
<span>${formatNumber(item.video_count)} 条视频</span> <span>${formatNumber(item.video_count)} 条视频</span>
<span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span> <span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
<span>${followerText}</span>
</div> </div>
<div class="video-stats"> <div class="video-stats">
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span> <span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
<span class="secondary-stat">总播放 ${formatCount(item.total_views)}</span> <span class="secondary-stat">${escapeHtml(secondaryLabel)} ${escapeHtml(secondary)}</span>
</div> </div>
</div> </div>
<span class="card-arrow" aria-hidden="true">›</span> <span class="card-arrow" aria-hidden="true">›</span>
+34
View File
@@ -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; } .cover-placeholder img.image-error { opacity: 0; }
.video-card-body { grid-column: 3; grid-row: 1; min-width: 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-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 { .video-title {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -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); background: var(--surface);
} }
.hero-cover { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; } .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-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; } .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; } .compact-hero { padding: 18px; }
+12 -1
View File
@@ -133,6 +133,14 @@ function upPrimary(item, sort) {
return { label: "播放/小时", text: formatCount(item.efficiency) }; 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) { function listHtml(data, scope, sort, startRank) {
if (!data.items.length) { if (!data.items.length) {
return emptyState( return emptyState(
@@ -143,8 +151,11 @@ function listHtml(data, scope, sort, startRank) {
if (scope === "up") { if (scope === "up") {
return `<div class="rank-list">${data.items.map((item, index) => { return `<div class="rank-list">${data.items.map((item, index) => {
const primary = upPrimary(item, sort); const primary = upPrimary(item, sort);
const secondary = upSecondary(item, sort);
return upCard(item, { 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("")}</div>`; }).join("")}</div>`;
} }
+4 -1
View File
@@ -1,6 +1,6 @@
import { api } from "../api.js"; import { api } from "../api.js";
import { import {
badge, emptyState, sectionHeader, videoCard, avatar, badge, emptyState, sectionHeader, videoCard,
} from "../components.js"; } from "../components.js";
import { barChart, lineChart, themeColor } from "../charts.js"; import { barChart, lineChart, themeColor } from "../charts.js";
import { import {
@@ -43,6 +43,8 @@ export async function renderUp(root, { params, query }) {
</header> </header>
<div class="detail-hero compact-hero"> <div class="detail-hero compact-hero">
<div class="up-hero-head">
${avatar(up, "hero-avatar")}
<div class="detail-hero-body"> <div class="detail-hero-body">
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1> <h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
<div class="detail-meta"> <div class="detail-meta">
@@ -53,6 +55,7 @@ export async function renderUp(root, { params, query }) {
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p> <p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
</div> </div>
</div> </div>
</div>
<div class="mini-stats up-stats"> <div class="mini-stats up-stats">
<div><span>视频数</span><strong>${formatCount(up.video_count)}</strong></div> <div><span>视频数</span><strong>${formatCount(up.video_count)}</strong></div>
+1 -1
View File
@@ -182,7 +182,7 @@ VIEW_PAYLOAD: dict[str, Any] = {
{"cid": 43184885058, "page": 1, "from": "vupload", "part": "第一部分"}, {"cid": 43184885058, "page": 1, "from": "vupload", "part": "第一部分"},
{"cid": 43184885059, "page": 2, "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": { "stat": {
"aid": 117530286889638, "aid": 117530286889638,
"view": 819, "view": 819,
+2
View File
@@ -353,6 +353,7 @@ def test_view_detail_by_aid_and_bvid():
assert detail.pages == 2 assert detail.pages == 2
assert detail.cover and detail.cover.startswith("http") assert detail.cover and detail.cover.startswith("http")
assert detail.metrics()["view"] == 819 assert detail.metrics()["view"] == 819
assert detail.up_face and detail.up_face.startswith("http")
assert "aid=117530286889638" in transport.calls[0]["url"] assert "aid=117530286889638" in transport.calls[0]["url"]
transport = QueueTransport([http_json(VIEW_PAYLOAD)]) transport = QueueTransport([http_json(VIEW_PAYLOAD)])
@@ -373,6 +374,7 @@ def test_view_detail_fallbacks_and_required_arg():
assert detail.duration == 0 assert detail.duration == 0
assert detail.pages is None assert detail.pages is None
assert detail.cover is None assert detail.cover is None
assert detail.up_face is None
client, _ = make_client(QueueTransport([])) client, _ = make_client(QueueTransport([]))
for kwargs in ({}, {"aid": 1, "bvid": "BV1xvxi6LEZD"}): for kwargs in ({}, {"aid": 1, "bvid": "BV1xvxi6LEZD"}):
+1 -1
View File
@@ -51,7 +51,7 @@ def test_init_and_status():
path = write_config(tmp) path = write_config(tmp)
code, output = run_cli(["init", "--config", str(path)]) code, output = run_cli(["init", "--config", str(path)])
assert code == 0 assert code == 0
assert '"schema_version": 5' in output assert '"schema_version": 6' in output
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
+4 -4
View File
@@ -24,7 +24,7 @@ def test_write_read_transaction():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db = make_db(tmp) db = make_db(tmp)
try: 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')")) 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" 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) db2 = make_db(tmp)
try: try:
assert db2.schema_version == 5 assert db2.schema_version == 6
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 5 assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 6
finally: finally:
db2.close() db2.close()
@@ -123,7 +123,7 @@ def test_migration_backup_on_existing_db():
db = Database(path, backup_dir=Path(tmp) / "backup") db = Database(path, backup_dir=Path(tmp) / "backup")
try: try:
applied = db.initialize() 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 assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1
finally: finally:
db.close() db.close()
+2 -1
View File
@@ -45,7 +45,7 @@ def detail(
data["pubdate"] = pubdate data["pubdate"] = pubdate
data["ctime"] = pubdate data["ctime"] = pubdate
data["duration"] = duration 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)] data["pages"] = [{"cid": 1, "page": i + 1} for i in range(pages)]
return VideoDetail.from_api(data) return VideoDetail.from_api(data)
@@ -150,6 +150,7 @@ def test_ingest_candidate_added_and_snapshot():
assert video["source"] == "candidate:telegram" assert video["source"] == "candidate:telegram"
assert video["folder_id"] is None assert video["folder_id"] is None
assert video["up_mid"] == 100 assert video["up_mid"] == 100
assert video["up_face"] == "http://i1.hdslb.com/bfs/face/u.jpg"
snap = repo.get_snapshot(DATE, 9) snap = repo.get_snapshot(DATE, 9)
assert snap is not None and snap["ok"] == 1 and snap["view"] == 819 assert snap is not None and snap["ok"] == 1 and snap["view"] == 819
assert ups.calls == [([100], {100: "UP100"})] assert ups.calls == [([100], {100: "UP100"})]
+9 -9
View File
@@ -37,8 +37,8 @@ def test_fresh_and_idempotent():
conn = sqlite3.connect(Path(tmp) / "app.db") conn = sqlite3.connect(Path(tmp) / "app.db")
try: try:
applied = run_migrations(conn) applied = run_migrations(conn)
assert applied == [1, 2, 3, 4, 5] assert applied == [1, 2, 3, 4, 5, 6]
assert current_version(conn) == 5 assert current_version(conn) == 6
assert EXPECTED_TABLES <= table_names(conn) assert EXPECTED_TABLES <= table_names(conn)
assert "schema_version" in table_names(conn) assert "schema_version" in table_names(conn)
columns = { columns = {
@@ -54,7 +54,7 @@ def test_fresh_and_idempotent():
} }
assert { assert {
"pages", "duration", "up_follower", "up_follower_at", "pages", "duration", "up_follower", "up_follower_at",
"up_follower_backfilled", "up_follower_backfilled", "up_face",
} <= video_columns } <= video_columns
profile_columns = { profile_columns = {
row[1] for row in conn.execute("PRAGMA table_info(up_profile)") 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) == [] assert run_migrations(conn) == []
row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone() row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()
assert row[0] == 5 assert row[0] == 6
finally: finally:
conn.close() conn.close()
@@ -81,15 +81,15 @@ def test_backup_before_upgrade():
conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')") conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')")
conn.commit() 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( applied = run_migrations(
conn, conn,
backup_dir=backup_dir, backup_dir=backup_dir,
now=1_800_000_000, now=1_800_000_000,
migrations=MIGRATIONS + (v6,), migrations=MIGRATIONS + (v7,),
) )
assert applied == [6] assert applied == [7]
backups = list(backup_dir.glob("app-pre-v5-*.db")) backups = list(backup_dir.glob("app-pre-v6-*.db"))
assert len(backups) == 1 assert len(backups) == 1
check = sqlite3.connect(backups[0]) check = sqlite3.connect(backups[0])
try: try:
@@ -174,7 +174,7 @@ def test_v5_normalizes_unfaved():
) )
conn.commit() conn.commit()
applied = run_migrations(conn) applied = run_migrations(conn)
assert applied == [5] assert applied == [5, 6]
rows = dict(conn.execute("SELECT aid, status FROM video").fetchall()) rows = dict(conn.execute("SELECT aid, status FROM video").fetchall())
assert rows == {1: "active", 2: "dropped"} assert rows == {1: "active", 2: "dropped"}
finally: finally:
+3
View File
@@ -153,6 +153,9 @@ def test_snapshot_success_resume_and_force():
assert (row["view"], row["like"], row["favorite"], row["coin"], assert (row["view"], row["like"], row["favorite"], row["coin"],
row["share"], row["reply"], row["danmaku"]) == (100, 10, 3, 2, 1, 4, 5) row["share"], row["reply"], row["danmaku"]) == (100, 10, 3, 2, 1, 4, 5)
assert row["ok"] == 1 and row["src"] == "view" 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) fan = repo.get_up_stat(DATE, 37754047)
assert fan["follower"] == 2767268 and fan["ok"] == 1 assert fan["follower"] == 2767268 and fan["ok"] == 1
assert repo.kv_get(service.completed_key(DATE)) == "1" assert repo.kv_get(service.completed_key(DATE)) == "1"
+6 -3
View File
@@ -182,6 +182,8 @@ def test_up_queries():
assert ups["items"][0]["video_count"] == 2 assert ups["items"][0]["video_count"] == 2
assert ups["items"][0]["total_views"] == 6400 assert ups["items"][0]["total_views"] == 6400
assert ups["items"][0]["avg_views"] == 3200.0 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) one = q.list_ups(min_videos=2)
assert one["total"] == 1 assert one["total"] == 1
@@ -190,6 +192,7 @@ def test_up_queries():
assert detail["video_count"] == 2 assert detail["video_count"] == 2
assert detail["total_views"] == 6400 assert detail["total_views"] == 6400
assert detail["blacklisted"] is False 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"]["dates"] == list(DATES)
assert detail["series"]["views"] == [6000, 6200, 6400] assert detail["series"]["views"] == [6000, 6200, 6400]
assert detail["space_url"].endswith("/1") assert detail["space_url"].endswith("/1")
@@ -454,7 +457,7 @@ def test_about_payload():
assert about["snapshot_days"] == 3 assert about["snapshot_days"] == 3
assert about["snapshot_first_date"] == DATES[0] assert about["snapshot_first_date"] == DATES[0]
assert about["fans_start_date"] == DATES[0] assert about["fans_start_date"] == DATES[0]
assert about["schema_version"] == 5 assert about["schema_version"] == 6
assert about["analytics"] == { assert about["analytics"] == {
"min_duration_seconds": 600, "min_duration_seconds": 600,
"maturity_days": 2, "maturity_days": 2,
@@ -464,8 +467,8 @@ def test_about_payload():
} }
assert about["heat_weights"]["view"] == 0.30 assert about["heat_weights"]["view"] == 0.30
assert about["followers"]["videos_total"] == 3 assert about["followers"]["videos_total"] == 3
assert about["followers"]["videos_covered"] == 0 assert about["followers"]["videos_covered"] == 3
assert about["followers"]["videos_missing"] == 3 assert about["followers"]["videos_missing"] == 0
assert about["folders"][0]["media_id"] == 3700023351 assert about["folders"][0]["media_id"] == 3700023351
assert about["preview"] is False assert about["preview"] is False
finally: finally:
+1 -1
View File
@@ -75,7 +75,7 @@ def test_readonly_routes_and_static():
assert status == 200 and payload["ok"] is True assert status == 200 and payload["ok"] is True
status, _, payload = running.json("/api/about") status, _, payload = running.json("/api/about")
assert status == 200 assert status == 200
assert payload["data"]["schema_version"] == 5 assert payload["data"]["schema_version"] == 6
assert payload["data"]["analytics"]["min_duration_seconds"] == 600 assert payload["data"]["analytics"]["min_duration_seconds"] == 600
status, _, overview = running.json("/api/overview") status, _, overview = running.json("/api/overview")
+10
View File
@@ -106,6 +106,16 @@ def build_db(tmp: str | Path) -> Path:
) )
repo.set_video_status(103, "unfaved") 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 = { metrics = {
101: {"view": 1000, "like": 100, "coin": 20, "favorite": 30, "share": 2, "reply": 10, "danmaku": 12}, 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}, 102: {"view": 5000, "like": 200, "coin": 50, "favorite": 100, "share": 10, "reply": 30, "danmaku": 40},