feat(growth): same-day 1-day baselines for new videos #6
No files matched your search
+1
-1
@@ -756,7 +756,7 @@ uv run python tests/test_moves.py
|
|||||||
- 粉丝采集对每条 `relation/stat` 加 45s 硬超时看门狗(防 DNS/传输层挂起),每 25 条输出 `up_profile.progress` 进度;单条失败不阻塞整批;
|
- 粉丝采集对每条 `relation/stat` 加 45s 硬超时看门狗(防 DNS/传输层挂起),每 25 条输出 `up_profile.progress` 进度;单条失败不阻塞整批;
|
||||||
- `app.py backfill-followers`:一次性回填历史视频粉丝数,支持 `--limit` / `--dry-run`;
|
- `app.py backfill-followers`:一次性回填历史视频粉丝数,支持 `--limit` / `--dry-run`;
|
||||||
- 新增内容效率/互动效率榜与 UP 内容效率(Gamma–Poisson EB 收缩),过滤 < 600 秒与未满 2 天的视频;
|
- 新增内容效率/互动效率榜与 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` 保留兼容;
|
- 主导航拆分:`/videos`(视频)与 `/ups`(UP主)独立;旧 `/rank` 保留兼容;
|
||||||
- 新增 `/about` 数据说明页与 `/api/about`;
|
- 新增 `/about` 数据说明页与 `/api/about`;
|
||||||
- 卡片精简:列表只保留主指标与互动率,明细移到详情页;
|
- 卡片精简:列表只保留主指标与互动率,明细移到详情页;
|
||||||
|
|||||||
@@ -108,5 +108,6 @@
|
|||||||
"video_shrink_minutes": 60,
|
"video_shrink_minutes": 60,
|
||||||
"shrink_minutes": null,
|
"shrink_minutes": null,
|
||||||
"home_growth_days": "auto",
|
"home_growth_days": "auto",
|
||||||
|
"growth_rate_prior": 500,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -123,5 +123,7 @@
|
|||||||
"shrink_minutes": null,
|
"shrink_minutes": null,
|
||||||
// "auto" = 7 日有数据用 7,否则 1
|
// "auto" = 7 日有数据用 7,否则 1
|
||||||
"home_growth_days": "auto",
|
"home_growth_days": "auto",
|
||||||
|
// 增速榜排序:Δ / (基线 + prior)。卡片上的百分比仍是 Δ / 基线。
|
||||||
|
"growth_rate_prior": 500,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
+3
-2
@@ -679,8 +679,9 @@ w = {view:0.30, like:0.20, coin:0.20, favorite:0.15,
|
|||||||
```
|
```
|
||||||
d7 = (view_today - view_{today-7}) / 实际间隔天数
|
d7 = (view_today - view_{today-7}) / 实际间隔天数
|
||||||
r7 = (view_today - view_{today-7}) / max(view_{today-7}, 1)
|
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 # 需同日第二次采样
|
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 项直接排序 | — |
|
| 单项榜 | 7 项直接排序 | — |
|
||||||
| UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — |
|
| UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — |
|
||||||
|
|
||||||
**冷启动**:近 1 日不要求跨日快照。当日发布相对 0;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。
|
**冷启动**:近 1 日不要求跨日快照。当日发布相对 0,只进播放增量、不进播放增速;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。
|
||||||
|
|
||||||
### 8.3 趋势
|
### 8.3 趋势
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
def normalize_cover(url: object) -> str | None:
|
||||||
"""把 B 站封面统一为 https;空值返回 None。"""
|
"""把 B 站封面统一为 https;空值返回 None。"""
|
||||||
if not isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ DEFAULT_ANALYTICS: dict[str, Any] = {
|
|||||||
"video_shrink_minutes": 60,
|
"video_shrink_minutes": 60,
|
||||||
"shrink_minutes": None,
|
"shrink_minutes": None,
|
||||||
"home_growth_days": "auto",
|
"home_growth_days": "auto",
|
||||||
|
"growth_rate_prior": 500,
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
@@ -210,6 +211,7 @@ class AnalyticsConfig:
|
|||||||
video_shrink_minutes: int
|
video_shrink_minutes: int
|
||||||
shrink_minutes: int | None
|
shrink_minutes: int | None
|
||||||
home_growth_days: str | int
|
home_growth_days: str | int
|
||||||
|
growth_rate_prior: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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,
|
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(
|
return AppConfig(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from ..analytics import (
|
|||||||
METRICS,
|
METRICS,
|
||||||
MIN_VIEW_FOR_GROWTH_RATE,
|
MIN_VIEW_FOR_GROWTH_RATE,
|
||||||
compute_growth,
|
compute_growth,
|
||||||
|
smoothed_growth_rate,
|
||||||
content_hours,
|
content_hours,
|
||||||
eb_rate,
|
eb_rate,
|
||||||
effective_shrink_minutes,
|
effective_shrink_minutes,
|
||||||
@@ -437,6 +438,8 @@ class DashboardQueries:
|
|||||||
growth = compute_growth(current, 0, 0, allow_zero_gap=True)
|
growth = compute_growth(current, 0, 0, allow_zero_gap=True)
|
||||||
payload = growth.as_dict()
|
payload = growth.as_dict()
|
||||||
payload["baseline"] = "zero"
|
payload["baseline"] = "zero"
|
||||||
|
# previous=0 时 rate = view/1,会按原始播放碾压真日环比。
|
||||||
|
payload["rate"] = None
|
||||||
return payload
|
return payload
|
||||||
try:
|
try:
|
||||||
open_at = snapshot.get("open_captured_at")
|
open_at = snapshot.get("open_captured_at")
|
||||||
@@ -1031,9 +1034,13 @@ class DashboardQueries:
|
|||||||
if sort == "growth_rate":
|
if sort == "growth_rate":
|
||||||
items = [
|
items = [
|
||||||
item for item in 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]):
|
def sort_key(item: Mapping[str, Any]):
|
||||||
if sort == "added":
|
if sort == "added":
|
||||||
return (int(item["first_seen"]), int(item["pubdate"]))
|
return (int(item["first_seen"]), int(item["pubdate"]))
|
||||||
@@ -1050,9 +1057,18 @@ class DashboardQueries:
|
|||||||
)
|
)
|
||||||
value = item.get(field)
|
value = item.get(field)
|
||||||
return value if value is not None else float("-inf")
|
return value if value is not None else float("-inf")
|
||||||
if sort in {"growth", "growth_rate"}:
|
if sort == "growth":
|
||||||
field = "delta" if sort == "growth" else "rate"
|
value = item.get("growth", {}).get("delta")
|
||||||
value = item.get("growth", {}).get(field)
|
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")
|
return value if value is not None else float("-inf")
|
||||||
value = (item["metrics"] or {}).get(sort)
|
value = (item["metrics"] or {}).get(sort)
|
||||||
return value if value is not None else -1
|
return value if value is not None else -1
|
||||||
@@ -1069,7 +1085,12 @@ class DashboardQueries:
|
|||||||
reason = None
|
reason = None
|
||||||
if sort in {"growth", "growth_rate"} and not growth_available:
|
if sort in {"growth", "growth_rate"} and not growth_available:
|
||||||
available = False
|
available = False
|
||||||
if growth_days == 1:
|
if growth_days == 1 and sort == "growth_rate":
|
||||||
|
reason = (
|
||||||
|
"近 1 日增速需要前一日快照,或更早发布的新入库当天至少两次成功采样;"
|
||||||
|
"当日发布不计入增速"
|
||||||
|
)
|
||||||
|
elif growth_days == 1:
|
||||||
reason = (
|
reason = (
|
||||||
"近 1 日增长需要前一日快照;当日发布有一次成功采样即可;"
|
"近 1 日增长需要前一日快照;当日发布有一次成功采样即可;"
|
||||||
"更早发布的新入库需要当天至少两次成功采样"
|
"更早发布的新入库需要当天至少两次成功采样"
|
||||||
@@ -1108,6 +1129,7 @@ class DashboardQueries:
|
|||||||
"available": available,
|
"available": available,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"duration_filter": duration_filter,
|
"duration_filter": duration_filter,
|
||||||
|
"growth_rate_prior": rate_prior if sort == "growth_rate" else None,
|
||||||
}
|
}
|
||||||
return self.db.read(run)
|
return self.db.read(run)
|
||||||
|
|
||||||
@@ -1754,6 +1776,7 @@ class DashboardQueries:
|
|||||||
"video_shrink_minutes": cfg.analytics.video_shrink_minutes,
|
"video_shrink_minutes": cfg.analytics.video_shrink_minutes,
|
||||||
"shrink_minutes": cfg.analytics.shrink_minutes,
|
"shrink_minutes": cfg.analytics.shrink_minutes,
|
||||||
"home_growth_days": cfg.analytics.home_growth_days,
|
"home_growth_days": cfg.analytics.home_growth_days,
|
||||||
|
"growth_rate_prior": cfg.analytics.growth_rate_prior,
|
||||||
},
|
},
|
||||||
"heat_weights": {
|
"heat_weights": {
|
||||||
metric: float(value)
|
metric: float(value)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
coverUrl, escapeAttr, escapeHtml, formatCount, formatDate,
|
coverUrl, escapeAttr, escapeHtml, formatCount, formatDate,
|
||||||
formatDelta, formatDuration, formatPercent, metricLabel, statusLabel,
|
formatDelta, formatDuration, formatPercent, formatSignedPercent, metricLabel, statusLabel,
|
||||||
} from "./format.js";
|
} from "./format.js";
|
||||||
|
|
||||||
export function cover(item, className = "thumb") {
|
export function cover(item, className = "thumb") {
|
||||||
@@ -38,7 +38,7 @@ function growthBaselineTag(growth) {
|
|||||||
|
|
||||||
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, growthField = "delta",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const metrics = item.metrics || {};
|
const metrics = item.metrics || {};
|
||||||
const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary);
|
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 primaryValue = typeof raw === "number" ? formatCount(raw) : raw;
|
||||||
const label = primaryLabel || metricLabel(primary);
|
const label = primaryLabel || metricLabel(primary);
|
||||||
const growth = item.growth;
|
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 title = escapeHtml(item.title || "(无标题)");
|
||||||
const up = escapeHtml(item.up_name || "未知 UP");
|
const up = escapeHtml(item.up_name || "未知 UP");
|
||||||
const upMid = item.up_mid;
|
const upMid = item.up_mid;
|
||||||
@@ -79,7 +85,7 @@ export function videoCard(item, {
|
|||||||
<span class="primary-stat">
|
<span class="primary-stat">
|
||||||
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
|
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
|
||||||
</span>
|
</span>
|
||||||
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}${growthBaselineTag(growth)}</span>` : ""}
|
${change !== null ? `<span class="delta ${changeUp ? "up" : "down"}">${change}${growthBaselineTag(growth)}</span>` : ""}
|
||||||
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
|
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export function formatPercent(value, digits = 2) {
|
|||||||
return `${(Number(value) * 100).toFixed(digits)}%`;
|
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) {
|
export function formatDate(timestamp) {
|
||||||
if (!timestamp) return "—";
|
if (!timestamp) return "—";
|
||||||
const date = new Date(Number(timestamp) * 1000);
|
const date = new Date(Number(timestamp) * 1000);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export async function renderAbout(root) {
|
|||||||
<li><strong>播放 / 投币 / 收藏 / 评论</strong>:直接取当日快照;弹幕与转发不单列,已并入“互动”。</li>
|
<li><strong>播放 / 投币 / 收藏 / 评论</strong>:直接取当日快照;弹幕与转发不单列,已并入“互动”。</li>
|
||||||
<li><strong>互动</strong>:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。</li>
|
<li><strong>互动</strong>:点赞 + 投币 + 收藏。综合热度也使用全部七项指标,按配置权重加权。</li>
|
||||||
<li><strong>互动率</strong>:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。</li>
|
<li><strong>互动率</strong>:(点赞 + 投币 + 收藏) / 播放;播放不足 100 不计算。</li>
|
||||||
|
<li><strong>播放增速</strong>:卡片上的百分比是增量 / 基线播放。排序用增量 / (基线 + ${analytics.growth_rate_prior}),避免小基数霸榜。当日发布没有基线,不进增速榜。</li>
|
||||||
<li><strong>综合热度</strong>:每项先 <code>log1p</code>,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。</li>
|
<li><strong>综合热度</strong>:每项先 <code>log1p</code>,再按中位数与 IQR 做稳健标准化,最后加权求和。分数只用于同一批数据内排序,不能跨日期比较。</li>
|
||||||
<li><strong>内容效率</strong>:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。榜单里的单条视频用收缩后的估计:<code>(播放 + κ·μ₀) / (小时 + κ)</code>,κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。</li>
|
<li><strong>内容效率</strong>:播放 / 内容小时。内容小时 = 视频总时长(多 P 为各 P 之和)÷ 3600。榜单里的单条视频用收缩后的估计:<code>(播放 + κ·μ₀) / (小时 + κ)</code>,κ 默认 ${analytics.video_shrink_minutes} 分钟、μ₀ 为全库播放/小时;详情页保留原始播放/小时。</li>
|
||||||
<li><strong>互动效率</strong>:互动 / 内容小时,互动口径与收缩方式同上。</li>
|
<li><strong>互动效率</strong>:互动 / 内容小时,互动口径与收缩方式同上。</li>
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ function listHtml(data, scope, sort, startRank) {
|
|||||||
return videoCard(item, {
|
return videoCard(item, {
|
||||||
rank: startRank + index, primary: primary.primary,
|
rank: startRank + index, primary: primary.primary,
|
||||||
primaryLabel: primary.label, primaryText: primary.text,
|
primaryLabel: primary.label, primaryText: primary.text,
|
||||||
|
growthField: sort === "growth_rate" ? "rate" : "delta",
|
||||||
});
|
});
|
||||||
}).join("")}</div>`;
|
}).join("")}</div>`;
|
||||||
}
|
}
|
||||||
@@ -179,9 +180,18 @@ function durationNote(data) {
|
|||||||
return `<div class="section-note">内容效率与互动效率:时长 ≥ ${filter.min_duration_seconds} 秒、发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view}${shrink};时长缺失不参与。</div>`;
|
return `<div class="section-note">内容效率与互动效率:时长 ≥ ${filter.min_duration_seconds} 秒、发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view}${shrink};时长缺失不参与。</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function growthNote(sort, growthDays) {
|
function growthNote(sort, growthDays, prior) {
|
||||||
if (sort !== "growth" && sort !== "growth_rate") return "";
|
if (sort === "growth_rate") {
|
||||||
if (Number(growthDays) !== 1) return "";
|
const strength = Number(prior);
|
||||||
|
const smooth = Number.isFinite(strength)
|
||||||
|
? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。`
|
||||||
|
: "百分比是真实相对增量;排序按平滑增速。";
|
||||||
|
const cold = Number(growthDays) === 1
|
||||||
|
? "当日发布不进增速榜。更早发布的新入库按入库时播放量起算,需要当天至少两次采样。"
|
||||||
|
: "";
|
||||||
|
return `<div class="section-note">${smooth}${cold}</div>`;
|
||||||
|
}
|
||||||
|
if (sort !== "growth" || Number(growthDays) !== 1) return "";
|
||||||
return `<div class="section-note">近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需要当天至少两次采样。</div>`;
|
return `<div class="section-note">近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需要当天至少两次采样。</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +261,7 @@ async function renderScope(root, { query }, scope) {
|
|||||||
</header>
|
</header>
|
||||||
${controls({ scope, sort, within, year, growthDays, q, years })}
|
${controls({ scope, sort, within, year, growthDays, q, years })}
|
||||||
${durationNote(data)}
|
${durationNote(data)}
|
||||||
${growthNote(sort, growthDays)}
|
${growthNote(sort, growthDays, data.growth_rate_prior)}
|
||||||
${data.available === false ? `<div class="notice-row warning">
|
${data.available === false ? `<div class="notice-row warning">
|
||||||
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
|
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
|
||||||
<span>${escapeHtml(data.reason || "")}</span>
|
<span>${escapeHtml(data.reason || "")}</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from reaction_board.analytics import ( # noqa: E402
|
|||||||
METRICS,
|
METRICS,
|
||||||
age_days,
|
age_days,
|
||||||
compute_growth,
|
compute_growth,
|
||||||
|
smoothed_growth_rate,
|
||||||
content_hours,
|
content_hours,
|
||||||
eb_rate,
|
eb_rate,
|
||||||
effective_shrink_minutes,
|
effective_shrink_minutes,
|
||||||
@@ -161,6 +162,19 @@ def test_compute_growth():
|
|||||||
assert negative.per_day == -5.0
|
assert negative.per_day == -5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoothed_growth_rate():
|
||||||
|
# 80→512:真实增速 5.4;prior=500 时排序分 432/580
|
||||||
|
assert abs(smoothed_growth_rate(432, 80, 500) - 432 / 580) < 1e-12
|
||||||
|
assert abs(smoothed_growth_rate(432, 5000, 500) - 432 / 5500) < 1e-12
|
||||||
|
assert abs(smoothed_growth_rate(432, 80, 1000) - 432 / 1080) < 1e-12
|
||||||
|
# prior=0 时排序分退回真实相对增速(基线 ≥ 1)
|
||||||
|
assert smoothed_growth_rate(432, 80, 0) == 432 / 80
|
||||||
|
assert smoothed_growth_rate(None, 80, 500) is None
|
||||||
|
assert smoothed_growth_rate(432, None, 500) is None
|
||||||
|
assert smoothed_growth_rate(10, 0, 0) is None
|
||||||
|
assert smoothed_growth_rate(10, 80, -1) is None
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_cover_and_hosts():
|
def test_normalize_cover_and_hosts():
|
||||||
assert normalize_cover(None) is None
|
assert normalize_cover(None) is None
|
||||||
assert normalize_cover("") is None
|
assert normalize_cover("") is None
|
||||||
@@ -251,6 +265,7 @@ def main_test():
|
|||||||
test_score_percentiles()
|
test_score_percentiles()
|
||||||
test_interaction_rate_thresholds()
|
test_interaction_rate_thresholds()
|
||||||
test_compute_growth()
|
test_compute_growth()
|
||||||
|
test_smoothed_growth_rate()
|
||||||
test_normalize_cover_and_hosts()
|
test_normalize_cover_and_hosts()
|
||||||
test_content_efficiency_helpers()
|
test_content_efficiency_helpers()
|
||||||
test_age_and_maturity()
|
test_age_and_maturity()
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ def test_analytics_defaults_and_validation():
|
|||||||
assert cfg.analytics.video_shrink_minutes == 60
|
assert cfg.analytics.video_shrink_minutes == 60
|
||||||
assert cfg.analytics.shrink_minutes is None
|
assert cfg.analytics.shrink_minutes is None
|
||||||
assert cfg.analytics.home_growth_days == "auto"
|
assert cfg.analytics.home_growth_days == "auto"
|
||||||
|
assert cfg.analytics.growth_rate_prior == 500
|
||||||
assert cfg.schedule.recent_snapshot_minutes == 60
|
assert cfg.schedule.recent_snapshot_minutes == 60
|
||||||
assert cfg.schedule.recent_video_days == 2
|
assert cfg.schedule.recent_video_days == 2
|
||||||
assert cfg.schedule.snapshot_at == "23:15"
|
assert cfg.schedule.snapshot_at == "23:15"
|
||||||
@@ -159,6 +160,7 @@ def test_analytics_defaults_and_validation():
|
|||||||
"video_shrink_minutes": 30,
|
"video_shrink_minutes": 30,
|
||||||
"shrink_minutes": 90,
|
"shrink_minutes": 90,
|
||||||
"home_growth_days": 1,
|
"home_growth_days": 1,
|
||||||
|
"growth_rate_prior": 1000,
|
||||||
}
|
}
|
||||||
data["schedule"] = {
|
data["schedule"] = {
|
||||||
"discover_minutes": 30,
|
"discover_minutes": 30,
|
||||||
@@ -176,6 +178,7 @@ def test_analytics_defaults_and_validation():
|
|||||||
assert cfg.analytics.video_shrink_minutes == 30
|
assert cfg.analytics.video_shrink_minutes == 30
|
||||||
assert cfg.analytics.shrink_minutes == 90
|
assert cfg.analytics.shrink_minutes == 90
|
||||||
assert cfg.analytics.home_growth_days == 1
|
assert cfg.analytics.home_growth_days == 1
|
||||||
|
assert cfg.analytics.growth_rate_prior == 1000
|
||||||
assert cfg.schedule.recent_snapshot_minutes == 15
|
assert cfg.schedule.recent_snapshot_minutes == 15
|
||||||
assert cfg.schedule.recent_video_days == 3
|
assert cfg.schedule.recent_video_days == 3
|
||||||
|
|
||||||
@@ -194,6 +197,7 @@ def test_analytics_defaults_and_validation():
|
|||||||
({"home_growth_days": 3}, "home_growth_days"),
|
({"home_growth_days": 3}, "home_growth_days"),
|
||||||
({"home_growth_days": "7"}, "home_growth_days"),
|
({"home_growth_days": "7"}, "home_growth_days"),
|
||||||
({"home_growth_days": 0}, "home_growth_days"),
|
({"home_growth_days": 0}, "home_growth_days"),
|
||||||
|
({"growth_rate_prior": -1}, "growth_rate_prior"),
|
||||||
):
|
):
|
||||||
data = base_config()
|
data = base_config()
|
||||||
data["analytics"] = bad
|
data["analytics"] = bad
|
||||||
|
|||||||
@@ -492,6 +492,7 @@ def test_about_payload():
|
|||||||
"video_shrink_minutes": 60,
|
"video_shrink_minutes": 60,
|
||||||
"shrink_minutes": None,
|
"shrink_minutes": None,
|
||||||
"home_growth_days": "auto",
|
"home_growth_days": "auto",
|
||||||
|
"growth_rate_prior": 500,
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -748,12 +749,18 @@ def test_same_day_growth_baselines():
|
|||||||
assert 106 not in by_aid and 107 not in by_aid
|
assert 106 not in by_aid and 107 not in by_aid
|
||||||
assert by_aid[103]["growth"]["baseline"] == "zero"
|
assert by_aid[103]["growth"]["baseline"] == "zero"
|
||||||
assert by_aid[103]["growth"]["delta"] == 200
|
assert by_aid[103]["growth"]["delta"] == 200
|
||||||
|
assert by_aid[103]["growth"]["rate"] is None
|
||||||
assert by_aid[103]["growth"]["gap_days"] == 0
|
assert by_aid[103]["growth"]["gap_days"] == 0
|
||||||
assert by_aid[105]["growth"]["baseline"] == "open"
|
assert by_aid[105]["growth"]["baseline"] == "open"
|
||||||
assert by_aid[105]["growth"]["delta"] == 300
|
assert by_aid[105]["growth"]["delta"] == 300
|
||||||
assert by_aid[101]["growth"]["baseline"] == "snapshot"
|
assert by_aid[101]["growth"]["baseline"] == "snapshot"
|
||||||
assert by_aid[101]["growth"]["delta"] == 100
|
assert by_aid[101]["growth"]["delta"] == 100
|
||||||
|
|
||||||
|
rate = q.list_videos(sort="growth_rate", growth_days=1, page_size=20)
|
||||||
|
rate_aids = {item["aid"] for item in rate["items"]}
|
||||||
|
assert 103 not in rate_aids
|
||||||
|
assert 101 in rate_aids and 105 in rate_aids
|
||||||
|
|
||||||
seven = q.list_videos(sort="growth", growth_days=7)
|
seven = q.list_videos(sort="growth", growth_days=7)
|
||||||
seven_aids = {item["aid"] for item in seven["items"]}
|
seven_aids = {item["aid"] for item in seven["items"]}
|
||||||
assert 103 not in seven_aids
|
assert 103 not in seven_aids
|
||||||
@@ -774,6 +781,41 @@ def test_same_day_growth_baselines():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_growth_rate_sort_uses_prior():
|
||||||
|
"""小基数的真实增速可以更高,排序分用基线 + prior,不按 raw rate 排。"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db, q = setup(tmp)
|
||||||
|
try:
|
||||||
|
repo = Repository(db)
|
||||||
|
latest = DATES[-1]
|
||||||
|
previous_day = DATES[-2]
|
||||||
|
start = int(day_bounds(latest)[0])
|
||||||
|
pub = int(day_bounds("2026-09-01")[0])
|
||||||
|
for aid, views in ((301, (80, 512)), (302, (2000, 4000))):
|
||||||
|
repo.upsert_from_fav(
|
||||||
|
resource(aid, up_mid=1, up_name="UP A", pubdate=pub),
|
||||||
|
FOLDER.media_id, was_added=True, now=start,
|
||||||
|
)
|
||||||
|
repo.upsert_snapshot(
|
||||||
|
previous_day, aid, metrics=_view_metrics(views[0]),
|
||||||
|
ok=True, err=None, captured_at=start - 86400,
|
||||||
|
)
|
||||||
|
repo.upsert_snapshot(
|
||||||
|
latest, aid, metrics=_view_metrics(views[1]),
|
||||||
|
ok=True, err=None, captured_at=start + 60,
|
||||||
|
)
|
||||||
|
DASHBOARD_CACHE.clear()
|
||||||
|
ranked = q.list_videos(sort="growth_rate", growth_days=1, page_size=20)
|
||||||
|
order = [item["aid"] for item in ranked["items"]]
|
||||||
|
assert ranked["growth_rate_prior"] == 500
|
||||||
|
assert order.index(302) < order.index(301)
|
||||||
|
small = next(item for item in ranked["items"] if item["aid"] == 301)
|
||||||
|
assert abs(small["growth"]["rate"] - 432 / 80) < 1e-12
|
||||||
|
assert small["growth"]["delta"] == 432
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_system_tasks_sorted_by_started_at_desc():
|
def test_system_tasks_sorted_by_started_at_desc():
|
||||||
"""最近任务按 started_at 倒序;缺开始时间的排末尾(含进行中无 finished)。"""
|
"""最近任务按 started_at 倒序;缺开始时间的排末尾(含进行中无 finished)。"""
|
||||||
import json
|
import json
|
||||||
@@ -835,6 +877,7 @@ def main_test():
|
|||||||
test_snapshot_recent_next_run_follows_completion()
|
test_snapshot_recent_next_run_follows_completion()
|
||||||
test_overview_adjacent_previous_with_seed_and_dual_fields()
|
test_overview_adjacent_previous_with_seed_and_dual_fields()
|
||||||
test_same_day_growth_baselines()
|
test_same_day_growth_baselines()
|
||||||
|
test_growth_rate_sort_uses_prior()
|
||||||
print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统/访问统计测试全部通过")
|
print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统/访问统计测试全部通过")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user