diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e2d4d35..3402ee4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -756,7 +756,7 @@ uv run python tests/test_moves.py - 粉丝采集对每条 `relation/stat` 加 45s 硬超时看门狗(防 DNS/传输层挂起),每 25 条输出 `up_profile.progress` 进度;单条失败不阻塞整批; - `app.py backfill-followers`:一次性回填历史视频粉丝数,支持 `--limit` / `--dry-run`; - 新增内容效率/互动效率榜与 UP 内容效率(Gamma–Poisson EB 收缩),过滤 < 600 秒与未满 2 天的视频; -- `analytics` 配置段:`min_duration_seconds`、`maturity_days`、`min_view_for_ranking`、`video_shrink_minutes`、`shrink_minutes`; +- `analytics` 配置段:`min_duration_seconds`、`maturity_days`、`min_view_for_ranking`、`video_shrink_minutes`、`shrink_minutes`、`growth_rate_prior`; - 主导航拆分:`/videos`(视频)与 `/ups`(UP主)独立;旧 `/rank` 保留兼容; - 新增 `/about` 数据说明页与 `/api/about`; - 卡片精简:列表只保留主指标与互动率,明细移到详情页; diff --git a/config.docker.example.jsonc b/config.docker.example.jsonc index 0af70f4..7465fc2 100644 --- a/config.docker.example.jsonc +++ b/config.docker.example.jsonc @@ -108,5 +108,6 @@ "video_shrink_minutes": 60, "shrink_minutes": null, "home_growth_days": "auto", + "growth_rate_prior": 500, }, } diff --git a/config.example.jsonc b/config.example.jsonc index f7b06d7..a81b24c 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -123,5 +123,7 @@ "shrink_minutes": null, // "auto" = 7 日有数据用 7,否则 1 "home_growth_days": "auto", + // 增速榜排序:Δ / (基线 + prior)。卡片上的百分比仍是 Δ / 基线。 + "growth_rate_prior": 500, }, } diff --git a/project.md b/project.md index b4935ce..90bdd13 100644 --- a/project.md +++ b/project.md @@ -679,8 +679,9 @@ w = {view:0.30, like:0.20, coin:0.20, favorite:0.15, ``` d7 = (view_today - view_{today-7}) / 实际间隔天数 r7 = (view_today - view_{today-7}) / max(view_{today-7}, 1) -d1_published_today = view_today - 0 +d1_published_today = view_today - 0 # 只进增量榜,不计算增速 d1_imported_today = view_today - view_open # 需同日第二次采样 +sort_rate = Δ / (基线 + analytics.growth_rate_prior) # 默认 prior=500,只用于增速榜排序 ``` | 榜 | 口径 | 基数门槛 | @@ -692,7 +693,7 @@ d1_imported_today = view_today - view_open # 需同日第二次采样 | 单项榜 | 7 项直接排序 | — | | UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — | -**冷启动**:近 1 日不要求跨日快照。当日发布相对 0;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。 +**冷启动**:近 1 日不要求跨日快照。当日发布相对 0,只进播放增量、不进播放增速;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。 ### 8.3 趋势 diff --git a/reaction_board/analytics.py b/reaction_board/analytics.py index 70043ab..9f91a3e 100644 --- a/reaction_board/analytics.py +++ b/reaction_board/analytics.py @@ -340,6 +340,29 @@ def compute_growth( ) +def smoothed_growth_rate( + delta: int | float | None, + previous: int | float | None, + prior: int | float, +) -> float | None: + """增速榜排序分:``Δ / (基线 + prior)``。 + + ``rate`` 仍是真实相对增速。``prior`` 只削弱小基数在排序里的优势。 + """ + if delta is None or previous is None or isinstance(delta, bool) or isinstance(previous, bool): + return None + if isinstance(prior, bool): + return None + try: + strength = float(prior) + base = max(0, int(previous)) + strength + except (TypeError, ValueError): + return None + if strength < 0 or base <= 0: + return None + return int(delta) / base + + def normalize_cover(url: object) -> str | None: """把 B 站封面统一为 https;空值返回 None。""" if not isinstance(url, str): diff --git a/reaction_board/config.py b/reaction_board/config.py index d241f15..61128af 100644 --- a/reaction_board/config.py +++ b/reaction_board/config.py @@ -52,6 +52,7 @@ DEFAULT_ANALYTICS: dict[str, Any] = { "video_shrink_minutes": 60, "shrink_minutes": None, "home_growth_days": "auto", + "growth_rate_prior": 500, } DEFAULT_CONFIG: dict[str, Any] = { @@ -210,6 +211,7 @@ class AnalyticsConfig: video_shrink_minutes: int shrink_minutes: int | None home_growth_days: str | int + growth_rate_prior: int @dataclass(frozen=True) @@ -592,6 +594,11 @@ def _build_config(data: Mapping[str, Any], path: Path, *, secrets_path: Path) -> ) ), home_growth_days=home_growth_days, + growth_rate_prior=_as_int( + analytics_raw.get("growth_rate_prior", 500), + "analytics.growth_rate_prior", + minimum=0, + ), ) return AppConfig( diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index e690efe..c38fbc2 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -20,6 +20,7 @@ from ..analytics import ( METRICS, MIN_VIEW_FOR_GROWTH_RATE, compute_growth, + smoothed_growth_rate, content_hours, eb_rate, effective_shrink_minutes, @@ -437,6 +438,8 @@ class DashboardQueries: growth = compute_growth(current, 0, 0, allow_zero_gap=True) payload = growth.as_dict() payload["baseline"] = "zero" + # previous=0 时 rate = view/1,会按原始播放碾压真日环比。 + payload["rate"] = None return payload try: open_at = snapshot.get("open_captured_at") @@ -1031,9 +1034,13 @@ class DashboardQueries: if sort == "growth_rate": items = [ item for item in items - if (item["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE + if item.get("growth", {}).get("baseline") != "zero" + and item.get("growth", {}).get("rate") is not None + and (item["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE ] + rate_prior = max(0, int(analytics.growth_rate_prior)) + def sort_key(item: Mapping[str, Any]): if sort == "added": return (int(item["first_seen"]), int(item["pubdate"])) @@ -1050,9 +1057,18 @@ class DashboardQueries: ) value = item.get(field) return value if value is not None else float("-inf") - if sort in {"growth", "growth_rate"}: - field = "delta" if sort == "growth" else "rate" - value = item.get("growth", {}).get(field) + if sort == "growth": + value = item.get("growth", {}).get("delta") + return value if value is not None else float("-inf") + if sort == "growth_rate": + growth = item.get("growth") or {} + delta = growth.get("delta") + current = (item.get("metrics") or {}).get("view") + if delta is None or current is None: + return float("-inf") + value = smoothed_growth_rate( + delta, int(current) - int(delta), rate_prior, + ) return value if value is not None else float("-inf") value = (item["metrics"] or {}).get(sort) return value if value is not None else -1 @@ -1069,7 +1085,12 @@ class DashboardQueries: reason = None if sort in {"growth", "growth_rate"} and not growth_available: available = False - if growth_days == 1: + if growth_days == 1 and sort == "growth_rate": + reason = ( + "近 1 日增速需要前一日快照,或更早发布的新入库当天至少两次成功采样;" + "当日发布不计入增速" + ) + elif growth_days == 1: reason = ( "近 1 日增长需要前一日快照;当日发布有一次成功采样即可;" "更早发布的新入库需要当天至少两次成功采样" @@ -1108,6 +1129,7 @@ class DashboardQueries: "available": available, "reason": reason, "duration_filter": duration_filter, + "growth_rate_prior": rate_prior if sort == "growth_rate" else None, } return self.db.read(run) @@ -1754,6 +1776,7 @@ class DashboardQueries: "video_shrink_minutes": cfg.analytics.video_shrink_minutes, "shrink_minutes": cfg.analytics.shrink_minutes, "home_growth_days": cfg.analytics.home_growth_days, + "growth_rate_prior": cfg.analytics.growth_rate_prior, }, "heat_weights": { metric: float(value) diff --git a/reaction_board/web/static/components.js b/reaction_board/web/static/components.js index 3f25c1b..327221c 100644 --- a/reaction_board/web/static/components.js +++ b/reaction_board/web/static/components.js @@ -1,6 +1,6 @@ import { coverUrl, escapeAttr, escapeHtml, formatCount, formatDate, - formatDelta, formatDuration, formatPercent, metricLabel, statusLabel, + formatDelta, formatDuration, formatPercent, formatSignedPercent, metricLabel, statusLabel, } from "./format.js"; export function cover(item, className = "thumb") { @@ -38,7 +38,7 @@ function growthBaselineTag(growth) { export function videoCard(item, { rank = null, primary = "view", primaryLabel = "", primaryText = null, - showStatus = false, + showStatus = false, growthField = "delta", } = {}) { const metrics = item.metrics || {}; const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary); @@ -49,7 +49,13 @@ export function videoCard(item, { const primaryValue = typeof raw === "number" ? formatCount(raw) : raw; const label = primaryLabel || metricLabel(primary); const growth = item.growth; - const delta = growth?.available ? growth.delta : null; + const showRate = growthField === "rate"; + const rate = showRate && growth?.available ? growth.rate : null; + const delta = !showRate && growth?.available ? growth.delta : null; + const change = showRate + ? (rate === null || rate === undefined ? null : formatSignedPercent(rate)) + : (delta === null || delta === undefined ? null : formatDelta(delta)); + const changeUp = showRate ? Number(rate) >= 0 : Number(delta) >= 0; const title = escapeHtml(item.title || "(无标题)"); const up = escapeHtml(item.up_name || "未知 UP"); const upMid = item.up_mid; @@ -79,7 +85,7 @@ export function videoCard(item, { ${escapeHtml(primaryValue ?? "—")}${escapeHtml(label)} - ${delta !== null ? `${formatDelta(delta)}${growthBaselineTag(growth)}` : ""} + ${change !== null ? `${change}${growthBaselineTag(growth)}` : ""} 互动 ${formatPercent(item.interaction_rate)} diff --git a/reaction_board/web/static/format.js b/reaction_board/web/static/format.js index 27c3603..618b407 100644 --- a/reaction_board/web/static/format.js +++ b/reaction_board/web/static/format.js @@ -46,6 +46,12 @@ export function formatPercent(value, digits = 2) { return `${(Number(value) * 100).toFixed(digits)}%`; } +export function formatSignedPercent(value, digits = 2) { + const text = formatPercent(value, digits); + if (text === "—" || Number(value) <= 0) return text; + return `+${text}`; +} + export function formatDate(timestamp) { if (!timestamp) return "—"; const date = new Date(Number(timestamp) * 1000); diff --git a/reaction_board/web/static/views/about.js b/reaction_board/web/static/views/about.js index 3416ade..2492af8 100644 --- a/reaction_board/web/static/views/about.js +++ b/reaction_board/web/static/views/about.js @@ -50,6 +50,7 @@ export async function renderAbout(root) {
log1p,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。(播放 + κ·μ₀) / (小时 + κ),κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。