Compare commits

Author SHA1 Message Date
bot-ci 77d74c501a tools: add split_config_secrets for config.json → jsonc + secrets 2026-11-02 13:24:27 +08:00
31 changed files with 605 additions and 647 deletions

No files matched your search

+3 -2
View File
@@ -267,6 +267,7 @@ 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` 示例:
@@ -608,7 +609,7 @@ interaction_rate = (like + coin + favorite) / max(view, 1)
- 查询前一有效快照而不是填补缺失日期; - 查询前一有效快照而不是填补缺失日期;
- 缺失日期在图表中断开; - 缺失日期在图表中断开;
- 增速榜要求播放基数至少 1000; - 增速榜要求播放基数至少 200;
- 互动率榜要求播放至少 100; - 互动率榜要求播放至少 100;
- 样本不足两天的视频明确标注,不伪造增长数据。 - 样本不足两天的视频明确标注,不伪造增长数据。
@@ -756,7 +757,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`、`growth_rate_prior`; - `analytics` 配置段:`min_duration_seconds`、`maturity_days`、`min_view_for_ranking`、`video_shrink_minutes`、`shrink_minutes`;
- 主导航拆分:`/videos`(视频)与 `/ups`(UP主)独立;旧 `/rank` 保留兼容; - 主导航拆分:`/videos`(视频)与 `/ups`(UP主)独立;旧 `/rank` 保留兼容;
- 新增 `/about` 数据说明页与 `/api/about`; - 新增 `/about` 数据说明页与 `/api/about`;
- 卡片精简:列表只保留主指标与互动率,明细移到详情页; - 卡片精简:列表只保留主指标与互动率,明细移到详情页;
+7
View File
@@ -314,6 +314,13 @@ 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,6 +108,5 @@
"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,7 +123,5 @@
"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,
}, },
} }
+4 -9
View File
@@ -380,7 +380,7 @@ for folder in 配置的年度夹(2025、2026、…):
- 两个夹共 831 条,一次性导入 `video` 表; - 两个夹共 831 条,一次性导入 `video` 表;
- **历史快照无法回填**:B 站不提供任意日期历史数据,只能从部署当天开始积累; - **历史快照无法回填**:B 站不提供任意日期历史数据,只能从部署当天开始积累;
- **开发联调阶段产生的数据一律保留**(不清库、不重建),正式上线直接续用同一个 `app.db`; - **开发联调阶段产生的数据一律保留**(不清库、不重建),正式上线直接续用同一个 `app.db`;
- 新发现/新入库的视频从"入库当天"开始积累快照。近 1 日:当日发布按 0 起算(一次成功采样即可);更早发布的新入库按入库时播放量起算,第二次采样走晚间全量,不进小时快照。近 7/30 日仍要跨日快照。 - 新发现/新入库的视频从"入库当天"开始积累快照,第一天没有增量是正常的,看板对"仅 1 个数据点"的视频标注"样本不足"。
--- ---
@@ -473,8 +473,6 @@ 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);
@@ -674,26 +672,23 @@ w = {view:0.30, like:0.20, coin:0.20, favorite:0.15,
### 8.2 增长榜 ### 8.2 增长榜
> 看板 UI 中“增长”标注为**播放增量**(区间内绝对增量),“增速”标注为**播放增速**(增量 / 基线播放)。近 7/30 日基线取不早于 `最新快照 - N 天` 的最近一条成功快照。近 1 日:有更早日期快照则用前一日;否则当日发布基准为 0,更早发布的新入库用当天第一次成功采样(`view_open`)。 > 看板 UI 中“增长”标注为**播放增量**(区间内绝对增量),“增速”标注为**播放增速**(增量 / 基线播放)。两者基线都取不早于 `最新快照 - N 天` 的最近一条成功快照。
``` ```
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)` | 基线播放 `>= 1000` | | 近 7 天增速 | `r7(view)` | `view >= 200` |
| 互动率 | `(like+coin+fav)/view` | `view >= 100` | | 互动率 | `(like+coin+fav)/view` | `view >= 100` |
| 单项榜 | 7 项直接排序 | — | | 单项榜 | 7 项直接排序 | — |
| UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — | | UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — |
**冷启动**:近 1 日不要求跨日快照。当日发布相对 0,只进播放增量、不进播放增速;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。 **冷启动**:不足 2 天数据的视频用"发布后 24h 增量",并标注"样本不足";缺失日不插值。
### 8.3 趋势 ### 8.3 趋势
+4 -32
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 = 1000 MIN_VIEW_FOR_GROWTH_RATE = 200
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,20 +313,15 @@ 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))
@@ -334,35 +329,12 @@ def compute_growth(
return Growth( return Growth(
available=True, available=True,
delta=delta, delta=delta,
per_day=None if gap == 0 else delta / gap, per_day=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,7 +52,6 @@ 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] = {
@@ -211,7 +210,6 @@ 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)
@@ -594,11 +592,6 @@ 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,18 +244,6 @@ 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",
),
),
) )
+8 -30
View File
@@ -690,17 +690,12 @@ 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)
view_open, open_captured_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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",
@@ -712,22 +707,12 @@ 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),
view_value, values.get("view"),
values.get("like"), values.get("like"),
values.get("favorite"), values.get("favorite"),
values.get("coin"), values.get("coin"),
@@ -735,11 +720,9 @@ class Repository:
values.get("reply"), values.get("reply"),
values.get("danmaku"), values.get("danmaku"),
int(captured_at), int(captured_at),
ok_flag, 1 if ok else 0,
err, err,
src, src,
open_view,
open_at,
), ),
) )
self.db.write(run) self.db.write(run)
@@ -758,23 +741,18 @@ 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)
+1 -2
View File
@@ -310,8 +310,7 @@ 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()
+21 -158
View File
@@ -20,7 +20,6 @@ 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,
@@ -59,19 +58,6 @@ 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()
@@ -399,18 +385,6 @@ 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,
@@ -432,70 +406,6 @@ 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],
@@ -505,7 +415,6 @@ 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")
@@ -546,9 +455,7 @@ class DashboardQueries:
), ),
} }
if include_growth: if include_growth:
item["growth"] = self._growth_result( item["growth"] = self._growth_for(snapshot, previous).as_dict()
video, snapshot, previous, days_back=int(growth_days or 1),
)
return item return item
# ------------------------------------------------------------ overview # ------------------------------------------------------------ overview
@@ -567,7 +474,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)
# 播放增量:近 1 日。有更早日用相邻成功日;当日发布相对 0;否则用 view_open。 # 播放增量:按片相对当前点的相邻成功日,避免 seed/recent 拉到 T-2。
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:
@@ -582,13 +489,12 @@ 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)
if snapshot is None: prior = previous.get(aid)
if snapshot is None or prior is None:
continue continue
growth = self._growth_result( growth = self._growth_for(snapshot, prior)
video, snapshot, previous.get(aid), days_back=1, if growth.available and growth.delta is not None:
) 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
@@ -716,32 +622,19 @@ 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:%'" "SELECT k, v FROM kv WHERE k LIKE 'task:last:%' ORDER BY k"
).fetchall() ).fetchall()
items: list[tuple[str, Any]] = [] result: dict[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"]}
items.append((name, value)) result[name] = value
items.sort(key=lambda pair: self._task_started_sort_key(pair[1])) return result
# 插入序即开始时间倒序;前端 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(
@@ -975,7 +868,6 @@ 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
] ]
@@ -1047,13 +939,9 @@ class DashboardQueries:
if sort == "growth_rate": if sort == "growth_rate":
items = [ items = [
item for item in items item for item in items
if item.get("growth", {}).get("baseline") != "zero" if (item["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE
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"]))
@@ -1070,18 +958,9 @@ 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 == "growth": if sort in {"growth", "growth_rate"}:
value = item.get("growth", {}).get("delta") field = "delta" if sort == "growth" else "rate"
return value if value is not None else float("-inf") value = item.get("growth", {}).get(field)
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
@@ -1098,20 +977,9 @@ class DashboardQueries:
reason = None reason = None
if sort in {"growth", "growth_rate"} and not growth_available: if sort in {"growth", "growth_rate"} and not growth_available:
available = False available = False
if growth_days == 1 and sort == "growth_rate": reason = (
reason = ( f"近 {growth_days} 日增长需要至少 {growth_days + 1} 天快照"
"近 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 = {
@@ -1142,7 +1010,6 @@ 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)
@@ -1164,19 +1031,16 @@ 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( previous = self._snapshot_map(conn, [aid], before=snapshot["stat_date"] if snapshot else None).get(aid)
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"] = self._growth_result( item["growth"] = growth.as_dict()
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])
@@ -1789,7 +1653,6 @@ 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)
+5 -17
View File
@@ -1,6 +1,6 @@
import { import {
coverUrl, escapeAttr, escapeHtml, formatCount, formatDate, coverUrl, escapeAttr, escapeHtml, formatCount, formatDate,
formatDelta, formatDuration, formatPercent, formatSignedPercent, metricLabel, statusLabel, formatDelta, formatDuration, formatPercent, metricLabel, statusLabel,
} from "./format.js"; } from "./format.js";
export function cover(item, className = "thumb") { export function cover(item, className = "thumb") {
@@ -30,15 +30,9 @@ 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, growthField = "delta", showStatus = false,
} = {}) { } = {}) {
const metrics = item.metrics || {}; const metrics = item.metrics || {};
const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary); const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary);
@@ -49,13 +43,7 @@ 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 showRate = growthField === "rate"; const delta = growth?.available ? growth.delta : null;
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;
@@ -85,7 +73,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>
${change !== null ? `<span class="delta ${changeUp ? "up" : "down"}">${change}${growthBaselineTag(growth)}</span>` : ""} ${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</span>` : ""}
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span> <span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
</div> </div>
</div> </div>
@@ -130,7 +118,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)}${growthBaselineTag(growth)}</span>` ? `<span class="metric-delta ${growth.delta >= 0 ? "up" : "down"}">${formatDelta(growth.delta)}</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,12 +46,6 @@ 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: 160px minmax(0, 1fr); } .video-card { grid-template-columns: 128px 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: 160px minmax(0, 1fr); } .video-card { grid-template-columns: 144px 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 { gap: 8px; } .video-card { grid-template-columns: 104px minmax(0, 1fr); 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; }
+1 -2
View File
@@ -50,7 +50,6 @@ 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>
@@ -93,7 +92,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>近 7/30 日增长仍要跨日快照。近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需当天至少两次采样。样本不足时显示提示,不伪造 0。</li> <li>样本不足(少于 2 天快照、单视频 UP)会显示提示,不伪造 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
? "当日发布按 0 起算;更早发布的新入库需要当天至少两次采样。" ? "当前快照不足,完成至少 2 天快照后即可显示播放增量。"
: `需要至少 ${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,7 +166,6 @@ 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>`;
} }
@@ -180,21 +179,6 @@ 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 });
@@ -261,7 +245,6 @@ 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 && !detail.growth?.available ? badge("样本不足", "warning") : ""} ${samples < 2 ? 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,7 +12,6 @@ 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,
@@ -132,18 +131,6 @@ 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
@@ -162,19 +149,6 @@ 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
@@ -265,7 +239,6 @@ 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": 8' in output assert '"schema_version": 7' in output
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
-4
View File
@@ -146,7 +146,6 @@ 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"
@@ -160,7 +159,6 @@ 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,
@@ -178,7 +176,6 @@ 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
@@ -197,7 +194,6 @@ 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 == 8 assert db.schema_version == 7
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 == 8 assert db2.schema_version == 7
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 8 assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 7
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, 8] assert applied == [1, 2, 3, 4, 5, 6, 7]
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()
+8 -46
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, 8] assert applied == [1, 2, 3, 4, 5, 6, 7]
assert current_version(conn) == 8 assert current_version(conn) == 7
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,14 +70,10 @@ 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] == 8 assert row[0] == 7
finally: finally:
conn.close() conn.close()
@@ -92,15 +88,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()
v9 = Migration(9, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",)) v8 = Migration(8, "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 + (v9,), migrations=MIGRATIONS + (v8,),
) )
assert applied == [9] assert applied == [8]
backups = list(backup_dir.glob("app-pre-v8-*.db")) backups = list(backup_dir.glob("app-pre-v7-*.db"))
assert len(backups) == 1 assert len(backups) == 1
check = sqlite3.connect(backups[0]) check = sqlite3.connect(backups[0])
try: try:
@@ -185,46 +181,13 @@ def test_v5_normalizes_unfaved():
) )
conn.commit() conn.commit()
applied = run_migrations(conn) applied = run_migrations(conn)
assert applied == [5, 6, 7, 8] assert applied == [5, 6, 7]
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()
@@ -232,7 +195,6 @@ 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,59 +296,6 @@ 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)
@@ -420,7 +367,6 @@ 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: 收藏夹/视频状态机与黑名单测试全部通过")
+3 -11
View File
@@ -444,22 +444,19 @@ 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 为旧片;4 发布已久但当天入库,不进小时任务 # 1/2 在墙钟近 2 天内;3 为旧片
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,验证小时任务可覆盖刷新
@@ -480,27 +477,23 @@ 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, view_open FROM snapshot " "SELECT view, ok, captured_at FROM snapshot WHERE stat_date=? AND aid=1",
"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、4 assert full.success >= 1 # 至少旧片 3
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:
@@ -569,7 +562,6 @@ 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
@@ -0,0 +1,194 @@
# -*- 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()
+6 -192
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, FOLDER, build_db, resource, write_config # noqa: E402 from tests.web_fixtures import DATES, build_db, 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 + 120) # 101/102 跨日 + 103 当日发布 assert overview["view_delta"] == (100 + 100 + 0) # 101/102 的日增量
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"] == 8 assert about["schema_version"] == 7
assert about["analytics"] == { assert about["analytics"] == {
"min_duration_seconds": 600, "min_duration_seconds": 600,
"maturity_days": 2, "maturity_days": 2,
@@ -492,7 +492,6 @@ 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
@@ -578,13 +577,9 @@ 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, 103} assert {item["aid"] for item in auto["items"]} == {101, 102}
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
@@ -672,10 +667,9 @@ def test_overview_adjacent_previous_with_seed_and_dual_fields():
+ overview["counts"]["unfaved"] + overview["counts"]["unfaved"]
+ overview["counts"]["removed"] + overview["counts"]["removed"]
) )
# 101/102:当前=seed(=完成日数值),相邻 previous=完成日本身 → 增量 0 # 当前=seed(=完成日数值),相邻 previous=完成日本身 → 增量 0
# 103:当日发布,近 1 日相对 0,计入当前播放
assert overview["view_delta_available"] is True assert overview["view_delta_available"] is True
assert overview["view_delta"] == 120 assert overview["view_delta"] == 0
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
@@ -684,186 +678,9 @@ 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()
@@ -879,12 +696,9 @@ 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"] == 8 assert payload["data"]["schema_version"] == 7
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 只有最后一天快照(发布日=快照日,近 1 日相对 0);104 另加一条失败快照 # 103 只有最后一天快照,用于验证样本不足;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},
+2 -4
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, view_open, open_captured_at danmaku, captured_at, ok, err, src
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'preview', ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'preview')
""", """,
( (
stat_date, stat_date,
@@ -253,8 +253,6 @@ 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
@@ -0,0 +1,326 @@
# -*- 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())