From c0b02863956707e8281abea8de6805b430d25fb5 Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 11:33:15 +0800 Subject: [PATCH] feat(web): split video and up rankings and add about page --- reaction_board/web/static/app.js | 26 ++- reaction_board/web/static/components.js | 29 +-- reaction_board/web/static/style.css | 5 + reaction_board/web/static/views/about.js | 96 +++++++++ reaction_board/web/static/views/home.js | 8 +- reaction_board/web/static/views/rankings.js | 206 +++++++++++++------- reaction_board/web/static/views/up.js | 11 +- reaction_board/web/static/views/video.js | 7 +- tests/test_database.py | 8 +- 9 files changed, 298 insertions(+), 98 deletions(-) create mode 100644 reaction_board/web/static/views/about.js diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 2280cda..7441acf 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -4,25 +4,31 @@ import { onRoute, parseLocation, startRouter } from "./router.js"; import { renderCandidates } from "./views/candidates.js"; import { renderFans } from "./views/fans.js"; import { renderHome } from "./views/home.js"; -import { renderRankings } from "./views/rankings.js"; +import { renderUps, renderVideos } from "./views/rankings.js"; import { renderSystem } from "./views/system.js"; import { renderUp } from "./views/up.js"; import { renderVideo } from "./views/video.js"; +import { renderAbout } from "./views/about.js"; const NAV_ITEMS = [ { path: "/home", label: "首页", icon: "home" }, - { path: "/rank", label: "榜单", icon: "rank" }, + { path: "/videos", label: "视频", icon: "video" }, + { path: "/ups", label: "UP主", icon: "up" }, { path: "/fans", label: "粉丝", icon: "fans" }, { path: "/candidates", label: "候选", icon: "candidate", protected: true }, { path: "/system", label: "系统", icon: "system", protected: true }, + { path: "/about", label: "说明", icon: "info" }, ]; const ROUTES = [ { pattern: /^\/home$/, view: renderHome, title: "最新动态" }, - { pattern: /^\/rank$/, view: renderRankings, title: "榜单" }, + { pattern: /^\/videos$/, view: renderVideos, title: "视频" }, + { pattern: /^\/ups$/, view: renderUps, title: "UP主" }, + { pattern: /^\/rank$/, view: renderLegacyRank, title: "榜单" }, { pattern: /^\/video\/(\d+)$/, view: renderVideo, title: "视频详情" }, { pattern: /^\/up\/(\d+)$/, view: renderUp, title: "UP 详情" }, { pattern: /^\/fans$/, view: renderFans, title: "粉丝" }, + { pattern: /^\/about$/, view: renderAbout, title: "数据说明" }, { pattern: /^\/candidates$/, view: renderCandidates, title: "候选池", beforeEnter: requireAuth, @@ -33,6 +39,13 @@ const ROUTES = [ }, ]; +function renderLegacyRank(root, context) { + // 旧链接 /rank?scope=up 仍可用,但不再在页面内混排视频与 UP。 + return context.query.get("scope") === "up" + ? renderUps(root, context) + : renderVideos(root, context); +} + let authState = { authEnabled: false, authenticated: true }; function canAccessProtected() { @@ -53,9 +66,12 @@ function icon(name) { const paths = { home: '', rank: '', + video: '', + up: '', fans: '', candidate: '', system: '', + info: '', }; return ``; @@ -74,10 +90,12 @@ function renderNav() { function activeNav(path) { let active = "/home"; - if (path.startsWith("/rank") || path.startsWith("/video/") || path.startsWith("/up/")) active = "/rank"; + if (path.startsWith("/videos") || path.startsWith("/video/") || path.startsWith("/rank")) active = "/videos"; + else if (path.startsWith("/ups") || path.startsWith("/up/")) active = "/ups"; else if (path.startsWith("/fans")) active = "/fans"; else if (path.startsWith("/candidates")) active = "/candidates"; else if (path.startsWith("/system")) active = "/system"; + else if (path.startsWith("/about")) active = "/about"; document.querySelectorAll("[data-nav]").forEach((item) => { item.classList.toggle("active", item.dataset.nav === active); }); diff --git a/reaction_board/web/static/components.js b/reaction_board/web/static/components.js index da1c646..063370d 100644 --- a/reaction_board/web/static/components.js +++ b/reaction_board/web/static/components.js @@ -19,9 +19,16 @@ export function rankBadge(rank) { return `${rank}`; } -export function videoCard(item, { rank = null, primary = "view", showStatus = false } = {}) { +export function videoCard(item, { + rank = null, primary = "view", primaryLabel = "", primaryText = null, + showStatus = false, +} = {}) { const metrics = item.metrics || {}; - const primaryValue = metrics[primary]; + const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary); + const primaryValue = primaryText !== null + ? primaryText + : (hasMetric ? metrics[primary] : item[primary]); + const label = primaryLabel || metricLabel(primary); const growth = item.growth; const delta = growth?.available ? growth.delta : null; const title = escapeHtml(item.title || "(无标题)"); @@ -45,12 +52,9 @@ export function videoCard(item, { rank = null, primary = "view", showStatus = fa
- ${formatCount(primaryValue)}${escapeHtml(metricLabel(primary))} + ${escapeHtml(primaryValue ?? "—")}${escapeHtml(label)} ${delta !== null ? `${formatDelta(delta)}` : ""} - 赞 ${formatCount(metrics.like)} - 币 ${formatCount(metrics.coin)} - 藏 ${formatCount(metrics.favorite)} 互动 ${formatPercent(item.interaction_rate)}
@@ -58,12 +62,16 @@ export function videoCard(item, { rank = null, primary = "view", showStatus = fa `; } -export function upCard(item, { rank = null } = {}) { +export function upCard(item, { + rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = 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 hours = item.content_hours; return ` ${rankBadge(rank)} @@ -72,12 +80,11 @@ export function upCard(item, { rank = null } = {}) {

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

${formatNumber(item.video_count)} 条视频 - 均播 ${formatCount(item.avg_views)} + ${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}
- ${formatCount(item.total_views)}总播放 - 总互动 ${formatCount(item.total_interactions)} - 均互动 ${formatPercent(item.avg_interaction)} + ${escapeHtml(value ?? "—")}${escapeHtml(primaryLabel)} + 总播放 ${formatCount(item.total_views)}
diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index 2434893..75cf45c 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -891,3 +891,8 @@ h1, h2, h3, p { margin: 0; } .candidate-card { grid-template-columns: 84px minmax(0, 1fr); } .candidate-thumb { width: 84px; } } +.about-page .panel + .panel { margin-top: 12px; } +.about-page .panel h2 { font-size: 15px; margin-bottom: 8px; } +.about-list { margin: 0; padding-left: 18px; display: grid; gap: 6px; font-size: 13px; line-height: 1.55; } +.about-list code { padding: 1px 4px; border-radius: 4px; background: var(--accent-soft); font-size: 12px; } +.about-list.about-weights { list-style: none; padding-left: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/reaction_board/web/static/views/about.js b/reaction_board/web/static/views/about.js new file mode 100644 index 0000000..7090673 --- /dev/null +++ b/reaction_board/web/static/views/about.js @@ -0,0 +1,96 @@ +import { api } from "../api.js"; +import { escapeHtml } from "../format.js"; + +function weightRows(weights) { + return Object.entries(weights || {}) + .map(([metric, value]) => `
  • ${escapeHtml(metric)} ${(Number(value) * 100).toFixed(0)}%
  • `) + .join(""); +} + +export async function renderAbout(root) { + const data = await api("/api/about"); + const analytics = data.analytics || {}; + const followers = data.followers || {}; + const coverage = followers.videos_total + ? Math.round((followers.videos_covered / followers.videos_total) * 100) + : 0; + + root.innerHTML = `
    +
    +

    数据说明 统计口径与方法

    +
    + +
    +

    数据来源与更新

    +
      +
    • 视频元数据、播放与互动来自 B 站公开接口;粉丝数来自 relation/stat,匿名可用。
    • +
    • 发现与收藏夹同步每小时运行一次,每日快照在 00:01 采集;最新数据日:${escapeHtml(data.data_date || "暂无")}。
    • +
    • 当前快照覆盖 ${data.snapshot_days} 天${data.snapshot_first_date ? `(起始 ${escapeHtml(data.snapshot_first_date)})` : ""};历史快照无法从 B 站回填,只能从部署当天开始积累。
    • +
    • 数据库 schema 版本 ${data.schema_version};${data.preview ? "当前为预览数据,不影响真实数据库。" : "当前为真实数据库。"}
    • +
    +
    + +
    +

    三个时间概念

    +
      +
    • 发布时间(pubdate):视频在 B 站上线的时刻。
    • +
    • 入库时间(first_seen):本站第一次看到并写入该视频的时刻。人工加收藏或搜索确认都可能导致入库晚于发布。
    • +
    • 快照日期(stat_date):每天采集一次播放与互动,写入 snapshot。缺失日不插值,曲线直接断开。
    • +
    +

    因此,同一个视频的“发布后 7 天数据”和“入库后 7 天数据”可能不是同一段区间;详情页会同时展示发布时间与入库时间。

    +
    + +
    +

    指标口径

    +
      +
    • 播放 / 投币 / 收藏 / 评论:直接取当日快照;弹幕与转发不单列,已并入“互动”。
    • +
    • 互动:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。
    • +
    • 互动率:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。
    • +
    • 综合热度:每项先 log1p,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。
    • +
    • 内容效率:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。
    • +
    • 互动效率:互动 / 内容小时,互动口径同上。
    • +
    • UP 内容效率:把该 UP 的全部视频合并后计算,并用 Gamma–Poisson 经验贝叶斯向全库均值收缩,避免单条视频或极小样本冲榜。收缩强度 κ ${analytics.shrink_minutes ? `固定为 ${analytics.shrink_minutes} 分钟` : "取全库 UP 总内容分钟的中位数"}。
    • +
    • 累计触达:总播放。它是规模指标,不代表吸引力;高产 UP 排在前列是正常结果。
    • +
    • 分 P:pages 是分 P 数,duration 是该 bvid 各 P 时长之和。B 站不公开单 P 播放,因此没有单 P 榜;分 P 数只用于详情展示,不参与排序。
    • +
    +
    + +
    +

    榜单筛选

    +
      +
    • 内容效率与互动效率只统计时长 ≥ ${analytics.min_duration_seconds} 秒的视频,排除结构不同的短视频与切片。
    • +
    • 只统计发布满 ${analytics.maturity_days} 天的视频;reaction 视频通常在两周内接近平台期,过早比较会系统性低估新视频。
    • +
    • 不引入无界的“年龄惩罚”:超过成熟窗口后不再按天加分或扣分。
    • +
    • duration 为空或 ≤ 0 的视频(通常只在失效视频上出现)不进入任何时长相关榜,也不做插补。
    • +
    • 当前配置的最小播放门槛为 ${analytics.min_view_for_efficiency}。
    • +
    +
    + +
    +

    综合热度权重

    +
      ${weightRows(data.heat_weights)}
    +

    权重是主观配置,可在配置文件中调整;页面展示的是当前生效值。

    +
    + +
    +

    UP 粉丝快照

    +
      +
    • 视频入库时记录一次 UP 当前粉丝数,写入 video.up_follower;同一 UP 的旧视频不会被新快照覆盖。
    • +
    • up_profile.follower_first 保留首次采集值,follower_latest 随刷新更新。
    • +
    • 历史视频的粉丝数是回填值,会标记 backfilled;它与真实的“入库时”存在时间差,暂不用于榜单排序。
    • +
    • 当前覆盖:${followers.videos_covered} / ${followers.videos_total} 条视频(${coverage}%),涉及 ${followers.ups_tracked} 个 UP;缺失 ${followers.videos_missing} 条,可运行 app.py backfill-followers 补齐。
    • +
    +
    + +
    +

    已知局限

    +
      +
    • 播放是累计值,受发布时间影响;效率榜用成熟窗口过滤,但无法完全消除历史推荐差异。
    • +
    • B 站不公开完播率与单 P 播放,内容小时是“供给时长”,不是“实际观看时长”。
    • +
    • 多 P 可能让同一用户产生多次播放计数,本站不单独校正,属于已知偏差。
    • +
    • 粉丝数可能包含不活跃账号;粉丝快照暂不参与任何榜单。
    • +
    • 样本不足(少于 2 天快照、单视频 UP)会显示提示,不伪造 0。
    • +
    +
    +
    `; +} diff --git a/reaction_board/web/static/views/home.js b/reaction_board/web/static/views/home.js index 7ca2bc4..a329c22 100644 --- a/reaction_board/web/static/views/home.js +++ b/reaction_board/web/static/views/home.js @@ -90,7 +90,7 @@ export async function renderHome(root) {
    ${addedNote}
    @@ -102,7 +102,7 @@ export async function renderHome(root) {

    最近发布

    - 查看全部 + 查看全部

    按视频发布时间排序,与入库时间无关。

    @@ -114,7 +114,7 @@ export async function renderHome(root) {

    增长动态

    - 增长榜 + 增长榜
    ${growth.available && growth.items.length ? `
    ${growth.items.map((item, index) => videoCard(item, { rank: index + 1 })).join("")}
    ` @@ -124,7 +124,7 @@ export async function renderHome(root) {

    热度速览

    - 完整榜单 + 完整榜单
    ${heatStrip(heat.items)}
    diff --git a/reaction_board/web/static/views/rankings.js b/reaction_board/web/static/views/rankings.js index 1746b9e..6e0eef4 100644 --- a/reaction_board/web/static/views/rankings.js +++ b/reaction_board/web/static/views/rankings.js @@ -1,21 +1,32 @@ import { api } from "../api.js"; import { - centerActiveChips, chip, emptyState, pager, sectionHeader, upCard, videoCard, + centerActiveChips, chip, emptyState, pager, upCard, videoCard, } from "../components.js"; -import { escapeAttr, escapeHtml, formatCount } from "../format.js"; +import { escapeAttr, escapeHtml, formatCount, formatDate, formatNumber, formatPercent } from "../format.js"; import { replaceQuery } from "../router.js"; const VIDEO_SORTS = [ - ["heat", "综合热度"], ["view", "播放"], ["like", "点赞"], ["coin", "投币"], - ["favorite", "收藏"], ["share", "转发"], ["reply", "评论"], ["danmaku", "弹幕"], - ["interaction", "互动率"], ["growth", "播放增量"], ["growth_rate", "播放增速"], + ["efficiency", "内容效率"], + ["heat", "综合热度"], + ["view", "播放"], + ["coin", "投币"], + ["favorite", "收藏"], + ["reply", "评论"], + ["interaction", "互动率"], + ["growth", "播放增量"], + ["growth_rate", "播放增速"], ]; const UP_SORTS = [ - ["avg_views", "平均播放"], ["avg_interaction", "平均互动"], - ["interaction", "总互动"], ["latest", "最近发布"], - ["count", "视频数"], ["views", "总播放"], + ["efficiency", "内容效率"], + ["interaction_efficiency", "互动效率"], + ["duration", "内容量"], + ["views", "总播放"], + ["count", "视频数"], + ["avg_views", "平均播放"], + ["interaction", "总互动"], + ["latest", "最近发布"], ]; -const METRIC_PRIMARY = new Set(["view", "like", "coin", "favorite", "share", "reply", "danmaku"]); +const VIDEO_METRIC_PRIMARY = new Set(["view", "coin", "favorite", "reply"]); const OVERVIEW_TTL = 60_000; let overviewCache = { at: 0, promise: null }; @@ -36,10 +47,6 @@ function controls({ scope, sort, within, growthDays, q, folder, minVideos, folde const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS; return `
    -
    - - -
    ${detail.blacklisted ? `

    该 UP 在黑名单中;黑名单只影响未来发现,不影响当前视频追踪。

    ` : ""} + ${detail.up_follower_backfilled ? `

    UP 粉丝数为回填值,不是真实的入库时点。

    ` : ""}
    @@ -75,6 +77,9 @@ export async function renderVideo(root, { params, query }) {
    综合热度${formatScore(detail.heat)}
    全库百分位${detail.heat_percentile === null || detail.heat_percentile === undefined ? "—" : `前 ${Math.max(0, 100 - detail.heat_percentile).toFixed(0)}%`}
    播放日增${detail.growth?.available ? formatDelta(detail.growth.delta) : "—"}
    +
    内容效率${detail.efficiency == null ? "—" : `${formatCount(detail.efficiency)}/小时`}
    +
    互动效率${detail.interaction_efficiency == null ? "—" : `${formatCount(detail.interaction_efficiency)}/小时`}
    +
    UP 粉丝(入库时)${formatCount(detail.up_follower)}
    快照数${samples}
    最近快照${escapeHtml(detail.snapshot_date || "—")}
    diff --git a/tests/test_database.py b/tests/test_database.py index 3c01ad6..d9c949e 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 == 3 + assert db.schema_version == 4 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 == 3 - assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 3 + assert db2.schema_version == 4 + assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 4 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] + assert applied == [1, 2, 3, 4] assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1 finally: db.close()