feat(growth): same-day 1-day baselines for new videos
Let 1-day view growth use a zero baseline for videos published today, and the first successful sample for older videos ingested today, without mixing those into 7/30-day rankings.
This commit is contained in:
1 parent
8e7ead1669
commit
c5b7185675
21 files changed
+411
-59
No files matched your search
+101
-13
@@ -385,6 +385,18 @@ class DashboardQueries:
|
||||
percentiles = score_percentiles(scores)
|
||||
return videos, snapshots, scores, percentiles
|
||||
|
||||
@staticmethod
|
||||
def _publish_date(pubdate: object) -> str | None:
|
||||
if pubdate is None or isinstance(pubdate, bool):
|
||||
return None
|
||||
try:
|
||||
ts = int(pubdate)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if ts <= 0:
|
||||
return None
|
||||
return shanghai_date(ts)
|
||||
|
||||
@staticmethod
|
||||
def _growth_for(
|
||||
snapshot: Mapping[str, Any] | None,
|
||||
@@ -406,6 +418,68 @@ class DashboardQueries:
|
||||
gap = None
|
||||
return compute_growth(current, before, gap)
|
||||
|
||||
@classmethod
|
||||
def _same_day_growth(
|
||||
cls,
|
||||
snapshot: Mapping[str, Any] | None,
|
||||
video: Mapping[str, Any],
|
||||
metric: str = "view",
|
||||
) -> dict[str, Any] | None:
|
||||
"""近 1 日、没有更早快照时:当日发布相对 0;否则用 view_open。"""
|
||||
if snapshot is None:
|
||||
return None
|
||||
current = snapshot.get(metric)
|
||||
if current is None:
|
||||
return None
|
||||
stat_date = str(snapshot.get("stat_date") or "")
|
||||
published_on = cls._publish_date(video.get("pubdate"))
|
||||
if published_on is not None and published_on == stat_date:
|
||||
growth = compute_growth(current, 0, 0, allow_zero_gap=True)
|
||||
payload = growth.as_dict()
|
||||
payload["baseline"] = "zero"
|
||||
return payload
|
||||
try:
|
||||
open_at = snapshot.get("open_captured_at")
|
||||
captured_at = snapshot.get("captured_at")
|
||||
view_open = snapshot.get("view_open")
|
||||
open_ready = (
|
||||
open_at is not None
|
||||
and captured_at is not None
|
||||
and int(open_at) < int(captured_at)
|
||||
and view_open is not None
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
open_ready = False
|
||||
if not open_ready:
|
||||
return None
|
||||
growth = compute_growth(current, view_open, 0, allow_zero_gap=True)
|
||||
payload = growth.as_dict()
|
||||
payload["baseline"] = "open"
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _growth_result(
|
||||
cls,
|
||||
video: Mapping[str, Any],
|
||||
snapshot: Mapping[str, Any] | None,
|
||||
previous: Mapping[str, Any] | None,
|
||||
*,
|
||||
days_back: int,
|
||||
metric: str = "view",
|
||||
) -> dict[str, Any]:
|
||||
if previous is not None:
|
||||
growth = cls._growth_for(snapshot, previous, metric=metric)
|
||||
payload = growth.as_dict()
|
||||
payload["baseline"] = "snapshot" if growth.available else None
|
||||
return payload
|
||||
if days_back == 1:
|
||||
fallback = cls._same_day_growth(snapshot, video, metric=metric)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
payload = compute_growth(None, None, None).as_dict()
|
||||
payload["baseline"] = None
|
||||
return payload
|
||||
|
||||
def _item(
|
||||
self,
|
||||
video: Mapping[str, Any],
|
||||
@@ -415,6 +489,7 @@ class DashboardQueries:
|
||||
percentile: float | None = None,
|
||||
previous: Mapping[str, Any] | None = None,
|
||||
include_growth: bool = False,
|
||||
growth_days: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
metrics = _metric_map(snapshot)
|
||||
duration = video.get("duration")
|
||||
@@ -455,7 +530,9 @@ class DashboardQueries:
|
||||
),
|
||||
}
|
||||
if include_growth:
|
||||
item["growth"] = self._growth_for(snapshot, previous).as_dict()
|
||||
item["growth"] = self._growth_result(
|
||||
video, snapshot, previous, days_back=int(growth_days or 1),
|
||||
)
|
||||
return item
|
||||
|
||||
# ------------------------------------------------------------ overview
|
||||
@@ -474,7 +551,7 @@ class DashboardQueries:
|
||||
videos = self._videos(conn)
|
||||
aids = [int(video["aid"]) for video in videos]
|
||||
snapshots = self._snapshot_map(conn, aids)
|
||||
# 播放增量:按片相对当前点的相邻成功日,避免 seed/recent 拉到 T-2。
|
||||
# 播放增量:近 1 日。有更早日用相邻成功日;当日发布相对 0;否则用 view_open。
|
||||
previous = self._snapshot_previous_relative(conn, snapshots, days_back=1)
|
||||
totals = {metric: 0 for metric in METRICS}
|
||||
for video in videos:
|
||||
@@ -489,12 +566,13 @@ class DashboardQueries:
|
||||
for video in videos:
|
||||
aid = int(video["aid"])
|
||||
snapshot = snapshots.get(aid)
|
||||
prior = previous.get(aid)
|
||||
if snapshot is None or prior is None:
|
||||
if snapshot is None:
|
||||
continue
|
||||
growth = self._growth_for(snapshot, prior)
|
||||
if growth.available and growth.delta is not None:
|
||||
view_delta_sum += growth.delta
|
||||
growth = self._growth_result(
|
||||
video, snapshot, previous.get(aid), days_back=1,
|
||||
)
|
||||
if growth.get("available") and growth.get("delta") is not None:
|
||||
view_delta_sum += int(growth["delta"])
|
||||
available_deltas += 1
|
||||
if available_deltas:
|
||||
view_delta = view_delta_sum
|
||||
@@ -868,6 +946,7 @@ class DashboardQueries:
|
||||
percentile=percentiles.get(int(video["aid"])),
|
||||
previous=previous.get(int(video["aid"])),
|
||||
include_growth=sort in {"growth", "growth_rate"},
|
||||
growth_days=growth_days,
|
||||
)
|
||||
for video in videos
|
||||
]
|
||||
@@ -977,9 +1056,15 @@ class DashboardQueries:
|
||||
reason = None
|
||||
if sort in {"growth", "growth_rate"} and not growth_available:
|
||||
available = False
|
||||
reason = (
|
||||
f"近 {growth_days} 日增长需要至少 {growth_days + 1} 天快照"
|
||||
)
|
||||
if growth_days == 1:
|
||||
reason = (
|
||||
"近 1 日增长需要前一日快照;当日发布有一次成功采样即可;"
|
||||
"更早发布的新入库需要当天至少两次成功采样"
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
f"近 {growth_days} 日增长需要至少 {growth_days + 1} 天快照"
|
||||
)
|
||||
duration_filter = None
|
||||
if sort in duration_sorts:
|
||||
duration_filter = {
|
||||
@@ -1031,16 +1116,19 @@ class DashboardQueries:
|
||||
video = dict(row)
|
||||
snapshots = self._snapshot_map(conn, [aid])
|
||||
snapshot = snapshots.get(aid)
|
||||
previous = self._snapshot_map(conn, [aid], before=snapshot["stat_date"] if snapshot else None).get(aid)
|
||||
previous = self._snapshot_map(
|
||||
conn, [aid], before=snapshot["stat_date"] if snapshot else None,
|
||||
).get(aid)
|
||||
_, _, heat, percentiles = self._global_heat(conn)
|
||||
growth = self._growth_for(snapshot, previous)
|
||||
item = self._item(
|
||||
video,
|
||||
snapshot,
|
||||
heat=heat.get(aid),
|
||||
percentile=percentiles.get(aid),
|
||||
)
|
||||
item["growth"] = growth.as_dict()
|
||||
item["growth"] = self._growth_result(
|
||||
video, snapshot, previous, days_back=1,
|
||||
)
|
||||
item["snapshot_count"] = int(conn.execute(
|
||||
"SELECT COUNT(*) FROM snapshot WHERE aid = ?", (aid,)
|
||||
).fetchone()[0])
|
||||
|
||||
@@ -30,6 +30,12 @@ export function avatar(item, className = "avatar") {
|
||||
loading="lazy" referrerpolicy="no-referrer"></div>`;
|
||||
}
|
||||
|
||||
function growthBaselineTag(growth) {
|
||||
if (growth?.baseline === "zero") return "<small>今日</small>";
|
||||
if (growth?.baseline === "open") return "<small>入库后</small>";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function videoCard(item, {
|
||||
rank = null, primary = "view", primaryLabel = "", primaryText = null,
|
||||
showStatus = false,
|
||||
@@ -73,7 +79,7 @@ export function videoCard(item, {
|
||||
<span class="primary-stat">
|
||||
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
|
||||
</span>
|
||||
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</span>` : ""}
|
||||
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}${growthBaselineTag(growth)}</span>` : ""}
|
||||
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +124,7 @@ export function metricGrid(item) {
|
||||
return `<div class="metric-grid">
|
||||
${["view", "like", "coin", "favorite", "share", "reply", "danmaku"].map((metric) => {
|
||||
const extra = metric === "view" && growth?.available
|
||||
? `<span class="metric-delta ${growth.delta >= 0 ? "up" : "down"}">${formatDelta(growth.delta)}</span>`
|
||||
? `<span class="metric-delta ${growth.delta >= 0 ? "up" : "down"}">${formatDelta(growth.delta)}${growthBaselineTag(growth)}</span>`
|
||||
: "";
|
||||
return `<div class="metric-cell">
|
||||
<span class="metric-label">${escapeHtml(metricLabel(metric))}</span>
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function renderAbout(root) {
|
||||
<li>B 站不公开完播率与单 P 播放,内容小时是“供给时长”,不是“实际观看时长”。</li>
|
||||
<li>多 P 可能让同一用户产生多次播放计数,本站不单独校正,属于已知偏差。</li>
|
||||
<li>粉丝数可能包含不活跃账号;粉丝快照暂不参与任何榜单。</li>
|
||||
<li>样本不足(少于 2 天快照、单视频 UP)会显示提示,不伪造 0。</li>
|
||||
<li>近 7/30 日增长仍要跨日快照。近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需当天至少两次采样。样本不足时显示提示,不伪造 0。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function renderHome(root) {
|
||||
const growthEmptyTitle = growth.reason
|
||||
|| `近 ${growthDays} 日增长暂不可用`;
|
||||
const growthEmptyHint = growthDays <= 1
|
||||
? "当前快照不足,完成至少 2 天快照后即可显示播放增量。"
|
||||
? "当日发布按 0 起算;更早发布的新入库需要当天至少两次采样。"
|
||||
: `需要至少 ${growthDays + 1} 天快照才能计算近 ${growthDays} 日增长。`;
|
||||
const updatedLabel = formatClock(overview.updated_at);
|
||||
const dataDateLabel = formatDataDate(overview.data_date);
|
||||
|
||||
@@ -179,6 +179,12 @@ function durationNote(data) {
|
||||
return `<div class="section-note">内容效率与互动效率:时长 ≥ ${filter.min_duration_seconds} 秒、发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view}${shrink};时长缺失不参与。</div>`;
|
||||
}
|
||||
|
||||
function growthNote(sort, growthDays) {
|
||||
if (sort !== "growth" && sort !== "growth_rate") return "";
|
||||
if (Number(growthDays) !== 1) return "";
|
||||
return `<div class="section-note">近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需要当天至少两次采样。</div>`;
|
||||
}
|
||||
|
||||
function bindControls(root) {
|
||||
root.querySelector("#rank-sort")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ sort: event.target.value, page: null });
|
||||
@@ -245,6 +251,7 @@ async function renderScope(root, { query }, scope) {
|
||||
</header>
|
||||
${controls({ scope, sort, within, year, growthDays, q, years })}
|
||||
${durationNote(data)}
|
||||
${growthNote(sort, growthDays)}
|
||||
${data.available === false ? `<div class="notice-row warning">
|
||||
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
|
||||
<span>${escapeHtml(data.reason || "")}</span>
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function renderVideo(root, { params, query }) {
|
||||
<span>${escapeHtml(formatDuration(detail.duration))}</span>
|
||||
${detail.pages ? `<span>${formatNumber(detail.pages)} P</span>` : ""}
|
||||
${badge(statusLabel(detail.status), `status-${detail.status}`)}
|
||||
${samples < 2 ? badge("样本不足", "warning") : ""}
|
||||
${samples < 2 && !detail.growth?.available ? badge("样本不足", "warning") : ""}
|
||||
</div>
|
||||
${detail.blacklisted ? `<p class="section-note warning-text">该 UP 在黑名单中;黑名单只影响未来发现,不影响当前视频追踪。</p>` : ""}
|
||||
${detail.up_follower_backfilled ? `<p class="section-note">UP 粉丝数为回填值,不是真实的入库时点。</p>` : ""}
|
||||
|
||||
Reference in new issue
Block a user