feat(web): split video and up rankings and add about page
This commit is contained in:
1 parent
5a656d42a8
commit
c0b0286395
9 files changed
+298
-98
No files matched your search
@@ -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: '<path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z"/>',
|
||||
rank: '<path d="M5 20V10m7 10V4m7 16v-7"/>',
|
||||
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
||||
up: '<circle cx="12" cy="8" r="3"/><path d="M5 21a7 7 0 0 1 14 0"/><path d="m18.5 5 .6 1.2 1.4.2-1 1 .2 1.4-1.2-.6-1.2.6.2-1.4-1-1 1.4-.2z"/>',
|
||||
fans: '<path d="M16 20v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1"/><circle cx="9.5" cy="7" r="3"/><path d="M17 11a3 3 0 1 0 0-6m1 15v-1a4 4 0 0 0-3-3.8"/>',
|
||||
candidate: '<path d="M9 11l2 2 4-4"/><rect x="4" y="4" width="16" height="16" rx="3"/>',
|
||||
system: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
|
||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><circle cx="12" cy="7.5" r="1"/>',
|
||||
};
|
||||
return `<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ""}</svg>`;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -19,9 +19,16 @@ export function rankBadge(rank) {
|
||||
return `<span class="rank-badge${top}">${rank}</span>`;
|
||||
}
|
||||
|
||||
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
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span class="primary-stat">
|
||||
<b>${formatCount(primaryValue)}</b><small>${escapeHtml(metricLabel(primary))}</small>
|
||||
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
|
||||
</span>
|
||||
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</span>` : ""}
|
||||
<span class="secondary-stat">赞 ${formatCount(metrics.like)}</span>
|
||||
<span class="secondary-stat optional">币 ${formatCount(metrics.coin)}</span>
|
||||
<span class="secondary-stat optional">藏 ${formatCount(metrics.favorite)}</span>
|
||||
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,12 +62,16 @@ export function videoCard(item, { rank = null, primary = "view", showStatus = fa
|
||||
</a>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<a class="up-card" href="/up/${item.up_mid}">
|
||||
${rankBadge(rank)}
|
||||
@@ -72,12 +80,11 @@ export function upCard(item, { rank = null } = {}) {
|
||||
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
|
||||
<div class="video-meta">
|
||||
<span>${formatNumber(item.video_count)} 条视频</span>
|
||||
<span>均播 ${formatCount(item.avg_views)}</span>
|
||||
<span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span class="primary-stat"><b>${formatCount(item.total_views)}</b><small>总播放</small></span>
|
||||
<span class="secondary-stat">总互动 ${formatCount(item.total_interactions)}</span>
|
||||
<span class="secondary-stat">均互动 ${formatPercent(item.avg_interaction)}</span>
|
||||
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
|
||||
<span class="secondary-stat">总播放 ${formatCount(item.total_views)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="card-arrow" aria-hidden="true">›</span>
|
||||
|
||||
@@ -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)); }
|
||||
@@ -0,0 +1,96 @@
|
||||
import { api } from "../api.js";
|
||||
import { escapeHtml } from "../format.js";
|
||||
|
||||
function weightRows(weights) {
|
||||
return Object.entries(weights || {})
|
||||
.map(([metric, value]) => `<li><code>${escapeHtml(metric)}</code> ${(Number(value) * 100).toFixed(0)}%</li>`)
|
||||
.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 = `<section class="page about-page">
|
||||
<header class="page-head">
|
||||
<h1>数据说明 <span class="page-sub">统计口径与方法</span></h1>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>数据来源与更新</h2>
|
||||
<ul class="about-list">
|
||||
<li>视频元数据、播放与互动来自 B 站公开接口;粉丝数来自 <code>relation/stat</code>,匿名可用。</li>
|
||||
<li>发现与收藏夹同步每小时运行一次,每日快照在 00:01 采集;最新数据日:<strong>${escapeHtml(data.data_date || "暂无")}</strong>。</li>
|
||||
<li>当前快照覆盖 <strong>${data.snapshot_days}</strong> 天${data.snapshot_first_date ? `(起始 ${escapeHtml(data.snapshot_first_date)})` : ""};历史快照无法从 B 站回填,只能从部署当天开始积累。</li>
|
||||
<li>数据库 schema 版本 <code>${data.schema_version}</code>;${data.preview ? "当前为预览数据,不影响真实数据库。" : "当前为真实数据库。"}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>三个时间概念</h2>
|
||||
<ul class="about-list">
|
||||
<li><strong>发布时间</strong>(<code>pubdate</code>):视频在 B 站上线的时刻。</li>
|
||||
<li><strong>入库时间</strong>(<code>first_seen</code>):本站第一次看到并写入该视频的时刻。人工加收藏或搜索确认都可能导致入库晚于发布。</li>
|
||||
<li><strong>快照日期</strong>(<code>stat_date</code>):每天采集一次播放与互动,写入 <code>snapshot</code>。缺失日不插值,曲线直接断开。</li>
|
||||
</ul>
|
||||
<p class="section-note">因此,同一个视频的“发布后 7 天数据”和“入库后 7 天数据”可能不是同一段区间;详情页会同时展示发布时间与入库时间。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>指标口径</h2>
|
||||
<ul class="about-list">
|
||||
<li><strong>播放 / 投币 / 收藏 / 评论</strong>:直接取当日快照;弹幕与转发不单列,已并入“互动”。</li>
|
||||
<li><strong>互动</strong>:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。</li>
|
||||
<li><strong>互动率</strong>:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。</li>
|
||||
<li><strong>综合热度</strong>:每项先 <code>log1p</code>,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。</li>
|
||||
<li><strong>内容效率</strong>:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。</li>
|
||||
<li><strong>互动效率</strong>:互动 / 内容小时,互动口径同上。</li>
|
||||
<li><strong>UP 内容效率</strong>:把该 UP 的全部视频合并后计算,并用 Gamma–Poisson 经验贝叶斯向全库均值收缩,避免单条视频或极小样本冲榜。收缩强度 κ ${analytics.shrink_minutes ? `固定为 ${analytics.shrink_minutes} 分钟` : "取全库 UP 总内容分钟的中位数"}。</li>
|
||||
<li><strong>累计触达</strong>:总播放。它是规模指标,不代表吸引力;高产 UP 排在前列是正常结果。</li>
|
||||
<li><strong>分 P</strong>:<code>pages</code> 是分 P 数,<code>duration</code> 是该 bvid 各 P 时长之和。B 站不公开单 P 播放,因此没有单 P 榜;分 P 数只用于详情展示,不参与排序。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>榜单筛选</h2>
|
||||
<ul class="about-list">
|
||||
<li>内容效率与互动效率只统计时长 ≥ <strong>${analytics.min_duration_seconds}</strong> 秒的视频,排除结构不同的短视频与切片。</li>
|
||||
<li>只统计发布满 <strong>${analytics.maturity_days}</strong> 天的视频;reaction 视频通常在两周内接近平台期,过早比较会系统性低估新视频。</li>
|
||||
<li>不引入无界的“年龄惩罚”:超过成熟窗口后不再按天加分或扣分。</li>
|
||||
<li><code>duration</code> 为空或 ≤ 0 的视频(通常只在失效视频上出现)不进入任何时长相关榜,也不做插补。</li>
|
||||
<li>当前配置的最小播放门槛为 <strong>${analytics.min_view_for_efficiency}</strong>。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>综合热度权重</h2>
|
||||
<ul class="about-list about-weights">${weightRows(data.heat_weights)}</ul>
|
||||
<p class="section-note">权重是主观配置,可在配置文件中调整;页面展示的是当前生效值。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>UP 粉丝快照</h2>
|
||||
<ul class="about-list">
|
||||
<li>视频入库时记录一次 UP 当前粉丝数,写入 <code>video.up_follower</code>;同一 UP 的旧视频不会被新快照覆盖。</li>
|
||||
<li><code>up_profile.follower_first</code> 保留首次采集值,<code>follower_latest</code> 随刷新更新。</li>
|
||||
<li>历史视频的粉丝数是回填值,会标记 <code>backfilled</code>;它与真实的“入库时”存在时间差,暂不用于榜单排序。</li>
|
||||
<li>当前覆盖:${followers.videos_covered} / ${followers.videos_total} 条视频(${coverage}%),涉及 ${followers.ups_tracked} 个 UP;缺失 ${followers.videos_missing} 条,可运行 <code>app.py backfill-followers</code> 补齐。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>已知局限</h2>
|
||||
<ul class="about-list">
|
||||
<li>播放是累计值,受发布时间影响;效率榜用成熟窗口过滤,但无法完全消除历史推荐差异。</li>
|
||||
<li>B 站不公开完播率与单 P 播放,内容小时是“供给时长”,不是“实际观看时长”。</li>
|
||||
<li>多 P 可能让同一用户产生多次播放计数,本站不单独校正,属于已知偏差。</li>
|
||||
<li>粉丝数可能包含不活跃账号;粉丝快照暂不参与任何榜单。</li>
|
||||
<li>样本不足(少于 2 天快照、单视频 UP)会显示提示,不伪造 0。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>`;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export async function renderHome(root) {
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>最新入库</h2>
|
||||
<a class="text-link" href="/rank?scope=video&sort=added&within_days=7">查看全部</a>
|
||||
<a class="text-link" href="/videos?sort=added&within_days=7">查看全部</a>
|
||||
</div>
|
||||
${addedNote}
|
||||
<div class="video-list">
|
||||
@@ -102,7 +102,7 @@ export async function renderHome(root) {
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>最近发布</h2>
|
||||
<a class="text-link" href="/rank?scope=video&sort=published&within_days=14">查看全部</a>
|
||||
<a class="text-link" href="/videos?sort=published&within_days=14">查看全部</a>
|
||||
</div>
|
||||
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
|
||||
<div class="video-list">
|
||||
@@ -114,7 +114,7 @@ export async function renderHome(root) {
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>增长动态</h2>
|
||||
<a class="text-link" href="/rank?scope=video&sort=growth&growth_days=7">增长榜</a>
|
||||
<a class="text-link" href="/videos?sort=growth&growth_days=7">增长榜</a>
|
||||
</div>
|
||||
${growth.available && growth.items.length
|
||||
? `<div class="video-list">${growth.items.map((item, index) => videoCard(item, { rank: index + 1 })).join("")}</div>`
|
||||
@@ -124,7 +124,7 @@ export async function renderHome(root) {
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>热度速览</h2>
|
||||
<a class="text-link" href="/rank?scope=video&sort=heat">完整榜单</a>
|
||||
<a class="text-link" href="/videos?sort=heat">完整榜单</a>
|
||||
</div>
|
||||
${heatStrip(heat.items)}
|
||||
</section>
|
||||
|
||||
@@ -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 `
|
||||
<div class="rank-toolbar">
|
||||
<div class="segmented" role="tablist">
|
||||
<button type="button" class="${scope === "video" ? "active" : ""}" data-scope="video">视频</button>
|
||||
<button type="button" class="${scope === "up" ? "active" : ""}" data-scope="up">UP</button>
|
||||
</div>
|
||||
<form class="rank-search" id="rank-filter">
|
||||
<input class="search-input" type="search" name="q" value="${escapeAttr(q || "")}"
|
||||
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
|
||||
@@ -74,75 +81,73 @@ function controls({ scope, sort, within, growthDays, q, folder, minVideos, folde
|
||||
`;
|
||||
}
|
||||
|
||||
function videoPrimary(item, sort) {
|
||||
if (sort === "efficiency") {
|
||||
return { primary: "efficiency", label: "播放/小时", text: formatCount(item.efficiency) };
|
||||
}
|
||||
if (sort === "interaction_efficiency") {
|
||||
return { primary: "interaction_efficiency", label: "互动/小时", text: formatCount(item.interaction_efficiency) };
|
||||
}
|
||||
if (sort === "interaction") {
|
||||
return { primary: "view", label: "互动率", text: formatPercent(item.interaction_rate) };
|
||||
}
|
||||
if (VIDEO_METRIC_PRIMARY.has(sort)) {
|
||||
return { primary: sort, label: "", text: null };
|
||||
}
|
||||
return { primary: "view", label: "播放", text: null };
|
||||
}
|
||||
|
||||
function upPrimary(item, sort) {
|
||||
if (sort === "efficiency") {
|
||||
return { label: "播放/小时", text: formatCount(item.efficiency) };
|
||||
}
|
||||
if (sort === "interaction_efficiency") {
|
||||
return { label: "互动/小时", text: formatCount(item.interaction_efficiency) };
|
||||
}
|
||||
if (sort === "duration") {
|
||||
return { label: "内容小时", text: item.content_hours === null ? "—" : `${formatNumber(item.content_hours)} 小时` };
|
||||
}
|
||||
if (sort === "views") return { label: "总播放", text: formatCount(item.total_views) };
|
||||
if (sort === "count") return { label: "视频数", text: formatNumber(item.video_count) };
|
||||
if (sort === "avg_views") return { label: "平均播放", text: formatCount(item.avg_views) };
|
||||
if (sort === "avg_interaction") return { label: "平均互动", text: formatPercent(item.avg_interaction) };
|
||||
if (sort === "interaction") return { label: "总互动", text: formatCount(item.total_interactions) };
|
||||
if (sort === "latest") return { label: "最近发布", text: formatDate(item.latest_pubdate) };
|
||||
return { label: "播放/小时", text: formatCount(item.efficiency) };
|
||||
}
|
||||
|
||||
function listHtml(data, scope, sort, startRank) {
|
||||
if (!data.items.length) {
|
||||
return emptyState(
|
||||
data.available === false ? "增长榜暂不可用" : "没有匹配的视频",
|
||||
data.available === false
|
||||
? (data.reason || "需要至少两天的快照。")
|
||||
: "换个筛选条件或清空搜索关键词试试。",
|
||||
data.available === false ? "当前榜单暂不可用" : (scope === "up" ? "没有匹配的 UP" : "没有匹配的视频"),
|
||||
data.reason || "换个筛选条件或清空搜索关键词试试。",
|
||||
);
|
||||
}
|
||||
if (scope === "up") {
|
||||
return `<div class="rank-list">${data.items.map((item, index) => upCard(item, { rank: startRank + index })).join("")}</div>`;
|
||||
return `<div class="rank-list">${data.items.map((item, index) => {
|
||||
const primary = upPrimary(item, sort);
|
||||
return upCard(item, {
|
||||
rank: startRank + index, primaryLabel: primary.label, primaryText: primary.text,
|
||||
});
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
const primary = METRIC_PRIMARY.has(sort) ? sort : "view";
|
||||
return `<div class="rank-list">${data.items.map((item, index) =>
|
||||
videoCard(item, { rank: startRank + index, primary })).join("")}</div>`;
|
||||
return `<div class="rank-list">${data.items.map((item, index) => {
|
||||
const primary = videoPrimary(item, sort);
|
||||
return videoCard(item, {
|
||||
rank: startRank + index, primary: primary.primary,
|
||||
primaryLabel: primary.label, primaryText: primary.text,
|
||||
});
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
export async function renderRankings(root, { query }) {
|
||||
const scope = query.get("scope") === "up" ? "up" : "video";
|
||||
const defaultSort = scope === "up" ? "avg_views" : "heat";
|
||||
const sort = query.get("sort") || defaultSort;
|
||||
const within = query.get("within_days") || "all";
|
||||
const growthDays = query.get("growth_days") || "7";
|
||||
const q = query.get("q") || "";
|
||||
const folder = query.get("folder") || "";
|
||||
const minVideos = query.get("min_videos") || "1";
|
||||
const page = Math.max(1, Number(query.get("page") || 1));
|
||||
function durationNote(data) {
|
||||
const filter = data.duration_filter;
|
||||
if (!filter) return "";
|
||||
return `<div class="section-note">内容效率与互动效率仅统计时长 ≥ ${filter.min_duration_seconds} 秒、
|
||||
发布满 ${filter.maturity_days} 天的视频;时长缺失的视频不参与排序。</div>`;
|
||||
}
|
||||
|
||||
const overview = await getOverview();
|
||||
const folders = overview.folders || [];
|
||||
|
||||
const params = {
|
||||
sort,
|
||||
page,
|
||||
page_size: 20,
|
||||
within_days: within === "all" ? null : within,
|
||||
growth_days: growthDays,
|
||||
q,
|
||||
};
|
||||
let data;
|
||||
if (scope === "up") {
|
||||
data = await api("/api/rankings", {
|
||||
...params, scope: "up", min_videos: minVideos,
|
||||
});
|
||||
} else {
|
||||
data = await api("/api/videos", { ...params, folder: folder || null });
|
||||
}
|
||||
|
||||
const startRank = (data.page - 1) * data.page_size + 1;
|
||||
|
||||
root.innerHTML = `<section class="page rankings-page">
|
||||
<header class="page-head">
|
||||
<h1>榜单 <span class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</span></h1>
|
||||
</header>
|
||||
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
|
||||
${data.available === false ? `<div class="notice-row warning">
|
||||
<strong>增长数据尚不可用</strong><span>${escapeHtml(data.reason || "")}</span>
|
||||
</div>` : ""}
|
||||
${listHtml(data, scope, sort, startRank)}
|
||||
${pager(data.page, data.pages)}
|
||||
</section>`;
|
||||
|
||||
root.querySelectorAll("[data-scope]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({
|
||||
scope: button.dataset.scope,
|
||||
sort: button.dataset.scope === "up" ? "avg_views" : "heat",
|
||||
page: null,
|
||||
});
|
||||
}));
|
||||
function bindControls(root, scope) {
|
||||
root.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ sort: button.dataset.sort, page: null });
|
||||
}));
|
||||
@@ -169,3 +174,60 @@ export async function renderRankings(root, { query }) {
|
||||
}));
|
||||
centerActiveChips(root);
|
||||
}
|
||||
|
||||
async function renderScope(root, { query }, scope) {
|
||||
const defaultSort = "efficiency";
|
||||
const sort = query.get("sort") || defaultSort;
|
||||
const within = query.get("within_days") || "all";
|
||||
const growthDays = query.get("growth_days") || "7";
|
||||
const q = query.get("q") || "";
|
||||
const folder = query.get("folder") || "";
|
||||
const minVideos = query.get("min_videos") || "1";
|
||||
const page = Math.max(1, Number(query.get("page") || 1));
|
||||
|
||||
const overview = await getOverview();
|
||||
const folders = overview.folders || [];
|
||||
|
||||
const params = {
|
||||
sort,
|
||||
page,
|
||||
page_size: 20,
|
||||
within_days: within === "all" ? null : within,
|
||||
growth_days: growthDays,
|
||||
q,
|
||||
};
|
||||
let data;
|
||||
if (scope === "up") {
|
||||
data = await api("/api/ups", { ...params, min_videos: minVideos });
|
||||
} else {
|
||||
data = await api("/api/videos", { ...params, folder: folder || null });
|
||||
}
|
||||
|
||||
const startRank = (data.page - 1) * data.page_size + 1;
|
||||
const title = scope === "up" ? "UP主" : "视频";
|
||||
const unit = scope === "up" ? "个 UP" : "条视频";
|
||||
|
||||
root.innerHTML = `<section class="page rankings-page">
|
||||
<header class="page-head">
|
||||
<h1>${title} <span class="page-sub">共 ${formatCount(data.total)} ${unit}</span></h1>
|
||||
</header>
|
||||
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
|
||||
${durationNote(data)}
|
||||
${data.available === false ? `<div class="notice-row warning">
|
||||
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
|
||||
<span>${escapeHtml(data.reason || "")}</span>
|
||||
</div>` : ""}
|
||||
${listHtml(data, scope, sort, startRank)}
|
||||
${pager(data.page, data.pages)}
|
||||
</section>`;
|
||||
|
||||
bindControls(root, scope);
|
||||
}
|
||||
|
||||
export function renderVideos(root, context) {
|
||||
return renderScope(root, context, "video");
|
||||
}
|
||||
|
||||
export function renderUps(root, context) {
|
||||
return renderScope(root, context, "up");
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import { replaceQuery } from "../router.js";
|
||||
|
||||
const SORTS = [
|
||||
["view", "播放"], ["latest", "最新发布"], ["interaction", "互动率"],
|
||||
["view", "播放"], ["efficiency", "内容效率"], ["latest", "最新发布"],
|
||||
["interaction", "互动率"],
|
||||
];
|
||||
|
||||
function sortedVideos(videos, sort) {
|
||||
@@ -18,6 +19,8 @@ function sortedVideos(videos, sort) {
|
||||
items.sort((a, b) => b.pubdate - a.pubdate);
|
||||
} else if (sort === "interaction") {
|
||||
items.sort((a, b) => (b.interaction_rate || 0) - (a.interaction_rate || 0));
|
||||
} else if (sort === "efficiency") {
|
||||
items.sort((a, b) => (b.efficiency || 0) - (a.efficiency || 0));
|
||||
} else {
|
||||
items.sort((a, b) => (b.metrics?.view || 0) - (a.metrics?.view || 0));
|
||||
}
|
||||
@@ -35,7 +38,7 @@ export async function renderUp(root, { params, query }) {
|
||||
|
||||
root.innerHTML = `<section class="page up-page">
|
||||
<header class="page-head">
|
||||
<a class="back-link" href="/rank?scope=up&sort=avg_views">← 返回 UP 榜</a>
|
||||
<a class="back-link" href="/ups">← 返回 UP 榜</a>
|
||||
<a class="button ghost" href="${escapeAttr(up.space_url)}" target="_blank" rel="noreferrer">B 站空间</a>
|
||||
</header>
|
||||
|
||||
@@ -53,10 +56,14 @@ export async function renderUp(root, { params, query }) {
|
||||
|
||||
<div class="mini-stats up-stats">
|
||||
<div><span>视频数</span><strong>${formatCount(up.video_count)}</strong></div>
|
||||
<div><span>总时长</span><strong>${up.content_hours == null ? "—" : `${formatCount(up.content_hours)} 小时`}</strong></div>
|
||||
<div><span>播放/小时</span><strong>${formatCount(up.views_per_hour)}</strong></div>
|
||||
<div><span>总播放</span><strong>${formatCount(up.total_views)}</strong></div>
|
||||
<div><span>平均播放</span><strong>${formatCount(up.avg_views)}</strong></div>
|
||||
<div><span>总互动</span><strong>${formatCount(up.total_interactions)}</strong></div>
|
||||
<div><span>平均互动率</span><strong>${formatPercent(up.avg_interaction)}</strong></div>
|
||||
<div><span>粉丝(入库时)</span><strong>${formatCount(up.up_follower)}</strong></div>
|
||||
<div><span>当前粉丝</span><strong>${formatCount(up.follower_latest)}</strong></div>
|
||||
</div>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function renderVideo(root, { params, query }) {
|
||||
|
||||
root.innerHTML = `<section class="page video-page">
|
||||
<header class="page-head">
|
||||
<a class="back-link" href="/rank?scope=video&sort=heat">← 返回榜单</a>
|
||||
<a class="back-link" href="/videos">← 返回视频榜</a>
|
||||
<div class="head-actions">
|
||||
${detail.url ? `<a class="button ghost" href="${escapeAttr(detail.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""}
|
||||
</div>
|
||||
@@ -60,11 +60,13 @@ export async function renderVideo(root, { params, query }) {
|
||||
${detail.up_mid ? `<a class="inline-link" href="/up/${detail.up_mid}">${escapeHtml(detail.up_name || "未知 UP")}</a>` : escapeHtml(detail.up_name || "未知 UP")}
|
||||
<span>${escapeHtml(formatDate(detail.pubdate))}</span>
|
||||
<span>${escapeHtml(formatDuration(detail.duration))}</span>
|
||||
${detail.pages ? `<span>${formatNumber(detail.pages)} P</span>` : ""}
|
||||
${detail.folder_title ? badge(detail.folder_title) : ""}
|
||||
${badge(statusLabel(detail.status), `status-${detail.status}`)}
|
||||
${samples < 2 ? badge("样本不足", "warning") : ""}
|
||||
</div>
|
||||
${detail.blacklisted ? `<p class="section-note warning-text">该 UP 在黑名单中;黑名单只影响未来发现,不影响当前视频追踪。</p>` : ""}
|
||||
${detail.up_follower_backfilled ? `<p class="section-note">UP 粉丝数为回填值,不是真实的入库时点。</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +77,9 @@ export async function renderVideo(root, { params, query }) {
|
||||
<div><span>综合热度</span><strong>${formatScore(detail.heat)}</strong></div>
|
||||
<div><span>全库百分位</span><strong>${detail.heat_percentile === null || detail.heat_percentile === undefined ? "—" : `前 ${Math.max(0, 100 - detail.heat_percentile).toFixed(0)}%`}</strong></div>
|
||||
<div><span>播放日增</span><strong>${detail.growth?.available ? formatDelta(detail.growth.delta) : "—"}</strong></div>
|
||||
<div><span>内容效率</span><strong>${detail.efficiency == null ? "—" : `${formatCount(detail.efficiency)}/小时`}</strong></div>
|
||||
<div><span>互动效率</span><strong>${detail.interaction_efficiency == null ? "—" : `${formatCount(detail.interaction_efficiency)}/小时`}</strong></div>
|
||||
<div><span>UP 粉丝(入库时)</span><strong>${formatCount(detail.up_follower)}</strong></div>
|
||||
<div><span>快照数</span><strong>${samples}</strong></div>
|
||||
<div><span>最近快照</span><strong>${escapeHtml(detail.snapshot_date || "—")}</strong></div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user