Compare commits

Author SHA1 Message Date
kairee 39378c65b2 Merge pull request 'feat(growth): same-day 1-day baselines for new videos'
Reviewed-on: #6
2026-11-02 22:12:18 +08:00
kairee 00572a3429 fix(growth): require a 1000-view baseline on the rate board
Keep small-base videos on the delta board only, and raise the smoothing prior to 1000 so videos that just clear the floor do not dominate the ranking.
2026-11-02 22:11:30 +08:00
kairee 39a6537f67 fix(growth): rank the rate board by a smoothed score
Show the real percentage on the card, but sort with delta / (baseline + prior) so a small baseline cannot dominate. Same-day publishes stay off the rate board.
2026-11-02 22:06:41 +08:00
kairee 0f2ac612f5 fix(ui): widen mobile video-card cover so duration stays on the thumbnail 2026-11-02 21:39:06 +08:00
kairee 9412475473 Merge branch 'main' into cursor/same-day-growth-baseline 2026-11-02 20:45:30 +08:00
kairee c5b7185675 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.
2026-11-02 20:44:17 +08:00
bot-ci 3254648849 fix(system): sort recent tasks by started_at desc
「最近任务」原先按 kv 键名排序。改为后端 `_task_records` 按
started_at 倒序写入 dict(缺开始时间排末尾,含进行中);
前端不再参与排序。配套 test_system_tasks_sorted_by_started_at_desc。
2026-11-02 19:52:46 +08:00
31 changed files with 647 additions and 605 deletions

No files matched your search

+2 -3
View File
@@ -267,7 +267,6 @@ PRAGMA synchronous = NORMAL;
业务参数放在 `config.jsonc`(JSONC:`//` / `/* */` 注释与尾逗号;实现为自研轻量剥离后再 `json.loads`,无额外依赖)。 业务参数放在 `config.jsonc`(JSONC:`//` / `/* */` 注释与尾逗号;实现为自研轻量剥离后再 `json.loads`,无额外依赖)。
敏感凭据放同目录 `secrets.json`(纯 JSON,供 `auth` 原子回写)。二者都必须被 Git 忽略,日志中不得输出敏感值。 敏感凭据放同目录 `secrets.json`(纯 JSON,供 `auth` 原子回写)。二者都必须被 Git 忽略,日志中不得输出敏感值。
默认路径优先 `config.jsonc`,否则回落 `config.json`;旧单体 `config.json`(凭据内嵌)仍可加载,若存在 `secrets.json` 则凭据以它为准。 默认路径优先 `config.jsonc`,否则回落 `config.json`;旧单体 `config.json`(凭据内嵌)仍可加载,若存在 `secrets.json` 则凭据以它为准。
从旧单体迁到拆分布局:`uv run python tools/split_config_secrets.py [--dry-run] [--force] [--keep-src]`(默认写出同目录 `config.jsonc` + `secrets.json`,并把源改名为 `.bak`)。
`config.jsonc` 示例: `config.jsonc` 示例:
@@ -609,7 +608,7 @@ interaction_rate = (like + coin + favorite) / max(view, 1)
- 查询前一有效快照而不是填补缺失日期; - 查询前一有效快照而不是填补缺失日期;
- 缺失日期在图表中断开; - 缺失日期在图表中断开;
- 增速榜要求播放基数至少 200; - 增速榜要求播放基数至少 1000;
- 互动率榜要求播放至少 100; - 互动率榜要求播放至少 100;
- 样本不足两天的视频明确标注,不伪造增长数据。 - 样本不足两天的视频明确标注,不伪造增长数据。
@@ -757,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`;
- 卡片精简:列表只保留主指标与互动率,明细移到详情页; - 卡片精简:列表只保留主指标与互动率,明细移到详情页;
-7
View File
@@ -314,13 +314,6 @@ Copy-Item secrets.example.json secrets.json
默认启动优先读 `config.jsonc`,若不存在则回落 `config.json`(旧单体文件仍可用)。 默认启动优先读 `config.jsonc`,若不存在则回落 `config.json`(旧单体文件仍可用)。
`python app.py auth` **只回写** `secrets.json`,不会冲掉 JSONC 注释。 `python app.py auth` **只回写** `secrets.json`,不会冲掉 JSONC 注释。
若部署目录仍是旧版单体 `config.json`,可用拆分脚本一次迁出:
```powershell
uv run python tools/split_config_secrets.py --dry-run
uv run python tools/split_config_secrets.py # 写出 config.jsonc + secrets.json,源改名为 config.json.bak
```
凭据文件双账号格式: 凭据文件双账号格式:
```json ```json
+1
View File
@@ -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": 1000,
}, },
} }
+2
View File
@@ -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": 1000,
}, },
} }
+9 -4
View File
@@ -380,7 +380,7 @@ for folder in 配置的年度夹(2025、2026、…):
- 两个夹共 831 条,一次性导入 `video` 表; - 两个夹共 831 条,一次性导入 `video` 表;
- **历史快照无法回填**:B 站不提供任意日期历史数据,只能从部署当天开始积累; - **历史快照无法回填**:B 站不提供任意日期历史数据,只能从部署当天开始积累;
- **开发联调阶段产生的数据一律保留**(不清库、不重建),正式上线直接续用同一个 `app.db`; - **开发联调阶段产生的数据一律保留**(不清库、不重建),正式上线直接续用同一个 `app.db`;
- 新发现/新入库的视频从"入库当天"开始积累快照,第一天没有增量是正常的,看板对"仅 1 个数据点"的视频标注"样本不足"。 - 新发现/新入库的视频从"入库当天"开始积累快照。近 1 日:当日发布按 0 起算(一次成功采样即可);更早发布的新入库按入库时播放量起算,第二次采样走晚间全量,不进小时快照。近 7/30 日仍要跨日快照。
--- ---
@@ -473,6 +473,8 @@ CREATE TABLE snapshot (
ok INTEGER NOT NULL DEFAULT 1, ok INTEGER NOT NULL DEFAULT 1,
err TEXT, err TEXT,
src TEXT DEFAULT 'view', -- view | infos(降级时标记) src TEXT DEFAULT 'view', -- view | infos(降级时标记)
view_open INTEGER, -- 同日第一次成功采样的播放量
open_captured_at INTEGER, -- 开盘采样时刻
PRIMARY KEY (stat_date, aid) PRIMARY KEY (stat_date, aid)
); );
CREATE INDEX idx_snap_aid ON snapshot(aid, stat_date); CREATE INDEX idx_snap_aid ON snapshot(aid, stat_date);
@@ -672,23 +674,26 @@ w = {view:0.30, like:0.20, coin:0.20, favorite:0.15,
### 8.2 增长榜 ### 8.2 增长榜
> 看板 UI 中“增长”标注为**播放增量**(区间内绝对增量),“增速”标注为**播放增速**(增量 / 基线播放)。两者基线都取不早于 `最新快照 - N 天` 的最近一条成功快照。 > 看板 UI 中“增长”标注为**播放增量**(区间内绝对增量),“增速”标注为**播放增速**(增量 / 基线播放)。近 7/30 日基线取不早于 `最新快照 - N 天` 的最近一条成功快照。近 1 日:有更早日期快照则用前一日;否则当日发布基准为 0,更早发布的新入库用当天第一次成功采样(`view_open`)。
``` ```
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_imported_today = view_today - view_open # 需同日第二次采样
sort_rate = Δ / (基线 + analytics.growth_rate_prior) # 默认 prior=1000,只用于增速榜排序
``` ```
| 榜 | 口径 | 基数门槛 | | 榜 | 口径 | 基数门槛 |
|---|---|---| |---|---|---|
| 累计热度 | §8.1 | — | | 累计热度 | §8.1 | — |
| 近 7 天播放增量 | `d7(view)` | — | | 近 7 天播放增量 | `d7(view)` | — |
| 近 7 天增速 | `r7(view)` | `view >= 200` | | 近 7 天增速 | `r7(view)` | 基线播放 `>= 1000` |
| 互动率 | `(like+coin+fav)/view` | `view >= 100` | | 互动率 | `(like+coin+fav)/view` | `view >= 100` |
| 单项榜 | 7 项直接排序 | — | | 单项榜 | 7 项直接排序 | — |
| UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — | | UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — |
**冷启动**:不足 2 天数据的视频用"发布后 24h 增量",并标注"样本不足";缺失日不插值。 **冷启动**:近 1 日不要求跨日快照。当日发布相对 0,只进播放增量、不进播放增速;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。
### 8.3 趋势 ### 8.3 趋势
+32 -4
View File
@@ -34,7 +34,7 @@ DEFAULT_HEAT_WEIGHTS: dict[str, float] = {
} }
MIN_VIEW_FOR_INTERACTION = 100 MIN_VIEW_FOR_INTERACTION = 100
MIN_VIEW_FOR_GROWTH_RATE = 200 MIN_VIEW_FOR_GROWTH_RATE = 1000
MIN_EFFICIENCY_DURATION = 600 MIN_EFFICIENCY_DURATION = 600
DEFAULT_MATURITY_DAYS = 2 DEFAULT_MATURITY_DAYS = 2
DEFAULT_SHRINK_MINUTES = 120.0 DEFAULT_SHRINK_MINUTES = 120.0
@@ -313,15 +313,20 @@ def compute_growth(
current: int | float | None, current: int | float | None,
previous: int | float | None, previous: int | float | None,
gap_days: int | float | None, gap_days: int | float | None,
*,
allow_zero_gap: bool = False,
) -> Growth: ) -> Growth:
"""计算累计差值、日均差值和相对增速。 """计算累计差值、日均差值和相对增速。
缺失基线或间隔不足 1 天时返回不可用,不把 0 当作真实增长。 缺失基线或间隔为负时返回不可用。默认间隔不足 1 天也不可用,不把 0 当成真实增长。
``allow_zero_gap=True`` 时允许同一天的两次采样(或相对 0)计算增量,``per_day`` 为 None。
""" """
if current is None or previous is None or gap_days is None: if current is None or previous is None or gap_days is None:
return Growth(available=False) return Growth(available=False)
gap = int(gap_days) gap = int(gap_days)
if gap <= 0: if gap < 0:
return Growth(available=False)
if gap == 0 and not allow_zero_gap:
return Growth(available=False) return Growth(available=False)
current_value = max(0, int(current)) current_value = max(0, int(current))
previous_value = max(0, int(previous)) previous_value = max(0, int(previous))
@@ -329,12 +334,35 @@ def compute_growth(
return Growth( return Growth(
available=True, available=True,
delta=delta, delta=delta,
per_day=delta / gap, per_day=None if gap == 0 else delta / gap,
rate=delta / max(previous_value, 1), rate=delta / max(previous_value, 1),
gap_days=gap, gap_days=gap,
) )
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):
+7
View File
@@ -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": 1000,
} }
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", 1000),
"analytics.growth_rate_prior",
minimum=0,
),
) )
return AppConfig( return AppConfig(
+12
View File
@@ -244,6 +244,18 @@ MIGRATIONS: tuple[Migration, ...] = (
"CREATE INDEX idx_visit_date ON visit(stat_date)", "CREATE INDEX idx_visit_date ON visit(stat_date)",
), ),
), ),
Migration(
version=8,
name="snapshot open view",
statements=(
# 同日第一次成功采样的播放量,供近 1 日「入库后增量」做基准。
# 已有成功行无法找回被覆盖的入库值,只能把当前 view 当作开盘。
'ALTER TABLE snapshot ADD COLUMN view_open INTEGER',
"ALTER TABLE snapshot ADD COLUMN open_captured_at INTEGER",
'UPDATE snapshot SET view_open = "view", open_captured_at = captured_at '
"WHERE ok = 1 AND view_open IS NULL",
),
),
) )
+30 -8
View File
@@ -690,12 +690,17 @@ class Repository:
src: str = "view", src: str = "view",
) -> None: ) -> None:
values = metrics or {} values = metrics or {}
ok_flag = 1 if ok else 0
view_value = values.get("view")
open_view = view_value if ok else None
open_at = int(captured_at) if ok else None
def run(conn: sqlite3.Connection) -> None: def run(conn: sqlite3.Connection) -> None:
conn.execute( conn.execute(
""" """
INSERT INTO snapshot(stat_date, aid, "view", "like", favorite, coin, INSERT INTO snapshot(stat_date, aid, "view", "like", favorite, coin,
share, reply, danmaku, captured_at, ok, err, src) share, reply, danmaku, captured_at, ok, err, src,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) view_open, open_captured_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stat_date, aid) DO UPDATE SET ON CONFLICT(stat_date, aid) DO UPDATE SET
"view" = excluded."view", "view" = excluded."view",
"like" = excluded."like", "like" = excluded."like",
@@ -707,12 +712,22 @@ class Repository:
captured_at = excluded.captured_at, captured_at = excluded.captured_at,
ok = excluded.ok, ok = excluded.ok,
err = excluded.err, err = excluded.err,
src = excluded.src src = excluded.src,
view_open = CASE
WHEN excluded.ok = 1 AND snapshot.view_open IS NULL
THEN excluded."view"
ELSE snapshot.view_open
END,
open_captured_at = CASE
WHEN excluded.ok = 1 AND snapshot.open_captured_at IS NULL
THEN excluded.captured_at
ELSE snapshot.open_captured_at
END
""", """,
( (
stat_date, stat_date,
int(aid), int(aid),
values.get("view"), view_value,
values.get("like"), values.get("like"),
values.get("favorite"), values.get("favorite"),
values.get("coin"), values.get("coin"),
@@ -720,9 +735,11 @@ class Repository:
values.get("reply"), values.get("reply"),
values.get("danmaku"), values.get("danmaku"),
int(captured_at), int(captured_at),
1 if ok else 0, ok_flag,
err, err,
src, src,
open_view,
open_at,
), ),
) )
self.db.write(run) self.db.write(run)
@@ -741,18 +758,23 @@ class Repository:
""" """
INSERT INTO snapshot( INSERT INTO snapshot(
stat_date, aid, "view", "like", favorite, coin, stat_date, aid, "view", "like", favorite, coin,
share, reply, danmaku, captured_at, ok, err, src share, reply, danmaku, captured_at, ok, err, src,
view_open, open_captured_at
) )
SELECT SELECT
?, aid, "view", "like", favorite, coin, ?, aid, "view", "like", favorite, coin,
share, reply, danmaku, ?, 1, NULL, ? share, reply, danmaku, ?, 1, NULL, ?,
"view", ?
FROM snapshot FROM snapshot
WHERE stat_date = ? AND ok = 1 WHERE stat_date = ? AND ok = 1
AND aid NOT IN ( AND aid NOT IN (
SELECT aid FROM snapshot WHERE stat_date = ? SELECT aid FROM snapshot WHERE stat_date = ?
) )
""", """,
(target_date, int(captured_at), src, source_date, target_date), (
target_date, int(captured_at), src, int(captured_at),
source_date, target_date,
),
) )
return int(cur.rowcount or 0) return int(cur.rowcount or 0)
return self.db.write(run) return self.db.write(run)
+2 -1
View File
@@ -310,7 +310,8 @@ class SnapshotService:
"""刷新近 N 天新视频的当日快照;不碰完成日标记与日备份。 """刷新近 N 天新视频的当日快照;不碰完成日标记与日备份。
「近 N 天」按 pubdate 墙钟窗口:``now - recent_video_days * 86400``, 「近 N 天」按 pubdate 墙钟窗口:``now - recent_video_days * 86400``,
不是上海日历日差。允许覆盖同日已有 ``ok=1`` 行以便小时级刷新。 不是上海日历日差。当天入库但发布更早的旧片不进小时任务。
允许覆盖同日已有 ``ok=1`` 行以便小时级刷新。
""" """
cfg = self.config_store.get() cfg = self.config_store.get()
client = self._client() client = self._client()
+158 -21
View File
@@ -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,
@@ -58,6 +59,19 @@ def _chunks(values: Sequence[int], size: int = 400) -> Iterable[Sequence[int]]:
yield values[index:index + size] yield values[index:index + size]
def _growth_baseline_view(item: Mapping[str, Any]) -> int | None:
"""增速分母:当前播放减去区间增量。zero 基线的增量等于当前播放,结果为 0。"""
growth = item.get("growth") or {}
current = (item.get("metrics") or {}).get("view")
delta = growth.get("delta")
if current is None or delta is None:
return None
try:
return int(current) - int(delta)
except (TypeError, ValueError):
return None
def _date_minus(day: str, days: int) -> str: def _date_minus(day: str, days: int) -> str:
return (date.fromisoformat(day) - timedelta(days=int(days))).isoformat() return (date.fromisoformat(day) - timedelta(days=int(days))).isoformat()
@@ -385,6 +399,18 @@ class DashboardQueries:
percentiles = score_percentiles(scores) percentiles = score_percentiles(scores)
return videos, snapshots, scores, percentiles 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 @staticmethod
def _growth_for( def _growth_for(
snapshot: Mapping[str, Any] | None, snapshot: Mapping[str, Any] | None,
@@ -406,6 +432,70 @@ class DashboardQueries:
gap = None gap = None
return compute_growth(current, before, gap) 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"
# previous=0 时 rate = view/1,会按原始播放碾压真日环比。
payload["rate"] = None
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( def _item(
self, self,
video: Mapping[str, Any], video: Mapping[str, Any],
@@ -415,6 +505,7 @@ class DashboardQueries:
percentile: float | None = None, percentile: float | None = None,
previous: Mapping[str, Any] | None = None, previous: Mapping[str, Any] | None = None,
include_growth: bool = False, include_growth: bool = False,
growth_days: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
metrics = _metric_map(snapshot) metrics = _metric_map(snapshot)
duration = video.get("duration") duration = video.get("duration")
@@ -455,7 +546,9 @@ class DashboardQueries:
), ),
} }
if include_growth: 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 return item
# ------------------------------------------------------------ overview # ------------------------------------------------------------ overview
@@ -474,7 +567,7 @@ class DashboardQueries:
videos = self._videos(conn) videos = self._videos(conn)
aids = [int(video["aid"]) for video in videos] aids = [int(video["aid"]) for video in videos]
snapshots = self._snapshot_map(conn, aids) snapshots = self._snapshot_map(conn, aids)
# 播放增量:按片相对当前点的相邻成功日,避免 seed/recent 拉到 T-2。 # 播放增量:近 1 日。有更早日用相邻成功日;当日发布相对 0;否则用 view_open。
previous = self._snapshot_previous_relative(conn, snapshots, days_back=1) previous = self._snapshot_previous_relative(conn, snapshots, days_back=1)
totals = {metric: 0 for metric in METRICS} totals = {metric: 0 for metric in METRICS}
for video in videos: for video in videos:
@@ -489,12 +582,13 @@ class DashboardQueries:
for video in videos: for video in videos:
aid = int(video["aid"]) aid = int(video["aid"])
snapshot = snapshots.get(aid) snapshot = snapshots.get(aid)
prior = previous.get(aid) if snapshot is None:
if snapshot is None or prior is None:
continue continue
growth = self._growth_for(snapshot, prior) growth = self._growth_result(
if growth.available and growth.delta is not None: video, snapshot, previous.get(aid), days_back=1,
view_delta_sum += growth.delta )
if growth.get("available") and growth.get("delta") is not None:
view_delta_sum += int(growth["delta"])
available_deltas += 1 available_deltas += 1
if available_deltas: if available_deltas:
view_delta = view_delta_sum view_delta = view_delta_sum
@@ -622,19 +716,32 @@ class DashboardQueries:
continue continue
return None return None
@staticmethod
def _task_started_sort_key(value: Any) -> tuple[int, int]:
"""缺 started_at 的排末尾;有值的按开始时间倒序(含进行中)。"""
if isinstance(value, Mapping):
raw = value.get("started_at")
try:
return (0, -int(raw))
except (TypeError, ValueError):
pass
return (1, 0)
def _task_records(self, conn: sqlite3.Connection) -> dict[str, Any]: def _task_records(self, conn: sqlite3.Connection) -> dict[str, Any]:
rows = conn.execute( rows = conn.execute(
"SELECT k, v FROM kv WHERE k LIKE 'task:last:%' ORDER BY k" "SELECT k, v FROM kv WHERE k LIKE 'task:last:%'"
).fetchall() ).fetchall()
result: dict[str, Any] = {} items: list[tuple[str, Any]] = []
for row in rows: for row in rows:
name = str(row["k"]).removeprefix("task:last:") name = str(row["k"]).removeprefix("task:last:")
try: try:
value = json.loads(str(row["v"])) value = json.loads(str(row["v"]))
except (TypeError, json.JSONDecodeError): except (TypeError, json.JSONDecodeError):
value = {"raw": row["v"]} value = {"raw": row["v"]}
result[name] = value items.append((name, value))
return result items.sort(key=lambda pair: self._task_started_sort_key(pair[1]))
# 插入序即开始时间倒序;前端 Object.entries 保留该序,勿再排序。
return dict(items)
def _task_running(self, conn: sqlite3.Connection) -> dict[str, Any]: def _task_running(self, conn: sqlite3.Connection) -> dict[str, Any]:
rows = conn.execute( rows = conn.execute(
@@ -868,6 +975,7 @@ class DashboardQueries:
percentile=percentiles.get(int(video["aid"])), percentile=percentiles.get(int(video["aid"])),
previous=previous.get(int(video["aid"])), previous=previous.get(int(video["aid"])),
include_growth=sort in {"growth", "growth_rate"}, include_growth=sort in {"growth", "growth_rate"},
growth_days=growth_days,
) )
for video in videos for video in videos
] ]
@@ -939,9 +1047,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 (_growth_baseline_view(item) or 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"]))
@@ -958,9 +1070,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
@@ -977,9 +1098,20 @@ 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
reason = ( if growth_days == 1 and sort == "growth_rate":
f"近 {growth_days} 日增长需要至少 {growth_days + 1} 天快照" reason = (
) "近 1 日增速需要基线播放至少 "
f"{MIN_VIEW_FOR_GROWTH_RATE};当日发布不计入增速"
)
elif growth_days == 1:
reason = (
"近 1 日增长需要前一日快照;当日发布有一次成功采样即可;"
"更早发布的新入库需要当天至少两次成功采样"
)
else:
reason = (
f"近 {growth_days} 日增长需要至少 {growth_days + 1} 天快照"
)
duration_filter = None duration_filter = None
if sort in duration_sorts: if sort in duration_sorts:
duration_filter = { duration_filter = {
@@ -1010,6 +1142,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)
@@ -1031,16 +1164,19 @@ class DashboardQueries:
video = dict(row) video = dict(row)
snapshots = self._snapshot_map(conn, [aid]) snapshots = self._snapshot_map(conn, [aid])
snapshot = snapshots.get(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) _, _, heat, percentiles = self._global_heat(conn)
growth = self._growth_for(snapshot, previous)
item = self._item( item = self._item(
video, video,
snapshot, snapshot,
heat=heat.get(aid), heat=heat.get(aid),
percentile=percentiles.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( item["snapshot_count"] = int(conn.execute(
"SELECT COUNT(*) FROM snapshot WHERE aid = ?", (aid,) "SELECT COUNT(*) FROM snapshot WHERE aid = ?", (aid,)
).fetchone()[0]) ).fetchone()[0])
@@ -1653,6 +1789,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)
+17 -5
View File
@@ -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") {
@@ -30,9 +30,15 @@ export function avatar(item, className = "avatar") {
loading="lazy" referrerpolicy="no-referrer"></div>`; 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, { 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);
@@ -43,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;
@@ -73,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)}</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>
@@ -118,7 +130,7 @@ export function metricGrid(item) {
return `<div class="metric-grid"> return `<div class="metric-grid">
${["view", "like", "coin", "favorite", "share", "reply", "danmaku"].map((metric) => { ${["view", "like", "coin", "favorite", "share", "reply", "danmaku"].map((metric) => {
const extra = metric === "view" && growth?.available 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"> return `<div class="metric-cell">
<span class="metric-label">${escapeHtml(metricLabel(metric))}</span> <span class="metric-label">${escapeHtml(metricLabel(metric))}</span>
+6
View File
@@ -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);
+3 -3
View File
@@ -353,7 +353,7 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
transition: border-color 0.15s cubic-bezier(0.22, 1, 0.36, 1), transition: border-color 0.15s cubic-bezier(0.22, 1, 0.36, 1),
background-color 0.15s cubic-bezier(0.22, 1, 0.36, 1); background-color 0.15s cubic-bezier(0.22, 1, 0.36, 1);
} }
.video-card { grid-template-columns: 128px minmax(0, 1fr); } .video-card { grid-template-columns: 160px minmax(0, 1fr); }
.up-card { grid-template-columns: 64px minmax(0, 1fr); align-items: center; } .up-card { grid-template-columns: 64px minmax(0, 1fr); align-items: center; }
.video-card:hover, .up-card:hover { .video-card:hover, .up-card:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
@@ -995,7 +995,7 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
.up-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); } .up-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.detail-hero { flex-direction: row; align-items: flex-start; } .detail-hero { flex-direction: row; align-items: flex-start; }
.hero-cover { width: 300px; flex: 0 0 300px; } .hero-cover { width: 300px; flex: 0 0 300px; }
.video-card { grid-template-columns: 144px minmax(0, 1fr); } .video-card { grid-template-columns: 160px minmax(0, 1fr); }
.secondary-stat.optional { display: inline; } .secondary-stat.optional { display: inline; }
} }
@@ -1048,7 +1048,7 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
} }
@media (max-width: 380px) { @media (max-width: 380px) {
.video-card { grid-template-columns: 104px minmax(0, 1fr); gap: 8px; } .video-card { gap: 8px; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.candidate-card { grid-template-columns: 80px minmax(0, 1fr); } .candidate-card { grid-template-columns: 80px minmax(0, 1fr); }
.candidate-thumb { width: 80px; } .candidate-thumb { width: 80px; }
+2 -1
View File
@@ -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>:卡片上的百分比是增量 / 基线播放。基线播放不足 1000 不进增速榜。排序用增量 / (基线 + ${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>
@@ -92,7 +93,7 @@ export async function renderAbout(root) {
<li>B 站不公开完播率与单 P 播放,内容小时是“供给时长”,不是“实际观看时长”。</li> <li>B 站不公开完播率与单 P 播放,内容小时是“供给时长”,不是“实际观看时长”。</li>
<li>多 P 可能让同一用户产生多次播放计数,本站不单独校正,属于已知偏差。</li> <li>多 P 可能让同一用户产生多次播放计数,本站不单独校正,属于已知偏差。</li>
<li>粉丝数可能包含不活跃账号;粉丝快照暂不参与任何榜单。</li> <li>粉丝数可能包含不活跃账号;粉丝快照暂不参与任何榜单。</li>
<li>样本不足(少于 2 天快照、单视频 UP)会显示提示,不伪造 0。</li> <li>近 7/30 日增长仍要跨日快照。近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需当天至少两次采样。样本不足时显示提示,不伪造 0。</li>
</ul> </ul>
</section> </section>
+1 -1
View File
@@ -69,7 +69,7 @@ export async function renderHome(root) {
const growthEmptyTitle = growth.reason const growthEmptyTitle = growth.reason
|| `近 ${growthDays} 日增长暂不可用`; || `近 ${growthDays} 日增长暂不可用`;
const growthEmptyHint = growthDays <= 1 const growthEmptyHint = growthDays <= 1
? "当前快照不足,完成至少 2 天快照后即可显示播放增量。" ? "当日发布按 0 起算;更早发布的新入库需要当天至少两次采样。"
: `需要至少 ${growthDays + 1} 天快照才能计算近 ${growthDays} 日增长。`; : `需要至少 ${growthDays + 1} 天快照才能计算近 ${growthDays} 日增长。`;
const updatedLabel = formatClock(overview.updated_at); const updatedLabel = formatClock(overview.updated_at);
const dataDateLabel = formatDataDate(overview.data_date); const dataDateLabel = formatDataDate(overview.data_date);
@@ -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,6 +180,21 @@ 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, prior) {
if (sort === "growth_rate") {
const strength = Number(prior);
const smooth = Number.isFinite(strength)
? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。`
: "百分比是真实相对增量;排序按平滑增速。";
const cold = Number(growthDays) === 1
? "基线播放不足 1000、以及当日发布,不进增速榜。"
: "基线播放不足 1000 不进增速榜。";
return `<div class="section-note">${smooth}${cold}</div>`;
}
if (sort !== "growth" || Number(growthDays) !== 1) return "";
return `<div class="section-note">近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需要当天至少两次采样。</div>`;
}
function bindControls(root) { function bindControls(root) {
root.querySelector("#rank-sort")?.addEventListener("change", (event) => { root.querySelector("#rank-sort")?.addEventListener("change", (event) => {
replaceQuery({ sort: event.target.value, page: null }); replaceQuery({ sort: event.target.value, page: null });
@@ -245,6 +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, 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>
+1 -1
View File
@@ -62,7 +62,7 @@ export async function renderVideo(root, { params, query }) {
<span>${escapeHtml(formatDuration(detail.duration))}</span> <span>${escapeHtml(formatDuration(detail.duration))}</span>
${detail.pages ? `<span>${formatNumber(detail.pages)} P</span>` : ""} ${detail.pages ? `<span>${formatNumber(detail.pages)} P</span>` : ""}
${badge(statusLabel(detail.status), `status-${detail.status}`)} ${badge(statusLabel(detail.status), `status-${detail.status}`)}
${samples < 2 ? badge("样本不足", "warning") : ""} ${samples < 2 && !detail.growth?.available ? badge("样本不足", "warning") : ""}
</div> </div>
${detail.blacklisted ? `<p class="section-note warning-text">该 UP 在黑名单中;黑名单只影响未来发现,不影响当前视频追踪。</p>` : ""} ${detail.blacklisted ? `<p class="section-note warning-text">该 UP 在黑名单中;黑名单只影响未来发现,不影响当前视频追踪。</p>` : ""}
${detail.up_follower_backfilled ? `<p class="section-note">UP 粉丝数为回填值,不是真实的入库时点。</p>` : ""} ${detail.up_follower_backfilled ? `<p class="section-note">UP 粉丝数为回填值,不是真实的入库时点。</p>` : ""}
+27
View File
@@ -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,
@@ -131,6 +132,18 @@ def test_compute_growth():
no_gap = compute_growth(100, 90, 0) no_gap = compute_growth(100, 90, 0)
assert no_gap.available is False assert no_gap.available is False
same_day = compute_growth(100, 90, 0, allow_zero_gap=True)
assert same_day.available is True
assert same_day.delta == 10
assert same_day.per_day is None
assert same_day.gap_days == 0
assert abs(same_day.rate - 10 / 90) < 1e-12
from_zero_same_day = compute_growth(50, 0, 0, allow_zero_gap=True)
assert from_zero_same_day.delta == 50
assert from_zero_same_day.rate == 50.0
assert from_zero_same_day.per_day is None
growth = compute_growth(1000, 800, 7) growth = compute_growth(1000, 800, 7)
assert growth.available is True assert growth.available is True
assert growth.delta == 200 assert growth.delta == 200
@@ -149,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
@@ -239,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()
+1 -1
View File
@@ -51,7 +51,7 @@ def test_init_and_status():
path = write_config(tmp) path = write_config(tmp)
code, output = run_cli(["init", "--config", str(path)]) code, output = run_cli(["init", "--config", str(path)])
assert code == 0 assert code == 0
assert '"schema_version": 7' in output assert '"schema_version": 8' in output
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
+4
View File
@@ -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 == 1000
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": 2000,
} }
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 == 2000
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
+4 -4
View File
@@ -24,7 +24,7 @@ def test_write_read_transaction():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db = make_db(tmp) db = make_db(tmp)
try: try:
assert db.schema_version == 7 assert db.schema_version == 8
db.write(lambda c: c.execute("INSERT INTO kv(k, v) VALUES ('a', '1')")) 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" 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) db2 = make_db(tmp)
try: try:
assert db2.schema_version == 7 assert db2.schema_version == 8
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 7 assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 8
finally: finally:
db2.close() db2.close()
@@ -123,7 +123,7 @@ def test_migration_backup_on_existing_db():
db = Database(path, backup_dir=Path(tmp) / "backup") db = Database(path, backup_dir=Path(tmp) / "backup")
try: try:
applied = db.initialize() applied = db.initialize()
assert applied == [1, 2, 3, 4, 5, 6, 7] assert applied == [1, 2, 3, 4, 5, 6, 7, 8]
assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1 assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1
finally: finally:
db.close() db.close()
+46 -8
View File
@@ -38,8 +38,8 @@ def test_fresh_and_idempotent():
conn = sqlite3.connect(Path(tmp) / "app.db") conn = sqlite3.connect(Path(tmp) / "app.db")
try: try:
applied = run_migrations(conn) applied = run_migrations(conn)
assert applied == [1, 2, 3, 4, 5, 6, 7] assert applied == [1, 2, 3, 4, 5, 6, 7, 8]
assert current_version(conn) == 7 assert current_version(conn) == 8
assert EXPECTED_TABLES <= table_names(conn) assert EXPECTED_TABLES <= table_names(conn)
assert "schema_version" in table_names(conn) assert "schema_version" in table_names(conn)
columns = { columns = {
@@ -70,10 +70,14 @@ def test_fresh_and_idempotent():
assert { assert {
"stat_date", "ip", "path", "hits", "first_at", "last_at", "stat_date", "ip", "path", "hits", "first_at", "last_at",
} <= visit_columns } <= visit_columns
snapshot_columns = {
row[1] for row in conn.execute("PRAGMA table_info(snapshot)")
}
assert {"view_open", "open_captured_at"} <= snapshot_columns
# 再跑一次不应重复应用 # 再跑一次不应重复应用
assert run_migrations(conn) == [] assert run_migrations(conn) == []
row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone() row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()
assert row[0] == 7 assert row[0] == 8
finally: finally:
conn.close() conn.close()
@@ -88,15 +92,15 @@ def test_backup_before_upgrade():
conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')") conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')")
conn.commit() conn.commit()
v8 = Migration(8, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",)) v9 = Migration(9, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
applied = run_migrations( applied = run_migrations(
conn, conn,
backup_dir=backup_dir, backup_dir=backup_dir,
now=1_800_000_000, now=1_800_000_000,
migrations=MIGRATIONS + (v8,), migrations=MIGRATIONS + (v9,),
) )
assert applied == [8] assert applied == [9]
backups = list(backup_dir.glob("app-pre-v7-*.db")) backups = list(backup_dir.glob("app-pre-v8-*.db"))
assert len(backups) == 1 assert len(backups) == 1
check = sqlite3.connect(backups[0]) check = sqlite3.connect(backups[0])
try: try:
@@ -181,13 +185,46 @@ def test_v5_normalizes_unfaved():
) )
conn.commit() conn.commit()
applied = run_migrations(conn) applied = run_migrations(conn)
assert applied == [5, 6, 7] assert applied == [5, 6, 7, 8]
rows = dict(conn.execute("SELECT aid, status FROM video").fetchall()) rows = dict(conn.execute("SELECT aid, status FROM video").fetchall())
assert rows == {1: "active", 2: "dropped"} assert rows == {1: "active", 2: "dropped"}
finally: finally:
conn.close() conn.close()
def test_v8_backfills_view_open():
with tempfile.TemporaryDirectory() as tmp:
conn = sqlite3.connect(Path(tmp) / "app.db")
try:
run_migrations(conn, migrations=MIGRATIONS[:7])
conn.execute(
"INSERT INTO video(aid, title, pubdate, status, first_seen) "
"VALUES (1, 't', 1, 'active', 1)"
)
conn.execute(
'INSERT INTO snapshot(stat_date, aid, "view", captured_at, ok) '
"VALUES ('2026-10-31', 1, 500, 100, 1)"
)
conn.execute(
'INSERT INTO snapshot(stat_date, aid, "view", captured_at, ok) '
"VALUES ('2026-10-30', 1, NULL, 90, 0)"
)
conn.commit()
assert run_migrations(conn) == [8]
ok_row = conn.execute(
"SELECT view_open, open_captured_at FROM snapshot "
"WHERE stat_date='2026-10-31'"
).fetchone()
assert ok_row[0] == 500 and ok_row[1] == 100
fail_row = conn.execute(
"SELECT view_open, open_captured_at FROM snapshot "
"WHERE stat_date='2026-10-30'"
).fetchone()
assert fail_row[0] is None and fail_row[1] is None
finally:
conn.close()
def main(): def main():
test_fresh_and_idempotent() test_fresh_and_idempotent()
test_backup_before_upgrade() test_backup_before_upgrade()
@@ -195,6 +232,7 @@ def main():
test_prune_backups() test_prune_backups()
test_validate_migration_order() test_validate_migration_order()
test_v5_normalizes_unfaved() test_v5_normalizes_unfaved()
test_v8_backfills_view_open()
print("OK: SQLite 迁移、备份与回滚测试全部通过") print("OK: SQLite 迁移、备份与回滚测试全部通过")
+54
View File
@@ -296,6 +296,59 @@ def test_pending_snapshot_aids():
db.close() db.close()
def test_upsert_snapshot_preserves_view_open():
with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp)
try:
metrics = {
"view": 10, "like": 1, "favorite": 0, "coin": 0,
"share": 0, "reply": 0, "danmaku": 0,
}
repo.upsert_snapshot(
"2026-10-31", 1, metrics=metrics, ok=True, err=None, captured_at=10,
)
first = repo.get_snapshot("2026-10-31", 1)
assert first["view"] == 10
assert first["view_open"] == 10
assert first["open_captured_at"] == 10
metrics["view"] = 40
repo.upsert_snapshot(
"2026-10-31", 1, metrics=metrics, ok=True, err=None, captured_at=20,
)
second = repo.get_snapshot("2026-10-31", 1)
assert second["view"] == 40
assert second["view_open"] == 10
assert second["open_captured_at"] == 10
assert second["captured_at"] == 20
repo.upsert_snapshot(
"2026-10-31", 2, metrics=None, ok=False, err="x", captured_at=1,
)
failed = repo.get_snapshot("2026-10-31", 2)
assert failed["view_open"] is None
metrics["view"] = 7
repo.upsert_snapshot(
"2026-10-31", 2, metrics=metrics, ok=True, err=None, captured_at=5,
)
recovered = repo.get_snapshot("2026-10-31", 2)
assert recovered["view"] == 7
assert recovered["view_open"] == 7
assert recovered["open_captured_at"] == 5
copied = repo.copy_ok_snapshots(
"2026-10-31", "2026-11-01", captured_at=30, src="seed",
)
assert copied == 2
seeded = repo.get_snapshot("2026-11-01", 1)
assert seeded["view"] == 40
assert seeded["view_open"] == 40
assert seeded["open_captured_at"] == 30
assert seeded["src"] == "seed"
finally:
db.close()
def test_up_follower_snapshot_and_backfill(): def test_up_follower_snapshot_and_backfill():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, repo = setup(tmp) db, repo = setup(tmp)
@@ -367,6 +420,7 @@ def main():
test_primary_folder_is_order_independent() test_primary_folder_is_order_independent()
test_dropped_invalid_preserves_original_title() test_dropped_invalid_preserves_original_title()
test_pending_snapshot_aids() test_pending_snapshot_aids()
test_upsert_snapshot_preserves_view_open()
test_up_follower_snapshot_and_backfill() test_up_follower_snapshot_and_backfill()
print("OK: 收藏夹/视频状态机与黑名单测试全部通过") print("OK: 收藏夹/视频状态机与黑名单测试全部通过")
+11 -3
View File
@@ -444,19 +444,22 @@ def test_run_recent_videos_refreshes_without_completion_markers():
1: view_for(1, view=111), 1: view_for(1, view=111),
2: view_for(2, view=222), 2: view_for(2, view=222),
3: view_for(3, view=333), 3: view_for(3, view=333),
4: view_for(4, view=444),
}) })
db, repo, service = make_service(tmp, transport, track_fans=()) db, repo, service = make_service(tmp, transport, track_fans=())
try: try:
# 1/2 在墙钟近 2 天内;3 为旧片 # 1/2 在墙钟近 2 天内;3 为旧片;4 发布已久但当天入库,不进小时任务
add_video(repo, 1) add_video(repo, 1)
add_video(repo, 2) add_video(repo, 2)
add_video(repo, 3) add_video(repo, 3)
add_video(repo, 4)
repo.db.write(lambda conn: conn.executemany( repo.db.write(lambda conn: conn.executemany(
"UPDATE video SET pubdate = ? WHERE aid = ?", "UPDATE video SET pubdate = ? WHERE aid = ?",
[ [
(clock - 3600, 1), (clock - 3600, 1),
(clock - 86400, 2), (clock - 86400, 2),
(clock - 10 * 86400, 3), (clock - 10 * 86400, 3),
(clock - 10 * 86400, 4),
], ],
)) ))
# 预置同日 ok=1,验证小时任务可覆盖刷新 # 预置同日 ok=1,验证小时任务可覆盖刷新
@@ -477,23 +480,27 @@ def test_run_recent_videos_refreshes_without_completion_markers():
)) == {1, 2} )) == {1, 2}
row = repo.db.read(lambda conn: dict(conn.execute( row = repo.db.read(lambda conn: dict(conn.execute(
"SELECT view, ok, captured_at FROM snapshot WHERE stat_date=? AND aid=1", "SELECT view, ok, captured_at, view_open FROM snapshot "
"WHERE stat_date=? AND aid=1",
(DATE,), (DATE,),
).fetchone())) ).fetchone()))
assert row["view"] == 111 assert row["view"] == 111
assert row["ok"] == 1 assert row["ok"] == 1
assert row["captured_at"] == clock assert row["captured_at"] == clock
assert row["view_open"] == 1
# 完成日 / 备份标记保持原样 # 完成日 / 备份标记保持原样
assert repo.kv_get(service.completed_key(DATE)) is None assert repo.kv_get(service.completed_key(DATE)) is None
assert repo.kv_get("snapshot:last_completed") is None assert repo.kv_get("snapshot:last_completed") is None
assert repo.kv_get(service.backup_key(DATE)) is None assert repo.kv_get(service.backup_key(DATE)) is None
assert transport.count("/web-interface/view") == 2 assert transport.count("/web-interface/view") == 2
assert repo.get_snapshot(DATE, 4) is None
# 全量任务随后才写完成日;且已 ok=1 的跳过 # 全量任务随后才写完成日;且已 ok=1 的跳过
full = service.run_videos(date=DATE) full = service.run_videos(date=DATE)
assert full.skipped >= 2 # 1、2 已被小时任务写过 assert full.skipped >= 2 # 1、2 已被小时任务写过
assert full.success >= 1 # 至少旧片 3 assert full.success >= 1 # 至少旧片 3、4
assert repo.get_snapshot(DATE, 4) is not None
assert repo.kv_get(service.completed_key(DATE)) == "1" assert repo.kv_get(service.completed_key(DATE)) == "1"
assert repo.kv_get("snapshot:last_completed") == DATE assert repo.kv_get("snapshot:last_completed") == DATE
finally: finally:
@@ -562,6 +569,7 @@ def test_seed_from_last_completed_does_not_mark_completed():
row = repo.get_snapshot(today, 1) row = repo.get_snapshot(today, 1)
assert row is not None assert row is not None
assert int(row["view"]) == 10 assert int(row["view"]) == 10
assert int(row["view_open"]) == 10
assert row["src"] == "seed" assert row["src"] == "seed"
fan = repo.get_up_stat(today, 37754047) fan = repo.get_up_stat(today, 37754047)
assert fan is not None and int(fan["follower"]) == 100 assert fan is not None and int(fan["follower"]) == 100
-194
View File
@@ -1,194 +0,0 @@
# -*- coding: utf-8 -*-
"""config.json → jsonc + secrets 拆分脚本测试(离线):
uv run python tests/test_split_config_secrets.py"""
from __future__ import annotations
import json
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board import jsonc # noqa: E402
from reaction_board.config import load_config # noqa: E402
from tools.split_config_secrets import ( # noqa: E402
split_config_file,
split_raw,
)
def _monolith() -> dict:
return {
"bilibili": {
"read": {"mid": 11, "csrf": "read-csrf", "cookie": "SESSDATA=secret-read"},
"write": {"mid": 22, "csrf": "write-csrf", "cookie": "SESSDATA=secret-write"},
},
"telegram": {
"enabled": True,
"bot_token": "tg-secret-token",
"chat_id": 12345,
},
"web": {
"host": "127.0.0.1",
"port": 8080,
"auth_token": "web-secret-token",
"public_base_url": "",
},
"folders": [{"year": 2026, "media_id": 1, "title": "t"}],
"track_fans": [37754047],
"up_blacklist_seed": [],
}
def test_split_raw_extracts_secrets_keeps_business():
business, secrets, stats = split_raw(_monolith())
assert "bilibili" not in business
assert business["telegram"] == {"enabled": True}
assert business["web"]["host"] == "127.0.0.1"
assert business["web"]["port"] == 8080
assert "auth_token" not in business["web"]
assert secrets["bilibili"]["read"]["mid"] == 11
assert secrets["telegram"] == {"bot_token": "tg-secret-token", "chat_id": 12345}
assert secrets["web"] == {"auth_token": "web-secret-token"}
assert "bilibili" in stats["moved_paths"]
assert "telegram.bot_token" in stats["moved_paths"]
assert "web.auth_token" in stats["moved_paths"]
def test_split_raw_omits_absent_secret_stubs():
raw = {
"telegram": {"enabled": False},
"web": {"host": "0.0.0.0", "port": 9},
"folders": [],
}
business, secrets, stats = split_raw(raw)
assert secrets == {}
assert business["telegram"] == {"enabled": False}
assert business["web"]["host"] == "0.0.0.0"
assert stats["moved_count"] == 0
def test_dry_run_writes_nothing():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = root / "config.json"
src.write_text(json.dumps(_monolith(), ensure_ascii=False), encoding="utf-8")
summary = split_config_file(src, dry_run=True)
assert summary["dry_run"] is True
assert summary["moved_count"] >= 3
assert not (root / "config.jsonc").exists()
assert not (root / "secrets.json").exists()
assert src.exists()
# 摘要不得泄露 cookie/token
blob = json.dumps(summary, ensure_ascii=False)
assert "SESSDATA" not in blob
assert "tg-secret-token" not in blob
assert "web-secret-token" not in blob
def test_refuses_overwrite_without_force():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = root / "config.json"
src.write_text(json.dumps(_monolith(), ensure_ascii=False), encoding="utf-8")
(root / "config.jsonc").write_text("// existing\n{}\n", encoding="utf-8")
try:
split_config_file(src, keep_src=True)
except FileExistsError as exc:
assert "config.jsonc" in str(exc)
assert "--force" in str(exc)
else:
raise AssertionError("已有输出时无 --force 必须失败")
assert src.exists()
assert "existing" in (root / "config.jsonc").read_text(encoding="utf-8")
def test_writes_and_renames_src_by_default():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = root / "config.json"
src.write_text(json.dumps(_monolith(), ensure_ascii=False), encoding="utf-8")
summary = split_config_file(src)
cfg_path = root / "config.jsonc"
sec_path = root / "secrets.json"
bak = root / "config.json.bak"
assert summary["ok"] is True
assert not src.exists()
assert bak.exists()
assert cfg_path.exists()
assert sec_path.exists()
text = cfg_path.read_text(encoding="utf-8")
assert text.startswith("//")
assert "secrets.json" in text
business = jsonc.load(cfg_path)
secrets = json.loads(sec_path.read_text(encoding="utf-8"))
assert "bilibili" not in business
assert business["telegram"]["enabled"] is True
assert "bot_token" not in business["telegram"]
assert business["web"]["host"] == "127.0.0.1"
assert "auth_token" not in business["web"]
assert secrets["bilibili"]["read"]["mid"] == 11
assert secrets["telegram"]["chat_id"] == 12345
assert secrets["web"]["auth_token"] == "web-secret-token"
loaded = load_config(cfg_path, env={}, require_auth=False)
assert loaded.bilibili.read.mid == 11
assert loaded.bilibili.write.mid == 22
assert loaded.telegram.enabled is True
assert loaded.telegram.bot_token == "tg-secret-token"
assert loaded.web.host == "127.0.0.1"
assert loaded.web.auth_token == "web-secret-token"
assert summary["verify"]["checked"] is True
assert summary["verify"]["read_mid"] == 11
def test_force_overwrite_and_keep_src():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = root / "config.json"
src.write_text(json.dumps(_monolith(), ensure_ascii=False), encoding="utf-8")
(root / "config.jsonc").write_text("{}\n", encoding="utf-8")
(root / "secrets.json").write_text("{}\n", encoding="utf-8")
summary = split_config_file(src, force=True, keep_src=True)
assert src.exists()
assert summary["src_renamed"] is False
secrets = json.loads((root / "secrets.json").read_text(encoding="utf-8"))
assert secrets["bilibili"]["read"]["mid"] == 11
def test_honors_secrets_path_relative():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
src = root / "config.json"
data = _monolith()
data["secrets_path"] = "creds/my-secrets.json"
src.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
summary = split_config_file(src, keep_src=True)
sec = root / "creds" / "my-secrets.json"
assert Path(summary["secrets_out"]) == sec
assert sec.exists()
business = jsonc.load(root / "config.jsonc")
assert business.get("secrets_path") == "creds/my-secrets.json"
def main():
test_split_raw_extracts_secrets_keeps_business()
test_split_raw_omits_absent_secret_stubs()
test_dry_run_writes_nothing()
test_refuses_overwrite_without_force()
test_writes_and_renames_src_by_default()
test_force_overwrite_and_keep_src()
test_honors_secrets_path_relative()
print(
"OK: split_config_secrets 抽取 bilibili/telegram/web 凭据、"
"dry-run、拒绝覆盖、改名与 secrets_path 测试全部通过"
)
if __name__ == "__main__":
main()
+192 -6
View File
@@ -15,7 +15,7 @@ from reaction_board.repository import Repository # noqa: E402
from reaction_board.timeutil import day_bounds # noqa: E402 from reaction_board.timeutil import day_bounds # noqa: E402
from reaction_board.web.cache import DASHBOARD_CACHE # noqa: E402 from reaction_board.web.cache import DASHBOARD_CACHE # noqa: E402
from reaction_board.web.queries import DashboardQueries # noqa: E402 from reaction_board.web.queries import DashboardQueries # noqa: E402
from tests.web_fixtures import DATES, build_db, write_config # noqa: E402 from tests.web_fixtures import DATES, FOLDER, build_db, resource, write_config # noqa: E402
FIXED_NOW = 1_793_459_000 # 2026-10-31 前后 FIXED_NOW = 1_793_459_000 # 2026-10-31 前后
@@ -46,7 +46,7 @@ def test_overview():
assert overview["counts"]["candidates_pending"] == 1 assert overview["counts"]["candidates_pending"] == 1
assert overview["totals"]["view"] == 1200 + 5200 + 120 assert overview["totals"]["view"] == 1200 + 5200 + 120
assert overview["interactions"] > 0 assert overview["interactions"] > 0
assert overview["view_delta"] == (100 + 100 + 0) # 101/102 的日增量 assert overview["view_delta"] == (100 + 100 + 120) # 101/102 跨日 + 103 当日发布
assert overview["snapshot_health"]["total"] == 3 assert overview["snapshot_health"]["total"] == 3
assert overview["folders"][0]["media_count"] == 3 assert overview["folders"][0]["media_count"] == 3
assert overview["folders"][0]["ratio"] < 1 assert overview["folders"][0]["ratio"] < 1
@@ -484,7 +484,7 @@ def test_about_payload():
assert about["snapshot_days"] == 3 assert about["snapshot_days"] == 3
assert about["snapshot_first_date"] == DATES[0] assert about["snapshot_first_date"] == DATES[0]
assert about["fans_start_date"] == DATES[0] assert about["fans_start_date"] == DATES[0]
assert about["schema_version"] == 7 assert about["schema_version"] == 8
assert about["analytics"] == { assert about["analytics"] == {
"min_duration_seconds": 600, "min_duration_seconds": 600,
"maturity_days": 2, "maturity_days": 2,
@@ -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": 1000,
} }
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
@@ -577,9 +578,13 @@ def test_growth_auto_falls_back_to_one_day():
assert auto["available"] is True assert auto["available"] is True
assert auto["growth_days"] == 1 assert auto["growth_days"] == 1
assert auto["days"] == 1 assert auto["days"] == 1
assert {item["aid"] for item in auto["items"]} == {101, 102} assert {item["aid"] for item in auto["items"]} == {101, 102, 103}
first = next(item for item in auto["items"] if item["aid"] == 101) first = next(item for item in auto["items"] if item["aid"] == 101)
assert first["growth"]["delta"] == 100 # 相邻日 assert first["growth"]["delta"] == 100 # 相邻日
assert first["growth"]["baseline"] == "snapshot"
today = next(item for item in auto["items"] if item["aid"] == 103)
assert today["growth"]["baseline"] == "zero"
assert today["growth"]["delta"] == 120
forced7 = q.list_videos(sort="growth", growth_days=7) forced7 = q.list_videos(sort="growth", growth_days=7)
assert forced7["available"] is False assert forced7["available"] is False
@@ -667,9 +672,10 @@ def test_overview_adjacent_previous_with_seed_and_dual_fields():
+ overview["counts"]["unfaved"] + overview["counts"]["unfaved"]
+ overview["counts"]["removed"] + overview["counts"]["removed"]
) )
# 当前=seed(=完成日数值),相邻 previous=完成日本身 → 增量 0 # 101/102:当前=seed(=完成日数值),相邻 previous=完成日本身 → 增量 0
# 103:当日发布,近 1 日相对 0,计入当前播放
assert overview["view_delta_available"] is True assert overview["view_delta_available"] is True
assert overview["view_delta"] == 0 assert overview["view_delta"] == 120
growth = q.list_videos(sort="growth", growth_days=1) growth = q.list_videos(sort="growth", growth_days=1)
assert growth["available"] is True assert growth["available"] is True
@@ -678,9 +684,186 @@ def test_overview_adjacent_previous_with_seed_and_dual_fields():
assert item["growth"]["available"] is True assert item["growth"]["available"] is True
assert item["growth"]["delta"] == 0 assert item["growth"]["delta"] == 0
assert item["growth"]["gap_days"] == 1 assert item["growth"]["gap_days"] == 1
assert item["growth"]["baseline"] == "snapshot"
finally: finally:
db.close() db.close()
def _view_metrics(view: int) -> dict[str, int]:
return {
"view": view, "like": 0, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0,
}
def test_same_day_growth_baselines():
"""近 1 日:当日发布相对 0;更早发布用 view_open;7 日不用这两条退路。"""
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
repo = Repository(db)
today = DATES[-1]
start = int(day_bounds(today)[0])
old_pub = int(day_bounds("2026-09-01")[0])
repo.upsert_snapshot(
today, 103, metrics=_view_metrics(200),
ok=True, err=None, captured_at=start + 3600,
)
repo.upsert_from_fav(
resource(105, up_mid=1, up_name="UP A", pubdate=old_pub),
FOLDER.media_id, was_added=True, now=start + 10,
)
repo.upsert_snapshot(
today, 105, metrics=_view_metrics(500),
ok=True, err=None, captured_at=start + 10,
)
repo.upsert_snapshot(
today, 105, metrics=_view_metrics(800),
ok=True, err=None, captured_at=start + 20,
)
repo.upsert_from_fav(
resource(106, up_mid=1, up_name="UP A", pubdate=old_pub),
FOLDER.media_id, was_added=True, now=start + 30,
)
repo.upsert_snapshot(
today, 106, metrics=_view_metrics(900),
ok=True, err=None, captured_at=start + 30,
)
repo.upsert_from_fav(
resource(107, up_mid=1, up_name="UP A", pubdate=old_pub),
FOLDER.media_id, was_added=True, now=start + 40,
)
repo.upsert_snapshot(
today, 107, metrics=_view_metrics(50),
ok=True, err=None, captured_at=start + 40,
)
db.write(lambda conn: conn.execute(
"UPDATE video SET pubdate = 0 WHERE aid = 107"
))
DASHBOARD_CACHE.clear()
one = q.list_videos(sort="growth", growth_days=1, page_size=20)
by_aid = {item["aid"]: item for item in one["items"]}
assert 103 in by_aid and 105 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"]["delta"] == 200
assert by_aid[103]["growth"]["rate"] is None
assert by_aid[103]["growth"]["gap_days"] == 0
assert by_aid[105]["growth"]["baseline"] == "open"
assert by_aid[105]["growth"]["delta"] == 300
assert by_aid[101]["growth"]["baseline"] == "snapshot"
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 and 105 not in rate_aids
assert 101 in rate_aids
seven = q.list_videos(sort="growth", growth_days=7)
seven_aids = {item["aid"] for item in seven["items"]}
assert 103 not in seven_aids
assert 105 not in seven_aids
detail103 = q.video_detail(103)
assert detail103["growth"]["baseline"] == "zero"
assert detail103["growth"]["delta"] == 200
detail105 = q.video_detail(105)
assert detail105["growth"]["baseline"] == "open"
assert detail105["growth"]["delta"] == 300
assert q.video_detail(106)["growth"]["available"] is False
assert q.video_detail(107)["growth"]["available"] is False
overview = q.overview()
assert overview["view_delta"] == 100 + 100 + 200 + 300
finally:
db.close()
def test_growth_rate_sort_uses_prior():
"""基线不足 1000 不进增速榜;达标后按基线 + 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])
# 301:80→512,真实 +540%,基线低于 1000,只留在增量榜。
# 304:1000→2500,真实增速高于 302,平滑后应排在 302 后面。
for aid, views in ((301, (80, 512)), (302, (5000, 10000)), (304, (1000, 2500))):
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"] == 1000
assert 301 not in order
assert order.index(302) < order.index(304)
modest = next(item for item in ranked["items"] if item["aid"] == 304)
assert abs(modest["growth"]["rate"] - 1500 / 1000) < 1e-12
growth = q.list_videos(sort="growth", growth_days=1, page_size=20)
grown = next(item for item in growth["items"] if item["aid"] == 301)
assert grown["growth"]["delta"] == 432
assert abs(grown["growth"]["rate"] - 432 / 80) < 1e-12
finally:
db.close()
def test_system_tasks_sorted_by_started_at_desc():
"""最近任务按 started_at 倒序;缺开始时间的排末尾(含进行中无 finished)。"""
import json
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
repo = Repository(db)
# 故意打乱写入/键名字母序;finished_at 与 started_at 错开,避免误用完成时间
samples = (
# name, started_at, finished_at
("zzz_old", FIXED_NOW - 300, FIXED_NOW - 10), # 开始最早,完成却很晚
("aaa_mid", FIXED_NOW - 100, FIXED_NOW - 90),
("mmm_new", FIXED_NOW - 10, None), # 进行中:只有 started
("no_time", None, FIXED_NOW), # 缺 started,应末尾
)
for name, started, finished in samples:
payload = {
"name": name,
"ok": True if finished is not None else True,
"error": None,
"summary": {},
}
if started is not None:
payload["started_at"] = started
if finished is not None:
payload["finished_at"] = finished
repo.kv_set(f"task:last:{name}", json.dumps(payload))
for legacy in ("snapshot", "fans", "discovery"):
repo.kv_delete(f"task:last:{legacy}")
DASHBOARD_CACHE.clear()
system = q.system()
assert list(system["tasks"].keys()) == [
"mmm_new", "aaa_mid", "zzz_old", "no_time",
]
overview = q.overview()
assert list(overview["tasks"].keys()) == list(system["tasks"].keys())
finally:
db.close()
def main_test(): def main_test():
test_overview() test_overview()
test_video_listing_filters_and_sorting() test_video_listing_filters_and_sorting()
@@ -696,9 +879,12 @@ def main_test():
test_fans_candidates_system_and_export() test_fans_candidates_system_and_export()
test_system_status_uses_completed_date_and_running_flags() test_system_status_uses_completed_date_and_running_flags()
test_system_next_runs_follow_last_completion_not_wall_clock() test_system_next_runs_follow_last_completion_not_wall_clock()
test_system_tasks_sorted_by_started_at_desc()
test_growth_auto_falls_back_to_one_day() test_growth_auto_falls_back_to_one_day()
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_growth_rate_sort_uses_prior()
print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统/访问统计测试全部通过") print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统/访问统计测试全部通过")
+1 -1
View File
@@ -75,7 +75,7 @@ def test_readonly_routes_and_static():
assert status == 200 and payload["ok"] is True assert status == 200 and payload["ok"] is True
status, _, payload = running.json("/api/about") status, _, payload = running.json("/api/about")
assert status == 200 assert status == 200
assert payload["data"]["schema_version"] == 7 assert payload["data"]["schema_version"] == 8
assert payload["data"]["analytics"]["min_duration_seconds"] == 600 assert payload["data"]["analytics"]["min_duration_seconds"] == 600
status, _, overview = running.json("/api/overview") status, _, overview = running.json("/api/overview")
+1 -1
View File
@@ -138,7 +138,7 @@ def build_db(tmp: str | Path) -> Path:
day, 37754047, follower=1000 + index * 10, following=5, day, 37754047, follower=1000 + index * 10, following=5,
ok=True, captured_at=ts(day) + 60, ok=True, captured_at=ts(day) + 60,
) )
# 103 只有最后一天快照,用于验证样本不足;104 另加一条失败快照 # 103 只有最后一天快照(发布日=快照日,近 1 日相对 0);104 另加一条失败快照
repo.upsert_snapshot( repo.upsert_snapshot(
DATES[-1], 103, DATES[-1], 103,
metrics={"view": 120, "like": 5, "coin": 1, "favorite": 2, "share": 0, "reply": 1, "danmaku": 1}, metrics={"view": 120, "like": 5, "coin": 1, "favorite": 2, "share": 0, "reply": 1, "danmaku": 1},
+4 -2
View File
@@ -237,8 +237,8 @@ def _insert_snapshot(
""" """
INSERT OR REPLACE INTO snapshot( INSERT OR REPLACE INTO snapshot(
stat_date, aid, "view", "like", favorite, coin, share, reply, stat_date, aid, "view", "like", favorite, coin, share, reply,
danmaku, captured_at, ok, err, src danmaku, captured_at, ok, err, src, view_open, open_captured_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'preview') ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'preview', ?, ?)
""", """,
( (
stat_date, stat_date,
@@ -253,6 +253,8 @@ def _insert_snapshot(
int(captured_at), int(captured_at),
1 if ok else 0, 1 if ok else 0,
err, err,
values.get("view") if ok else None,
int(captured_at) if ok else None,
), ),
) )
-326
View File
@@ -1,326 +0,0 @@
# -*- coding: utf-8 -*-
"""将旧版单体 ``config.json`` 拆成 ``config.jsonc`` + ``secrets.json``。
从源文件抽出凭据段:
- 整段 ``bilibili``(若存在)
- ``telegram.bot_token`` / ``telegram.chat_id``(``enabled`` 等非密钥留在业务文件)
- ``web.auth_token``(``host`` / ``port`` / ``track`` 等留在业务文件)
源里没有的密钥键不会写空壳。``telegram`` / ``web`` 抽空后若无剩余键,则不写入
secrets;业务侧若还有非密钥键则保留对应段。``bilibili`` 整段移出业务文件。
用法::
# 先看会挪哪些键(不打印 cookie/token 原文)
uv run python tools/split_config_secrets.py --dry-run
# 默认:写 config.jsonc + secrets.json,并把源文件改名为 config.json.bak
uv run python tools/split_config_secrets.py /path/to/config.json
# 已有输出时需显式覆盖;保留源文件不改名
uv run python tools/split_config_secrets.py --force --keep-src
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from reaction_board import jsonc # noqa: E402
from reaction_board.config import load_config, resolve_secrets_path # noqa: E402
TELEGRAM_SECRET_KEYS = ("bot_token", "chat_id")
WEB_SECRET_KEYS = ("auth_token",)
CONFIG_HEADER = """\
// 业务配置(可含注释与尾逗号)。敏感凭据在同目录 secrets.json。
// `python app.py auth` 只回写 secrets.json,不会冲掉本文件注释。
// 本文件由 tools/split_config_secrets.py 从旧版单体配置拆出。
"""
def _is_mapping(value: Any) -> bool:
return isinstance(value, Mapping) and not isinstance(value, (str, bytes))
def split_raw(raw: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
"""拆分业务配置与凭据。
返回 ``(business, secrets, stats)``。``stats`` 仅含键路径计数,不含密钥值。
"""
business: dict[str, Any] = copy.deepcopy(dict(raw))
secrets: dict[str, Any] = {}
moved: list[str] = []
bilibili = business.pop("bilibili", None)
if _is_mapping(bilibili):
secrets["bilibili"] = copy.deepcopy(dict(bilibili))
moved.append("bilibili")
elif bilibili is not None:
# 非法类型原样丢掉业务侧,避免残留;不写入 secrets
moved.append("bilibili(non-object-dropped)")
telegram = business.get("telegram")
if _is_mapping(telegram):
tg_biz = dict(telegram)
tg_sec: dict[str, Any] = {}
for key in TELEGRAM_SECRET_KEYS:
if key in tg_biz:
tg_sec[key] = tg_biz.pop(key)
moved.append(f"telegram.{key}")
if tg_sec:
secrets["telegram"] = tg_sec
if tg_biz:
business["telegram"] = tg_biz
else:
business.pop("telegram", None)
web = business.get("web")
if _is_mapping(web):
web_biz = dict(web)
web_sec: dict[str, Any] = {}
for key in WEB_SECRET_KEYS:
if key in web_biz:
web_sec[key] = web_biz.pop(key)
moved.append(f"web.{key}")
if web_sec:
secrets["web"] = web_sec
if web_biz:
business["web"] = web_biz
else:
business.pop("web", None)
stats = {
"moved_paths": moved,
"moved_count": len(moved),
"business_top_keys": sorted(business.keys()),
"secrets_top_keys": sorted(secrets.keys()),
"has_bilibili": "bilibili" in secrets,
"telegram_secret_keys": sorted(secrets.get("telegram", {}).keys())
if isinstance(secrets.get("telegram"), dict)
else [],
"web_secret_keys": sorted(secrets.get("web", {}).keys())
if isinstance(secrets.get("web"), dict)
else [],
}
return business, secrets, stats
def _atomic_write_json(path: Path, data: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
with tmp.open("w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
fh.write("\n")
os.replace(tmp, path)
def _atomic_write_jsonc(path: Path, data: Mapping[str, Any], *, header: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
body = json.dumps(data, ensure_ascii=False, indent=2)
if not body.endswith("\n"):
body += "\n"
text = header.rstrip() + "\n" + body
tmp = path.with_name(path.name + ".tmp")
with tmp.open("w", encoding="utf-8") as fh:
fh.write(text)
os.replace(tmp, path)
def resolve_outputs(
src: Path,
raw: Mapping[str, Any],
*,
config_out: Path | None,
secrets_out: Path | None,
) -> tuple[Path, Path]:
"""解析输出路径;``secrets_path`` 相对源文件目录。"""
base = src.resolve().parent
cfg_path = config_out if config_out is not None else (base / "config.jsonc")
if secrets_out is not None:
sec_path = secrets_out
else:
sec_path = resolve_secrets_path(src, raw)
return cfg_path, sec_path
def split_config_file(
src: Path,
*,
config_out: Path | None = None,
secrets_out: Path | None = None,
dry_run: bool = False,
force: bool = False,
keep_src: bool = False,
) -> dict[str, Any]:
"""执行拆分;返回可 JSON 序列化的摘要(不含密钥原文)。"""
src = Path(src)
if not src.exists():
raise FileNotFoundError(f"源配置不存在: {src}")
try:
raw = jsonc.load(src)
except ValueError as exc:
raise ValueError(f"源配置不是合法 JSON/JSONC: {src}: {exc}") from exc
if not isinstance(raw, Mapping):
raise ValueError(f"源配置顶层必须是对象: {src}")
business, secrets, stats = split_raw(raw)
cfg_path, sec_path = resolve_outputs(
src, raw, config_out=config_out, secrets_out=secrets_out
)
summary: dict[str, Any] = {
"ok": True,
"dry_run": bool(dry_run),
"src": str(src),
"config_out": str(cfg_path),
"secrets_out": str(sec_path),
"keep_src": bool(keep_src),
"src_bak": None if keep_src else str(src.with_name(src.name + ".bak")),
**stats,
}
if dry_run:
return summary
conflicts = [p for p in (cfg_path, sec_path) if p.exists()]
if conflicts and not force:
names = ", ".join(str(p) for p in conflicts)
raise FileExistsError(
f"输出已存在(需 --force 覆盖): {names}"
)
_atomic_write_jsonc(cfg_path, business, header=CONFIG_HEADER)
_atomic_write_json(sec_path, secrets)
# 可选校验:合并加载后 mid 与源一致(不打印 cookie)
verify: dict[str, Any] = {"checked": False}
if secrets.get("bilibili") or raw.get("bilibili"):
cfg = load_config(cfg_path, env={}, require_auth=False)
src_bili = raw.get("bilibili") if _is_mapping(raw.get("bilibili")) else {}
read_mid = None
write_mid = None
if _is_mapping(src_bili.get("read")):
read_mid = src_bili["read"].get("mid")
if _is_mapping(src_bili.get("write")):
write_mid = src_bili["write"].get("mid")
if read_mid is not None and int(cfg.bilibili.read.mid) != int(read_mid):
raise RuntimeError(
f"校验失败: bilibili.read.mid {cfg.bilibili.read.mid} != {read_mid}"
)
if write_mid is not None and int(cfg.bilibili.write.mid) != int(write_mid):
raise RuntimeError(
f"校验失败: bilibili.write.mid {cfg.bilibili.write.mid} != {write_mid}"
)
verify = {
"checked": True,
"read_mid": int(cfg.bilibili.read.mid),
"write_mid": int(cfg.bilibili.write.mid),
}
summary["verify"] = verify
if not keep_src:
bak = src.with_name(src.name + ".bak")
if bak.exists() and not force:
raise FileExistsError(
f"备份已存在(需 --force 覆盖): {bak}"
)
if bak.exists():
bak.unlink()
os.replace(src, bak)
summary["src_bak"] = str(bak)
summary["src_renamed"] = True
else:
summary["src_renamed"] = False
return summary
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="拆分旧版 config.json 为 config.jsonc + secrets.json",
)
parser.add_argument(
"src_pos",
nargs="?",
default=None,
help="源配置路径(默认仓库根目录 config.json)",
)
parser.add_argument(
"--src",
default=None,
help="源配置路径(与位置参数二选一;默认 ROOT/config.json)",
)
parser.add_argument(
"--config-out",
default=None,
help="业务 JSONC 输出路径(默认与源同目录 config.jsonc)",
)
parser.add_argument(
"--secrets-out",
default=None,
help="凭据 JSON 输出路径(默认同目录 secrets.json,"
"或源内 secrets_path)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="只打印将挪动的键路径,不写文件、不改名",
)
parser.add_argument(
"--force",
action="store_true",
help="允许覆盖已存在的输出(及 .bak)",
)
parser.add_argument(
"--keep-src",
action="store_true",
help="成功写入后保留源文件;默认改名为 <src>.bak,"
"以便 default_config_path 优先读到新的 config.jsonc",
)
args = parser.parse_args(list(argv) if argv is not None else None)
if args.src_pos and args.src and args.src_pos != args.src:
print(
json.dumps(
{"ok": False, "error": "位置参数与 --src 冲突"},
ensure_ascii=False,
),
file=sys.stderr,
)
return 2
src = Path(args.src_pos or args.src or (ROOT / "config.json"))
config_out = Path(args.config_out) if args.config_out else None
secrets_out = Path(args.secrets_out) if args.secrets_out else None
try:
summary = split_config_file(
src,
config_out=config_out,
secrets_out=secrets_out,
dry_run=args.dry_run,
force=args.force,
keep_src=args.keep_src,
)
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
except (OSError, ValueError, RuntimeError) as exc:
print(
json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False),
file=sys.stderr,
)
return 2
if __name__ == "__main__":
raise SystemExit(main())