Files
re-xxm/project.md
T
bot-ci 9969e65202 fix(scheduler): drop cross-task mutex, quiet window + retry
Incident: evening full snapshot could be recorded as ok with
already_completed/elapsed=0 after mutual skip with hourly sync, then
next_run jumped to the next day.

- Full-site snapshot never skips because another task is running
- 23:00-01:00 Asia/Shanghai: defer discovery / snapshot_recent /
  candidate_cleanup to after 01:00; fans never defers
- Skip records ok=false + skipped=true (not success / not completed)
- Skip/fail reschedule soon (~5m); daily success still next snapshot_at
- TaskControl return value drives next_run; system UI shows skip fields
2026-11-03 00:07:19 +08:00

940 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 咻咻满 Reaction 视频数据看板 —— 可行性分析与技术路线
> 状态:**设计定稿;P0(数据底座)、P1(每日快照与粉丝追踪)、P2(只读本地看板)、P3(Telegram 发现与入库)已实施;第 9 轮加入时长效率榜、UP 粉丝快照与导航拆分;第 10 轮与 B 站收藏夹解耦并加入 Telegram 手动入库**
> 最后更新:2026-11-01(第 10 轮:收藏夹解耦、手动入库、写鉴权仅用于重排序)
> B 站写操作仅保留 `fav/resource/sort`(由 `favsort.py` 触发,频率极低);`fav/resource/deal` 已移除
---
> 说明:本文档记录项目完整设计与历史验证。**第 10 轮起,收藏夹不再是库的真相源**:入库改由「搜索候选确认」与「Telegram 私聊手动入库」承担,项目不再轮询收藏夹;下方 §4、§7 等章节中关于「收藏夹同步 / 一键加入」的描述为历史设计,已被第 10 轮取代(见 §15)。
## 0. 结论摘要
**判定:可行,工程难度中等偏低。** 核心功能点都有稳定可用的官方 Web 接口,且取数匿名即可完成。
| 功能点 | 主力接口 | 实测 |
|---|---|---|
| 视频发现 | `wbi/search/type` | ✅ `code=0`,匿名可用,单关键词上限 800~1000 条 |
| 手动入库(链接/BV/av/短链) | `view?aid=` / `view?bvid=`、`b23.tv` 跳转 | ✅ 只读 |
| 每日统计 | `view?aid=` | ✅ 7 项指标齐全,0.07~0.14s/条,无风控 |
| 收藏夹重排序(唯一写操作) | `fav/resource/ids` + `infos` + `sort` | ✅ `favsort.py` 已实现 |
| 看板 | 本地 SQLite | ✅ 无外部依赖 |
**本轮确认的设计前提**(来自你的两轮答复):
1. 搜索频率 1 次/小时,**只取前 2 页,允许遗漏**——搜索是"代替人工发现 + 即时提醒",人工加收藏兜底;
2. 搜索结果的**分类靠人工点击确认**(通知即确认),不追求自动判定;
3. **不建 UP 白名单**(reaction UP 多为一次性投稿,收益低),改为**可配置的 UP 黑名单**(专发直播回放/切片的号直接排除,**仅作用于发现,不影响库内已有视频**);
4. 收藏夹**按年分夹**,但**新建夹由人工完成**,系统只读配置;新视频优先入"同 UP 最早视频所在夹";
5. **通知渠道先做 Telegram**(支持按钮;开发阶段用 long polling,不需要公网 URL);
6. 收藏夹是"新增视频来源"的兜底,**日更对象是库内已入库视频**,与年度夹解耦;
7. 用户取消收藏的视频**继续日更**,但允许打标记"移出库"后停止发现与追踪;
8. 新增追踪指标:**咻咻满粉丝数**(只画粉丝曲线,不叠加视频数据);
9. 所有凭据(Telegram token/chat_id、收藏夹 id、cookie)**统一放配置文件**;
10. 通知提供**收藏 / 忽略 / 拉黑**三个动作,拉黑只作用于"未来发现"。
**三个仍需注意的硬约束**:
- `fav/resource/infos` 只能替代 7 项里的 3 项(play/favorite/danmaku),`reply` 字段恒为 0,且没有 `like/coin/share` → **日更仍必须逐条 `view`**;
- 搜索 `order=pubdate` 排序不稳定(page 20 会跳到 3 月),**不能"翻到旧视频就停"**;
- 收藏夹约 **1000 条/夹**,2026 夹已 664 条,年末约 740 条 → 容量安全但需预警。
---
## 1. 已确认的需求口径
### 1.1 收录范围
- **只收「他人 reaction 咻咻满」**:别的 UP 主观看/试听/点评咻咻满的直播、唱歌、充电内容。
- **不收**:咻咻满本人投稿、纯直播歌切、粉丝切片(除非人工明确加入收藏夹)、搬运、纯翻唱。
- 判定责任在**人**:搜索只负责"可能相关"的候选,用户点确认才入库。
### 1.2 "充电"关键词的真实含义
- 不是"充电才能观看的付费视频";
- 而是很多 reaction UP 把"充电用户点播"的专场投稿标题写成 `【加急充电-咻咻满专场】`、`【咻咻满充电专场①】`,**标题里没有 reaction 字样**;
- 所以 `咻咻满 充电` 是**为了命中这批漏网的 reaction 视频**,实测 537 条结果、近 48h 新增 5 条,确有必要。
- 实测这些视频 `view` 正常返回 stat,`is_upower_exclusive=False`,不存在取不到数据的问题。
### 1.3 收藏夹按年(人工建夹 + 同 UP 归属)
- `4451053851` = `咻咻满-reaction-2025`(167 条)
- `3700023351` = `咻咻满 reaction 合集`(664 条,实为 2026 年)
- **年度夹只作为配置项**,系统不自动创建;年末人工建夹后把 `media_id` 加进配置。
**入哪个夹的判定(按优先级)**:
1. 该 UP 在库中已有视频 → **入"同 UP 最早视频所在夹"**。
例:某 UP 在 2026-12-25 发过视频、在 2026 夹,那么他 2027-01-02 的新视频**仍入 2026 夹**。
2. 该 UP 在库中无视频 → 入**当前自然年**对应的夹(找不到则用配置里的最新年度夹,并提示"建议新建 2027 夹")。
> 这样同一个 UP 的视频永远在同一个夹里,配合 §7.3 的"同 UP 连续"规则,收藏夹不会因为跨年把一个 UP 拆成两段。
> **容量处理(不做自动改夹)**:目标夹满了就让它失败——`deal` 返回错误时记 `fav_action.state='failed'` 并通过通知渠道告警(含"请新建年度夹并更新配置")。可在接近上限(如 `media_count >= 950`)时提前发一次预警,但**系统绝不擅自把视频塞进另一个夹**。
> **注意**:这只是"收藏夹归属",`snapshot` 与看板**不按年分表**,全部视频统一查询、统一出榜。
### 1.4 数据来源的关系(第 10 轮更新)
```
搜索候选 + Telegram 点确认 ──┐
├──► video 表(status=active)──► 每日统计 ──► 看板
Telegram 私聊发链接/BV 号 ───┘
```
- **本地库是真相源**:入库来自「搜索候选确认」与「Telegram 私聊手动入库」,只写本地 `video(status='active')`;
- **收藏夹解耦**:项目不再轮询收藏夹,也不会因取消收藏改变视频状态;收藏夹由人工维护,`favsort.py` 负责低频重排序;
- **日更对象** = 库内 `active` 视频;
- **黑名单只挡"发现"**:已入库的黑名单 UP 视频一律当普通视频处理,不判断、不排除、也不反向更新黑名单。
### 1.5 配置与凭据(JSONC + secrets)
业务项放 `config.jsonc`(可注释,`chmod 600`,`.gitignore` 排除);敏感凭据放同目录 `secrets.json`(纯 JSON,供 auth 回写)。
默认优先读 `config.jsonc`,否则 `config.json`。旧单体文件仍可用;存在 `secrets.json` 时凭据以它为准。
`secrets.json`:
```json
{
"bilibili": {
"read": {
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
"csrf": "<read bili_jct>",
"mid": 28802351
},
"write": {
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
"csrf": "<write bili_jct>",
"mid": 0
}
},
"telegram": {
"bot_token": "123456:ABC...",
"chat_id": "-1001234567890"
},
"web": { "auth_token": "..." }
}
```
`config.jsonc`(节选):
```jsonc
{
"telegram": { "enabled": true },
"folders": [
{ "year": 2025, "media_id": 4451053851, "title": "咻咻满-reaction-2025" },
{ "year": 2026, "media_id": 3700023351, "title": "咻咻满 reaction 合集" }
],
"track_fans": [37754047],
"up_blacklist_seed": [
261052419, 1908537643, 1701121088, 542262, 376316958,
145178393, 66389997, 293976264, 3026109
],
"schedule": { "discover_minutes": 60,
"snapshot_at": "23:15", "seed_at": "00:00", "candidate_ttl_days": 7 },
"discover": { "keywords": ["咻咻满"], "pages": 3, "window_days": 7,
"candidate_min_score": 4 },
"notify": { "min_score": 4, "max_items_per_message": 10 },
"classify": {
"require_title": "咻咻满",
"self_mid": 37754047,
"reaction": ["reaction", "反应"],
"charge": ["加急", "特急", "充电", "专场", "转场"],
"weak_positive": ["双人", "路人", "第一次", "首次"],
"negative": ["歌切", "切片", "纯享", "搬运", "完整版", "录播", "录像"],
"date_negative": true,
"duration_boost": { "gt_30min": 1, "gt_60min": 2 }
},
"heat_weights": { "view": 0.30, "like": 0.20, "coin": 0.20,
"favorite": 0.15, "share": 0.10, "reply": 0.03, "danmaku": 0.02 }
}
```
- 支持热加载(每 60 秒检查 mtime),改收藏夹/黑名单不用重启;`schedule` 中的周期与每日时间需重启生效;
- **黑名单本身放数据库**(`up_blacklist` 表),因为要从通知里动态拉黑;配置文件只做初始种子;
- 日志里**永不打印 token / cookie**,启动时只打印"已配置/未配置";
- 容器里把配置目录挂成**可写卷**(挂 `config.jsonc` + `secrets.json`,权限 600),因为 `auth` 扫码续期后要回写 `secrets.json`;
- `python app.py auth --account read|write` 对指定账号扫码续期,只写 `secrets.json` 中该账号身份字段、保留设备指纹,**不改**主 JSONC;
- `read` 用于快照、搜索发现、入库与所有查询;`write` 仅用于 `favsort.py` 收藏夹重排序(`sort`);不支持顶层扁平凭据;
- 可选的 `ENV` 覆盖(`TG_BOT_TOKEN` 等)留给 CI/临时调试,不作为主路径。
### 1.6 初始黑名单(已核对昵称,实测 2026-10-31)
| mid | 昵称 | 粉丝 | 类型(人工判断) |
|---|---|---|---|
| 261052419 | TL二十度灰 | 1,099 | 直播歌切 |
| 1908537643 | 咻咻满在发光 | 7,289 | 粉丝切片号 |
| 1701121088 | TTsssolo | 2,393 | 切片/搬运 |
| 542262 | 咻咻满的表情包 | 16,436 | 切片号 |
| 376316958 | 好心的王二哈 | 2,779 | 切片/直播相关 |
| 145178393 | 雨落柃云 | 1,545 | 切片/搬运 |
| 66389997 | 至尊煎饼 | 216 | 切片/搬运 |
| 293976264 | 宁牙XML | 158 | 切片/搬运 |
| 3026109 | 切片不切丝QAQ | 7,471 | 切片号 |
- 这 9 个作为 `up_blacklist` 的**初始种子**,首次启动时写入数据库(`INSERT OR IGNORE`);
- 之后通过通知里的「拉黑」动态追加;
- 已入库/收藏夹里的这些 UP 的视频**不受影响**(§1.4、§3.4);
- 误拉黑可在看板移除,或删库里的行。
---
## 2. 接口能力矩阵(2026-10-31 实测)
| 能力 | 接口 | 需登录 | 实测结果 | 用途 |
|---|---|---|---|---|
| 关键词搜索 | `GET x/web-interface/wbi/search/type` | 否 | ✅ 800~1000 条上限 | 发现 |
| 旧版搜索(降级) | `GET x/web-interface/search/type` | 否 | ✅ `code=0`,结果略少 | 兜底 |
| UP 主搜索 | 同上 `search_type=bili_user` | 否 | ✅ 找到 咻咻满 mid=37754047 | 仅元数据 |
| 空间投稿 | `GET x/space/wbi/arc/search` | 否(需 WBI) | ✅ `code=0` | 本方案不使用 |
| 视频完整统计 | `GET x/web-interface/view?aid=/bvid=` | **否** | ✅ 7 项齐全,0.1s | **日更 + 手动入库主力** |
| 收藏夹 ID 列表 | `GET x/v3/fav/resource/ids` | 是 | ✅ 664 条 1 次拿全 | `favsort.py` 重排序 |
| 收藏夹批量详情 | `GET x/v3/fav/resource/infos` | 是 | ✅ **一次 400 条**,0.2s;664 条返回 648(缺 16) | `favsort.py` 重排序 |
| ~~加入收藏夹~~ | `POST x/v3/fav/resource/deal` | — | — | **第 10 轮已移除** |
| 收藏夹内排序 | `POST x/v3/fav/resource/sort` | 是 + csrf | ✅ 已验证 `A:2:B:2` | 收藏夹重排序(唯一写操作) |
| 收藏夹列表 | `GET x/v3/fav/folder/created/list-all` | 是 | ✅ 返回 media_count | 列出重排序目标夹 |
| 粉丝数 | `GET x/relation/stat?vmid=` | **否** | ✅ `code=0`,匿名可用 | 粉丝追踪 |
| ~~视频统计(旧)~~ | `x/web-interface/archive/stat` | — | ❌ **HTTP 404,已下线** | 不可用 |
| ~~批量 view~~ | `view?aid=1,2` | — | ❌ `-400` | 不可用 |
### 2.1 `infos` 到底能替代什么(本轮重点实测)
逐条与 `view` 对照(同一批 6 条视频):
| 指标 | infos | view | 是否一致 |
|---|---|---|---|
| 播放 `play` / `view` | ✅ | ✅ | **完全一致**(746/746、1316/1316…) |
| 收藏 `collect` / `favorite` | ✅ | ✅ | **完全一致**(43/43、66/66…) |
| 弹幕 `danmaku` | ✅ | ✅ | **完全一致**(7/7、26/26…) |
| 评论 `reply` | ❌ 恒为 **0** | ✅ 16/24/7/38/37/109 | **字段已失效,不能用** |
| 点赞 `like` | ❌ 无此字段 | ✅ | 缺失 |
| 投币 `coin` | ❌ 无此字段 | ✅ | 缺失 |
| 转发 `share` | ❌ 无此字段 | ✅ | 缺失 |
**结论:`infos` 不能替代 `archive/stat` 做"7 项日更"。**
但它是**极好的批处理通道**,有不可替代的价值:
- 单次 **400 条 / 0.2 秒**(664 条 URL 约 12KB,一次请求 0.2s 返回 648 条);
- 返回 `upper.mid/name`、`bvid`、`title`、`pubtime`、`ctime`、`duration`、`attr`(失效标记);
- 664 条收藏的**全量元数据同步只需 2 次请求**(按 200 条分块更稳)。
**因此日更拆成两段**:
1. **元数据 + 3 项指标**(play/favorite/danmaku):`infos` 批量,2 次请求拿全库 → 秒级;
2. **其余 4 项**(like/coin/share/reply):`view` 逐条,831 条约 17 分钟。
> 注意:第一段不能只跑一次就当作日更,因为两张表口径必须一致;实际做法是**以 `view` 为唯一事实来源**,`infos` 只用于收藏夹同步/导入/校验。若某天 `view` 大面积失败,可用 `infos` 的三项先兜底并标记"部分指标缺失"。
### 2.2 `infos` 的两个坑(实测)
1. **静默丢条**:请求 664 条只返回 648 条,缺失的 16 条是"账号已注销/视频已删"。做差集时**不能用返回条数判断成功**,要 `请求 ids - 返回 ids` 得到缺失集合。
2. **失效标记**:`attr=9` 表示"已失效视频",664 条里有 20 条(3.0%);加上静默缺失的 16 条,共 **36 条 / 664 = 5.4% 已不可用**。日更必须跳过并把状态置为 `removed`,否则每天 36 次无效请求 + 报错。
---
## 3. 发现环节设计(每小时,前 2 页)
### 3.1 为什么"前 2 页就够"
实测(`order=pubdate`):
| 关键词 | 第 1 页时间跨度 | 第 2 页覆盖到 | 近 48h 新增 |
|---|---|---|---|
| `咻咻满 reaction` | 10-31 → 10-23 | 10-08 | 6 |
| `咻咻满 充电` | 10-31 → 10-17 | 10-02 | 5 |
第 1~2 页覆盖 20~30 天,而扫描间隔只有 1 小时,**理论冗余 500 倍**。即使排序抖动导致某条新视频落到第 3 页,下一轮也会被捞到(抖动主要是"旧视频位置漂移",不是"新视频沉底")。
**代价可接受**:漏掉的由人工加收藏兜底,且收藏夹监控会自动发现。
### 3.2 扫描流程
```
每 60 分钟(整点 ± 抖动):
for kw in ["咻咻满 reaction", "咻咻满 充电"]: # 2 个关键词
for page in (1, 2): # 每词 2 页
r = wbi_search(kw, page, order=pubdate) # 共 4 次请求
for item in r.result:
if item.aid in seen_aid or item.aid in video: continue # 永久去重
if now - item.pubdate > 14 天: continue # 时间窗,防历史回填
insert(seen_aid, item.aid)
record(candidate, score=classify(item)) # 进候选池
if score >= NOTIFY_THRESHOLD: enqueue_notify(item)
sleep(1.5~2.5s jitter)
# 收藏夹监控(独立任务,见 §4),发现人工新增会立即入库
```
请求量:**每小时 4 次搜索**,一天 96 次。远低于风控阈值(实测 1.5s 间隔连续 15 次无 `-352`)。
### 3.3 轻量降噪规则(只决定"要不要发通知",不决定"要不要入库")
```
# 硬门槛
title 含 "咻咻满"(全半角/大小写归一化后),否则直接 0 分
up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进)
# 正向命中次数 n
pos = reaction|反应 命中次数 + count(加急|特急|充电|专场) + count(双人|路人|第一次|首次)
n = pos + (pos > 0 ? 1 : 0) # 标题含"咻咻满"仅在已有正向命中时计 1 次
score = n * n # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5)
+ 2 # 时长 > 60 分钟(与下一条不叠加,取大)
+ 1 # 时长 > 30 分钟
- 1 # 时长 < 10 分钟(切片/片段;时长未知=0 不扣)
- 2 * k # 每个负向词:歌切|切片|搬运|完整版|录播|录像
- k*(k-1) # 同时命中 2 个额外 -2、3 个额外 -6、4 个额外 -12
- 4 # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日)
- 3 # UP 为 咻咻满本人(mid=37754047)
候选线 = discover.candidate_min_score(默认 = notify.min_score)
通知线 = notify.min_score
score < 候选线 → 丢弃(只过标题关、无正向命中的不再进候选池)
候选线 <= score < 通知线 → 进看板"候选池"(7 天后删除),不通知
score >= 通知线 → 通知
```
**几条设计依据**:
- **reaction 类用宽松 `re` 但须同现**:`re|reaction|反应` 必须与"咻咻满"或"充电"同时出现才计命中——既避免通用词(任意含 "re" 的拉丁串)加分,又能兼容 `reactio`/`reation`/`recation` 等拼写;
- **只过标题关不计分**:标题仅含 `require_title`("咻咻满")而没有命中任何正向词时 **0 分**——"咻咻满"单独出现和 "reaction" 单独出现一样没有正向意义,必须"咻咻满 + 任一正向词"才计分;
- **正向按命中次数超线性加权(n²)**:多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词就能把多信号真 reaction 打到线下;`1+1>2`、`1+1+1>5` 用平方实现;
- **负向也非线性**:每个负向词 -2,命中 2 个额外 -2、3 个额外 -6(`k*(k-1)`),带日期再 -4;单一负向词不再一票否决多信号 reaction;
- **时长 <10 分钟 -1**:这类基本是切片/片段;时长未知(0)不扣;
- **候选线与通知线分离**:`discover.candidate_min_score`(默认取 `notify.min_score`)控制是否进候选池——低于候选线的直接丢弃(只过标题关的不再入池),避免看板被无意义候选刷屏;
- **"听/看"等宽泛词已移除**;`纯享`(会误伤"纯享受")已从负向移除,`转场`("专场"错别字)已从正向移除;
- **时长只在已命中正向词时加权**:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池;
- **带日期的标题按强负向处理**:`2026-10-28`、`10月25日` 这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉;
- **`录播`/`录像` 计负向**:`完整版` 覆盖不到 `完整录播`;`录像` 覆盖 `直播录像`。裸 `回放` 不列入,因为 `【回放/咻咻满reaction】` 之类确为 reaction;
- 时长直接取**搜索结果自带的 `duration` 字段**(格式是 `分钟:秒`,超过 60 分钟写作 `105:5`),**不需要额外请求**;
- 关键词用单个 `咻咻满`(`discover.pages=3` 覆盖 7 天窗口),因此 `require_title` 是过滤非相关结果的关键兜底;
- UP 黑名单在配置文件里维护,可热加载,后续从"误通知"记录里一键加入。
### 3.4 通知内容
每条候选一条消息,包含:
- 封面缩略图 + 标题 + UP 名 + 发布时间 + **时长** + 当前播放/点赞
- 三个动作按钮:
- **「收藏」**:加入目标收藏夹并入库(见 §7)
- **「忽略」**:该 aid 标 `rejected`,不再提醒(同一轮同 UP 的其他候选不受影响)
- **「拉黑」**:把该视频的 **UP 拉入 `up_blacklist`**,同时把该 UP 的其他待决策候选一并标记为 `rejected`;**已入库/收藏夹里的该 UP 视频不受影响**
- 「打开视频」链接(`https://www.bilibili.com/video/<bvid>`)
**私聊管理命令**(仅 `telegram.chat_id` 本人可用):
- `/candidate [页码]`:纯文本列出待决策候选标题,每页 10 条;
- `/candi_del`:每页 10 个**标题按钮**(`cdel:<page>:<aid>`)+ 上一页/下一页;点击删除该候选后用 `editMessageReplyMarkup` **原地重建**本页键盘,可连续删;删空本页则回退上一页;
- `/candi_clear`:`[全部][待决策][取消]`(`cclr:all|pending|cancel`);「全部」清整张 `candidate` 表,「待决策」只清 `decision IS NULL`。
**黑名单语义(明确边界)**:
1. 只过滤"未来发现"——命中黑名单的 UP 新视频不进候选池、不发通知;
2. **不做回溯清理**:库内已有的、收藏夹里已有的该 UP 视频照常日更、照常上榜;
3. **不从收藏夹反推黑名单**:人工加进收藏夹的行为不会修改黑名单;
4. 可在看板里移除黑名单条目(误拉黑可恢复)。
> 同一轮多条候选**合并成一条消息**(避免刷屏),单条消息最多 10 个按钮。
**零额外请求**:搜索结果的 item 里已经带 `play / like / favorites / danmaku / review / duration / cover / pubdate / uname / mid`,发通知不需要额外调用 `view`。注意这些是索引值,可能比实时值滞后几分钟到几小时,通知里标注"(索引值)"即可。
---
## 4. 收藏夹监控(库的真相源)
### 4.1 为什么必须有
- 人工加收藏是最可靠的"确认"动作,系统必须能感知;
- 同时解决"标题/UP 改名、视频失效、用户移出收藏夹"的状态同步。
### 4.2 流程(每小时,与搜索同频)
```
for folder in 配置的年度夹(2025、2026、…):
ids = get(ids?media_id=folder) # 1 次,返回全部 aid
diff_add = ids - db.known_ids(folder)
diff_del = db.known_ids(folder) - ids
if diff_add:
for chunk in chunks(diff_add, 200): # 通常 1 次
infos = get(infos?resources=...)
for it in infos:
upsert(video, source='fav:<folder>', status='active')
enqueue_snapshot_today(it.id) # 当天补一次快照
notify("收藏夹发现 N 条新视频,已自动入库")
if diff_del:
for aid in diff_del:
mark(video.status='removed' or 'unfaved')
# 元数据刷新:每 6 小时用 infos 全量刷一次标题/UP/封面/失效状态
```
- 请求量:2 个夹 × (1 次 ids + 0~1 次 infos)/小时,**基本可忽略**;
- `infos` 返回 `attr=9` 或缺失 → 标记 `removed`,保留历史快照;
- 用户移出收藏夹(`diff_del` 且视频仍可访问)→ 标记 `unfaved`,**数据继续跟踪**(库不因取消收藏而丢历史),只是看板可筛"仅在库/在收藏夹"。
- 被用户手动"移出库"(`dropped`)的视频若又出现在收藏夹(`diff_add`)→ **自动恢复为 `active`**,并补当天快照;这是唯一会"复活"的状态。
- 监控的是**所有配置的年度夹**(2025、2026…),所以人工把新视频加进旧夹也能被发现;
- `video.folder_id` 以"实际所在夹"为准,若同一 aid 同时出现在多个夹(极少见),取最新加入的那个并告警。
- 上述 `enqueue_snapshot_today` 已实现为 `pending_snapshot_aids()` 检查:完成标记为 1 时仍会为新入库/恢复视频补采当天快照。
### 4.3 首次导入
- `infos` 按 200 条分块,664 条 = 4 次请求,秒级完成;
- 两个夹共 831 条,一次性导入 `video` 表;
- **历史快照无法回填**:B 站不提供任意日期历史数据,只能从部署当天开始积累;
- **开发联调阶段产生的数据一律保留**(不清库、不重建),正式上线直接续用同一个 `app.db`;
- 新发现/新入库的视频从"入库当天"开始积累快照。近 1 日:当日发布按 0 起算(一次成功采样即可);更早发布的新入库按入库时播放量起算,第二次采样走晚间全量,不进小时快照。近 7/30 日仍要跨日快照。
---
## 5. 每日数据追踪
### 5.1 任务
```
每天 23:15(Asia/Shanghai)全量落盘;00:00 克隆当日壳:
videos = db.where(status='active') # 跨所有年度夹
for v in videos:
if exists(snapshot[today, v.aid]) and ok: continue # 断点续跑
r = view(aid=v.aid) # 匿名 + 设备指纹
if code == 0: upsert(snapshot, today, r.stat)
elif code == -404: v.status='removed'
else: retry(3, backoff); snapshot(ok=0, err=...)
sleep(1.0~1.6s jitter)
发送完成摘要(总数/成功/失败/耗时)
```
### 5.2 容量与耗时
| 库规模 | 请求数 | 预估耗时(1.2s/条 + 网络) |
|---|---|---|
| 831(当前) | 831 | 约 17 分钟 |
| 1600(2027 年末) | 1600 | 约 33 分钟 |
| 3000(2029 年) | 3000 | 约 60 分钟 |
- 23:15 启动全量落盘;00:00 仅本地克隆壳,不影响白天使用;
- 若单条 0.1s + sleep 1.2s ≈ 1.3s,实际按 1.2s 估算偏保守。
### 5.3 幂等与口径
- 唯一键 `(stat_date, aid)`,`INSERT ... ON CONFLICT DO UPDATE`;
- `stat_date` 用本地日期,不用 UTC(避免日界落到前一天);
- 失败重试 3 次后写 `ok=0` 占位,**不写假数据**;看板显示"缺失"而不是 0;
- 完成标记之外还要检查当天待采集合:新入库/恢复视频立即补采,`ok=0` 项在续跑时重试;
- 强制重采开始前先写未完成标记,中断后仍可续跑;
- 日增量查询时用 `LAG()` 跳过缺失日,不插值。
### 5.4 粉丝数追踪(新增)
```
每天 23:15 与视频快照同一个调度窗口里,追加粉丝请求:
r = GET x/relation/stat?vmid=37754047 # 匿名可用,实测 0.1s
upsert(up_stat, today, mid=37754047, follower=r.follower, following=r.following)
```
- 接口实测:`{"mid":37754047,"following":95,"follower":2767264}`,**匿名`code=0`**,无需登录;
- 唯一键 `(stat_date, mid)`,同样幂等;
- 续跑时已成功的 `(stat_date, mid)` 会跳过,不会因后续失败覆盖为 NULL;
- 只加 1 次请求/天,成本可忽略;
- 支持配置多个 mid(`track_fans: [37754047]`),但看板默认只突出咻咻满;
- 粉丝日增 = 今日 - 昨日,缺失日不插值,曲线断开;
- 历史数据可用 `tools/import_fans_csv.py` 从 B 站粉丝历史 CSV 回填;只写 `up_stat`,默认跳过已有成功记录,`--replace` 可用完整历史覆盖重建,缺失日保持断开。
---
## 6. 数据模型(定稿草案)
```sql
-- 视频主表
CREATE TABLE video (
aid INTEGER PRIMARY KEY,
bvid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
up_mid INTEGER,
up_name TEXT,
pubdate INTEGER NOT NULL,
duration INTEGER,
cover TEXT,
folder_id INTEGER, -- 来源年度夹(可空=纯搜索确认)
fav_time INTEGER, -- 加入收藏夹时间
status TEXT NOT NULL, -- active|unfaved|removed|dropped|rejected
source TEXT, -- 'fav:3700023351' | 'search:咻咻满 reaction'
first_seen INTEGER NOT NULL,
last_sync INTEGER,
attr INTEGER
);
CREATE INDEX idx_video_status ON video(status, pubdate DESC);
CREATE INDEX idx_video_up ON video(up_mid, pubdate DESC);
-- 每日快照(唯一键 = 日期 + 视频)
CREATE TABLE snapshot (
stat_date TEXT NOT NULL, -- 'YYYY-MM-DD'
aid INTEGER NOT NULL,
view INTEGER, like INTEGER, favorite INTEGER, coin INTEGER,
share INTEGER, reply INTEGER, danmaku INTEGER,
captured_at INTEGER NOT NULL,
ok INTEGER NOT NULL DEFAULT 1,
err TEXT,
src TEXT DEFAULT 'view', -- view | infos(降级时标记)
view_open INTEGER, -- 同日第一次成功采样的播放量
open_captured_at INTEGER, -- 开盘采样时刻
PRIMARY KEY (stat_date, aid)
);
CREATE INDEX idx_snap_aid ON snapshot(aid, stat_date);
-- 收藏夹监控状态
CREATE TABLE folder (
media_id INTEGER PRIMARY KEY,
year INTEGER NOT NULL,
title TEXT,
media_count INTEGER,
last_ids_at INTEGER,
last_full_at INTEGER,
enabled INTEGER DEFAULT 1
);
-- UP 黑名单(专发直播回放/切片等,直接排除,不进候选)
CREATE TABLE up_blacklist (
mid INTEGER PRIMARY KEY,
name TEXT,
reason TEXT,
created_at INTEGER NOT NULL
);
-- UP 粉丝/关注数快照(每天一条)
CREATE TABLE up_stat (
stat_date TEXT NOT NULL,
mid INTEGER NOT NULL,
follower INTEGER,
following INTEGER,
captured_at INTEGER NOT NULL,
ok INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (stat_date, mid)
);
-- 候选与扫描流水(漏抓分析 + 去重 + 候选池)
CREATE TABLE candidate (
aid INTEGER PRIMARY KEY,
bvid TEXT,
title TEXT,
up_mid INTEGER,
up_name TEXT,
pubdate INTEGER,
cover TEXT,
score REAL,
channel TEXT, -- 'search:咻咻满 reaction#p1'
first_seen INTEGER NOT NULL,
notified_at INTEGER,
decision TEXT, -- accepted | rejected | NULL(待定)
decided_at INTEGER
);
-- 永久去重标记:candidate 删除后仍能防重复通知
CREATE TABLE seen_aid (
aid INTEGER PRIMARY KEY,
first_seen INTEGER NOT NULL,
source TEXT
);
-- 通知与一键加入
CREATE TABLE notify (
aid INTEGER NOT NULL,
channel TEXT NOT NULL,
batch_id TEXT,
sent_at INTEGER NOT NULL,
ok INTEGER NOT NULL,
err TEXT,
PRIMARY KEY (aid, channel)
);
CREATE TABLE fav_action (
token TEXT PRIMARY KEY, -- HMAC 签名串
aid INTEGER NOT NULL,
folder_id INTEGER NOT NULL,
state TEXT NOT NULL, -- pending | done | failed | expired
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
done_at INTEGER,
position INTEGER,
err TEXT
);
CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT);
```
**第 9 轮新增(schema v4,已迁移)**
- `video.pages`:分 P 数(来自 `infos` 的 `page`),只用于详情展示与包装诊断,不参与排序;
- `video.up_follower` / `up_follower_at` / `up_follower_backfilled`:视频**入库时**的 UP 粉丝数快照;
- `video.up_face`(第 11 轮,schema v6):UP 头像 URL,来自 `view` 的 `owner.face`;入库与每日快照都会刷新,UP 列表/详情据此显示头像;
- `up_profile(mid, name, follower_first, following_first, first_captured_at, follower_latest, last_refreshed)`:每个 UP 的首次/最近粉丝数。`follower_first` 不可被覆盖;
- 采集:收藏夹同步结束时对仍缺粉丝数的 UP 调一次 `relation/stat`(匿名可用);`first_seen >= 本次运行开始前 1 小时` 的视频记为实时采集,更早的记为回填;
- 回填:`python app.py backfill-followers [--limit N] [--dry-run]`,只补 `up_follower IS NULL` 的追踪视频;
- 基于粉丝数的榜单暂不实现,本轮只保证数据从现在起不再缺失。
**要点**
- `status` 是核心状态机(第 10 轮更新):
- `active` 在库,参与日更
- `unfaved` 历史遗留状态(迁移 v5 已归一为 `active`),不再由系统产生
- `removed` 视频失效(`view` 返回 `-404` / `62002` / `62012`),停止请求但保留历史
- `dropped` 手动"移出库",**停止发现与追踪**;再次被候选确认或手动入库时会恢复 `active`
- `candidate` 与 `video` 分表:候选可能永远不入库,避免污染主表;候选 `first_seen` 超过 **7 天**且 `decision IS NULL` → 删除 candidate 行,去重由 `seen_aid` 保证,不会重复通知;
- `seen_aid` 只存 `(aid, first_seen, source)`,每条约 30 字节,按 5 条/天增长,10 年也不到 100KB;
- `up_blacklist` 只影响发现,不影响已入库视频;
- `folder_id` 与 `folder_resource` 保留为历史数据,不再由同步任务写入(第 10 轮);
- 不预存"日增量",全部查询时算(历史数据修正后不会自相矛盾)。
---
## 7. 通知与「一键加入」
### 7.1 渠道抽象
统一接口 `notify.send(candidates, actions) -> ok`,实现:
| 优先级 | 渠道 | 按钮能力 | 备注 |
|---|---|---|---|
| **P0** | **Telegram Bot** | ✅ Inline Keyboard | `callback_data` 直接回调,**long polling 即可,不需要公网 URL** |
| P1 | 企业微信机器人 | ⚠️ 仅 markdown 链接 | 不支持按钮,只能点链接 |
| P1 | 邮件 | ⚠️ HTML 链接 | 兼容性最好 |
| P1 | 通用 Webhook | ✅ 自定义 JSON | 给用户自己接(Bark/ntfy/钉钉) |
**开发阶段只做 Telegram**:
- Bot 用 `getUpdates` long polling 收 `callback_query`,本机没有公网 IP 也能联调;
- `bot_token` / `chat_id` 从配置/secrets 读(见 §1.5),不写死在代码里;
- **按私聊设计**:`chat_id` 是用户 id(正数),Bot 只服务你一个人;回调 `callback_query` 直接用 `answerCallbackQuery` 回执;
- 上线后服务有域名 + TLS,再补"签名链接"路径给企业微信/邮件/Webhook;
- 两种路径共用同一个 `fav_action` 状态机,只是入口不同(`callback_data` vs `POST /a/<token>/confirm`)。
### 7.2 链接与防误触
```
通知链接: https://<domain>/a/<token>
token = base64url(aid.folder.exp.nonce) + "." + HMAC-SHA256(secret, payload)
```
- `GET /a/<token>`:**只读**,校验签名/过期/未使用 → 渲染确认页(封面、标题、UP、当前数据、加入后位置预览)→ 页面按钮 `POST /a/<token>/confirm`;
- `POST`:再次校验 → 调 `deal` → 调 `sort` 定位 → 写 `fav_action` → upsert `video(status='active')` → **删除 candidate 行** → 返回"已加入,位置 #N";
- 任一步失败(夹满 / csrf 失效 / 风控 / 网络):`fav_action.state='failed'`、保留 candidate、把错误原文通过通知渠道发回,用户可再点一次;
- 忽略:`candidate.decision='rejected'`,7 天后随候选一起删除;
- 拉黑:写 `up_blacklist` + 该 UP 所有待决策候选 `rejected`,不触碰已有 `video`;
- token **一次性**(成功后作废)、**24 小时过期**、`nonce` 防重放;
- **GET 绝不产生写副作用**,从根上避免 Telegram/微信/钉钉的链接预览预取误写;
- 若某渠道不支持按钮又想要"一步到位":确认页可用 JS 在加载后自动 POST(预览机器人不执行 JS),但仍保留手动按钮兜底。
### 7.3 加入后的位置计算
沿用 `bili-fav` 已验证的规则(同一 UP 连续、组内发布时间倒序、组间按该 UP 最早视频时间倒序):
```
target_folder =
同 UP 在库视频中最早一条的 folder_id # 优先,见 §1.3
若没有 → 当前自然年的夹
若该年未配置 → 最新年度夹 + 提示"建议新建新夹"
if 已在夹内: return "已在收藏夹,位置 #N"
anchor = 该 UP 组内比它新的最后一条
- 若同 UP 组存在:插到"该 UP 最新的那条"之前(即组头)
- 若不存在:新组,按组间规则插到"最早视频时间比它小的第一个组"之前
调用 deal(add_media_ids=target_folder, rid=aid, type=2, csrf=...)
调用 sort(sort="<prev_aid>:2:<aid>:2") # 把 aid 插到 prev 之后
校验 ids 序列里 aid 的位置
```
> 举例:某 UP 的旧视频在 2026 夹,他 2027-01-02 的新视频**仍进 2026 夹**,并插在该 UP 组头——这样收藏夹里同一 UP 永远是连续一段,不会跨年断裂。
>
> **失败处理**:`deal` 报错(夹满/权限/风控)→ `fav_action.state='failed'` + 原样记录错误信息 → 通过通知渠道告知用户 → 视频保持 `candidate` 状态,仍可重试。**不自动换夹、不自动丢弃。**
**幂等**:加入前先查 `ids` 是否已在夹内;并发/重复点击用 `fav_action.state` 锁。
### 7.4 年度夹切换
- 配置 `folders: [{year: 2025, media_id: 4451053851}, {year: 2026, media_id: 3700023351}]`;
- **系统不建夹**,年末人工建好后把 `media_id` 加进配置(可热加载,无需重启);
- 选夹规则见 §1.3(同 UP 最早视频所在夹优先);
- 12 月中旬开始在看板/通知里提醒"距年末 N 天,请准备下一年度收藏夹",避免跨年时新 UP 的视频无处可放。
---
## 8. 热度算法
### 8.1 综合热度(存量)
```
x_i = log1p(metric_i)
z_i = (x_i - median(x)) / (IQR(x)/1.349) # 稳健标准化
score = Σ w_i * z_i
w = {view:0.30, like:0.20, coin:0.20, favorite:0.15,
share:0.10, reply:0.03, danmaku:0.02}
```
- 权重可配置,看板可切预设(均衡/互动向/传播向);
- 用中位数 + IQR 而非 max-min,避免单个爆款把其他视频压平;
- 硬币权重高:B 站硬币有每日限额,是强意愿信号;
- 另设"互动率榜":`(like+coin+fav)/view`,识别小而精。
### 8.2 增长榜
> 看板 UI 中“增长”标注为**播放增量**(区间内绝对增量),“增速”标注为**播放增速**(增量 / 基线播放)。近 7/30 日基线取不早于 `最新快照 - N 天` 的最近一条成功快照。近 1 日:有更早日期快照则用前一日;否则当日发布基准为 0,更早发布的新入库用当天第一次成功采样(`view_open`)。
```
d7 = (view_today - view_{today-7}) / 实际间隔天数
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 | — |
| 近 7 天播放增量 | `d7(view)` | — |
| 近 7 天增速 | `r7(view)` | 基线播放 `>= 1000` |
| 互动率 | `(like+coin+fav)/view` | `view >= 100` |
| 单项榜 | 7 项直接排序 | — |
| UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — |
**冷启动**:近 1 日不要求跨日快照。当日发布相对 0,只进播放增量、不进播放增速;更早发布且当天入库的片子相对 `view_open`(第二次采样走晚间全量,不进小时快照)。近 7/30 日仍要跨日快照。缺失日不插值。
### 8.3 趋势
- 单视频:7 项折线,缺失日期断开;
- 按 UP:该 UP 全部视频的多线/堆叠;
- 区间:7/30/90/全部。
### 8.4 粉丝维度(独立,不参与热度计算)
```
fan_delta_1d(d) = follower(d) - follower(d-1)
fan_delta_7d(d) = follower(d) - follower(d-7)
fan_delta_cum = follower(d) - follower(首日)
```
- 单独一条曲线(默认近 90 天,可切 7/30/全部);
- **只画粉丝数**,不叠加 reaction 视频数/播放量(两者与咻咻满本人涨粉没有稳定因果关系);
- 缺失日不插值,曲线断开并标注;
- 首日没有前值,第一条增量从第 2 天开始。
### 8.5 时长效率与 UP 内容效率(第 9 轮)
Reaction 视频时长差异极大(100~336 分钟),而播放是累计计数;若不加时长分母,长视频会天然占优,短视频/切片则天然吃亏。因此新增与时长相关的口径:
```
内容小时 = duration / 3600 # 多 P 的 duration 已是各 P 之和
内容效率 = (view + κ_v·μ₀) / (内容小时 + κ_v) # 榜单口径(收缩后)
原始效率 = view / 内容小时 # 详情页口径
互动效率 = (互动 + κ_v·μ₀ᵢ) / (内容小时 + κ_v)
UP 内容效率 = EB 收缩后的 Σview / Σ内容小时
```
- 分母只用总时长,**不用分 P 数**:B 站播放记在 bvid 上,`view / pages` 没有统计含义;`pages` 仅用于详情展示与包装诊断;
- **视频级也做 EB 收缩**:`κ_v` 默认 60 分钟(配置 `analytics.video_shrink_minutes`),`μ₀` 为全库播放/小时。短时长视频的暴露量小、速率估计噪声大,收缩后不再虚高;2–3 小时的长视频几乎不受影响;
- 效率榜与综合热度榜只统计**播放 ≥ `analytics.min_view_for_ranking`(默认 2000)** 的视频;播放过低的视频多为小样本噪声,不上榜;
- 效率榜只统计 `duration >= analytics.min_duration_seconds`(默认 600 秒)且发布满 `analytics.maturity_days`(默认 2 天)的视频;不足者标注"样本不足";
- **不用无界年龄项**:reaction 视频通常在发布前两天完成数据积累、两周内接近平台期,`log(age)` 会系统性惩罚老视频,故采用"成熟窗口筛选"而非年龄回归;
- `duration` 为空或 ≤ 0(通常只在失效视频出现)不进入效率榜,不插补;
- UP 效率用 Gamma–Poisson 经验贝叶斯:`rate = (Σview + κ·μ₀) / (Σ内容分钟 + κ)`,`μ₀` 为全库播放/分钟,`κ` 自适应取全库 UP 总内容分钟中位数(配置 `analytics.shrink_minutes` 可覆盖,缺省回退 120 分钟);
- 视频榜默认 Tab 为内容效率,原始播放榜保留;互动单项只保留播放/投币/收藏/评论,点赞、弹幕、转发并入"互动";
- **UP 榜的"视频数"与"总时长"并列展示、分别可排序,不合成单一产出分**(两者高度共线,加权求和的权重是任意的)。
---
## 9. 看板
### 9.1 页面
| 页面 | 内容 |
|---|---|
| 首页 | 最新动态优先:标题下显示「收录视频 N」;顶部四个数据卡片为「今日入库 / 播放增量 / 7 日粉丝变化 / 数据日期」(前三项可点击跳转);下方为最近发布、增长动态、热度速览三个区块(竖排卡片,移动端每行 2 个、PC 每行 3 个,每处前 6 条);采集/候选等运行期信息只在登录后的「系统」「候选」页可见 |
| **视频** | 排序下拉:内容效率(默认)/综合热度/发布时间/入库时间/播放/投币/收藏/评论/互动率/播放增量/播放增速;时间下拉:全部/7 天/30 天/90 天/1 年/年份;搜索框约 230px;不再按收藏夹过滤 |
| **UP主** | 独立入口;排序下拉:内容效率/互动效率/内容量/总播放/视频数/平均播放/总互动/最近发布;时间下拉同上;不按视频数筛选 |
| 工具条 | 顺序为「排序」(带 label)→ 时间(无 label)→ 右侧自适应搜索框(最大 320px,放大镜按钮内嵌),gap 10px,PC 与移动均一行 |
| 数据说明 | 数据来源与更新节奏、三个时间概念、指标公式、时长/成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取当前配置 |
| 视频详情 | 元数据 + 分 P 数 + 七项指标 + 内容/互动效率 + 入库时粉丝数 + 单指标趋势 + 同 UP 对比 + 快照表 |
| UP 页 | 首次/最近观看时间、全部视频(默认按发布时间排序)、聚合趋势、单视频对比、总时长、播放/小时与互动/小时、入库时与当前粉丝数 |
| **粉丝页** | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(标注起始日期)+ 最近 14 天记录;支持 7/30/90/全部与自选起止日期,范围不超过一年按日绘制,超过一年按每月峰值日期绘制;只画粉丝,不叠加视频数据 |
| **候选池** | 候选、得分、通知时间、剩余 TTL;只读展示,操作指向 Telegram/CLI |
| **系统状态** | 最近任务、采集成功率、下次任务、备份、鉴权、黑名单与访问统计(PV/UV) |
### 9.2 技术
- 单 `index.html` + 原生 ES Module,无构建链;
- History API 路径路由(`/rank`、`/video/{aid}`、`/up/{mid}`),服务端对无扩展名的非静态路径回退 `index.html`;
- 排序、筛选和趋势指标切换用 `replaceState` 更新 URL 并重新取数,保留滚动位置,不整页刷新;
- 首页/榜单聚合结果缓存 1 小时,粉丝结果缓存 24 小时;新快照、收藏夹同步、发现、收藏操作和粉丝导入会通过 `kv` 版本号立即失效对应缓存;
- 图表本地 vendored ECharts(Apache-2.0,离线);
- `/api/*` JSON,前端只渲染;
- 移动端优先,底部主导航 5 项(首页/视频/UP主/粉丝/说明);桌面端左侧导航;「候选」「系统」入口放顶栏右侧(未登录为「登录」),避免移动端底部拥挤;
- 首页增长榜在不足 2 天快照时显示空态,不伪造 0。
### 9.3 只读边界与鉴权
- P2 Web 业务接口只读,写方法统一 `405`;数据管理继续通过 Telegram/CLI/数据库;
- 首页、榜单、视频/UP、粉丝页面和接口对访客开放;
- 配置 `web.auth_token` 后,“候选”和“系统”页面及其接口需要登录,启用登录页 + HttpOnly Cookie,或使用 `Authorization: Bearer <token>`;
- 未登录访问 `/candidates` / `/system` 会跳转 `/login?next=<原路径>`;
- 本地回环地址且未配置 `web.auth_token` 时视为无鉴权,全部页面可见;
- 非回环地址且未配置 token 时拒绝启动;
- 不做多用户(YAGNI)。
---
## 10. 部署
仓库已提供:
```text
Dockerfile 单容器镜像(只安装 httpx 0.28.1)
compose.example.yml 挂载 config.jsonc + secrets.json → /config、./data → /data
.dockerignore 排除凭据、数据、测试与本地环境
config.docker.example.jsonc 服务器业务配置样例(0.0.0.0 + /data 绝对路径)
secrets.example.json 凭据样例(auth 回写目标)
```
- 单容器跑「调度 + Web」,数据卷 `/data/app.db`;
- **不拆多容器、不依赖宿主 cron**:运营量级是"每小时几次请求 + 每天千次请求",单体进程完全够;调度用内置线程,避免宿主机差异;
- 镜像预计 120~150MB,多架构 amd64/arm64;
- 公网:Nginx/Caddy 反代 → TLS(Caddy 自动证书最省事);
- 日志 stdout(JSON 行);
- 备份:每日 `sqlite3 /data/app.db ".backup /data/backup/app-YYYYMMDD.db"`,保留 14 天。
**进程结构**
```
scheduler 线程:discovery(每 discover_minutes,避开 23:00–01:00) / snapshot_seed(每日 00:00) / snapshot(每日 23:15,不因其它任务 skip) / fans(每日 23:15,不避开) / snapshot_recent(小时,避开 23:00–01:00) / candidate_cleanup(每日,避开 23:00–01:00)
web 线程:ThreadingHTTPServer :8080
telegram:getUpdates long polling(回调 + 私聊文本入库)
storage:SQLite WAL,单写线程
```
---
## 11. 风险清单(更新)
| 风险 | 影响 | 概率 | 缓解 |
|---|---|---|---|
| 搜索排序抖动 + 只取 2 页 | 漏抓个别新视频 | 中 | 每小时扫一次 + 候选池 + 私聊手动入库兜底;业务上可接受 |
| 搜索结果噪声 | 通知太吵 | 中 | 轻量规则降噪 + 人点确认;不达阈值进候选池 |
| 收藏夹与库不一致 | 库内视频不在收藏夹 | 低 | 收藏夹已解耦;人工维护收藏夹,`favsort.py` 只做重排序 |
| 收藏夹 1000 条上限 | 重排序目标夹过满 | 低 | 由人工新建收藏夹;`favsort.py` 仅排序,不自动加条 |
| 黑名单误伤 | 漏掉正常 reaction | 低 | 黑名单可随时移除;被排除的 UP 不写候选表,但可从日志审计 |
| `infos` 静默丢条 | 重排序计划缺条 | 低 | `favsort.py` 用 `请求 ids - 返回 ids` 判断缺失 |
| `infos.reply` 恒 0 / 缺 like/coin/share | 统计不全 | **确定** | 日更以 `view` 为唯一事实来源,`infos` 仅用于重排序 |
| `b23.tv` 短链失效或改版 | 手动入库解析失败 | 低 | 解析失败会在 Telegram 回执提示;可直接改发 BV 号 |
| GET 链接被预览预取 | 无(已无写入型 GET) | — | 写操作走 Telegram 消息/回调,Web 全只读 |
| `sort` 写失败 | 收藏夹顺序不一致 | 中 | `favsort.py` 回读校验、失败冷却重试、可 `run` 续跑 |
| 视频失效 | 日更报错 | **高** | `view` 返回 `-404` / `62002` / `62012` → `removed`,不再请求 |
| 接口下线 | 功能中断 | 中 | 接口层抽象 + 降级(旧版 search);监控告警 |
| 风控 `-352/-412` | 采集中断 | 低(实测未见) | 限速 + 抖动 + 指数退避 + 告警 |
| SQLite 并发 | 写冲突 | 低 | WAL + 单写线程 |
| cookie 过期 | 只读采集失败 | 中 | `python app.py auth --account read|write` 扫码续期;`view`/搜索匿名可用作降级 |
---
## 12. 待确认
**无。** 第 10 轮口径已确认:入库只写本地库;收藏夹解耦、不再追踪;手动入库支持视频链接 / BV 号 / av 号 / `b23.tv` 短链;`bilibili.write` 仅用于 `favsort.py` 重排序。
进入运行前只需要你提供**运行期凭据**(不建议写进本文档):
- Telegram `bot_token` + 私聊 `chat_id`
- B 站 cookie(`read` 必需;`write` 仅重排序用;也可先用 `auth` 扫码生成)
- 收藏夹 `media_id`(`favsort.py` 重排序目标;已有 4451053851 / 3700023351)
---
## 13. 验证记录
环境:Windows / Python 3.14 / 现有 `bili-fav/config.json`(mid=28802351,SESSDATA 有效至 2027-04-29)。
| # | 验证项 | 结果 |
|---|---|---|
| 1 | WBI 关键词搜索 × 4 | 全部 `code=0` |
| 2 | WBI 搜索匿名 | `code=0` 可用 |
| 3 | 旧版 `search/type` | `code=0`,可降级 |
| 4 | UP 主搜索 | 咻咻满 mid=37754047(276.7 万粉) |
| 5 | 空间投稿 | Reaction厂 mid=230020975,251 投稿 |
| 6 | `view` 取 7 项 stat | 完整 |
| 7 | `view` 匿名 / 仅设备指纹 | 均 `code=0` |
| 8 | `archive/stat` | **HTTP 404 已下线** |
| 9 | 批量 `view?aid=1,2` | `-400` 不支持 |
| 10 | 连续 12 次 `view`(0.6s 间隔) | 无风控,0.07~0.14s/条 |
| 11 | 搜索分页深度 | 上限 ~1000(50 页),排序非严格时间序 |
| 12 | 收藏夹容量 | 3700023351=664、4451053851=167 |
| 13 | 充电专场视频 stat | 正常,非付费墙 |
| 14 | 664 条收藏 UP 分布 | 392 个 UP;269 个只投 1 条;Top100 覆盖 52.6% |
| 15 | 664 条标题规则回测 | 92.6% 含 reaction 类词;4.7% 含切片类词 |
| 16 | **`infos` 批量上限** | **400 条/次 OK(0.2s)**;664 条一次请求 URL 12KB,返回 648 条 |
| 17 | **`infos` vs `view` 对照** | play/favorite/danmaku **完全一致**;`reply` **恒 0**;无 like/coin/share |
| 18 | **失效判定** | `attr=9` 20 条 + 静默缺失 16 条 = **36/664(5.4%)** |
| 19 | `infos` 200 条分块 | `code=0`,稳定 |
| 20 | **粉丝数接口** `x/relation/stat` | `code=0`,匿名可用,`follower=2767264` |
| 21 | **搜索 item 的 duration 格式** | `分钟:秒`(`105:5`、`336:31`),超 60 分钟不转小时 |
| 22 | **搜索 item 自带统计字段** | `play/like/favorites/danmaku/review/duration/cover/pubdate/mid/uname` 齐全,通知可用索引值 |
| 23 | **初始黑名单 9 个 mid 核对** | `x/web-interface/card` 全部 `code=0`,昵称/粉丝数已记录(§1.6) |
| 24 | `x/web-interface/view` 七项 stat | `code=0`,view/like/favorite/coin/share/reply/danmaku 齐全 |
| 25 | `view` 不可访问返回码 | 实测为 `62012`;代码同时兼容 `-404`、`62002`、HTTP 404 |
| 26 | `x/relation/stat` 粉丝数 | `code=0`,匿名可用,实测 `follower=2767268` |
| 27 | `x/web-interface/nav` 登录态 | `code=0`、`isLogin=true`、mid=28802351 |
| 28 | **P1 真实全量快照** | 795 条全部 `ok=1`、0 失败、0 移除;耗时 1124s;粉丝 `follower=2767270`;日备份 `app-20261031.db` 含 795 条快照 |
| 29 | **空收藏夹 `ids` 契约** | `4413147139` 返回 `{"code":0,"data":[]}`,`data` 键存在且为数组;缺失/`null` 按协议异常处理 |
| 30 | **迁移 v2/v3 与增量同步** | 真实库升级到 schema 3,自动生成 `app-pre-v1-*` / `app-pre-v2-*`;同步 167+664 条无新增/移除,主归属无矛盾 |
| 31 | **P3 真实发现** | 4 次 WBI 搜索、168 条结果、161 已入库、6 条超时间窗、1 条候选(score=6) |
| 32 | **P3 Telegram 通知** | 真实 Bot 发送成功;发现重复结果后已修复按 aid 去重,离线测试覆盖 |
| 33 | **P3 同步联动** | 候选被人工加入收藏夹后,同步识别 1 条新增并当天补采快照;候选行自动删除 |
| 34 | **P2 预览数据** | 复制真实库回填 7 天:796 条视频、4,606 条合成快照、3 条失败、4 条候选、1 条预览黑名单 |
| 35 | **只读页面** | 首页/视频/UP主/视频详情/UP 详情/粉丝/数据说明/候选/系统页面可访问;390px 与 1280px 下无脚本错误 |
| 36 | **P2 只读边界** | 业务 POST/PUT/PATCH/DELETE 均返回 405,请求前后数据库行数不变 |
| 37 | **粉丝历史回填(CSV)** | 从 B 站粉丝历史 CSV 导入 2020-06-03~2026-10-31 共 1788 条,替换旧 91 条;155 个缺失日保持断开 |
| 38 | **迁移 v4 与时长效率榜** | 预览库升级到 schema 4;`/api/videos?sort=efficiency`、`/api/ups?sort=efficiency` 正确排序,`min_duration_seconds=600` 与 `maturity_days=2` 过滤生效,`duration` 缺失不参与 |
| 39 | **粉丝快照回填(真实性)** | 预览库 488 个 UP、796/796 视频补齐 `up_follower`,0 失败;历史视频标记 `backfilled=1` |
| 40 | **导航拆分与数据说明页** | `/videos`、`/ups`、`/about` 均返回 200;`/api/about` 返回 schema 4、当前阈值、权重与粉丝覆盖度 |
| 41 | **全量离线测试** | 32 个 `tests/test_*.py` 全部通过(含新增 `test_up_profile.py` 与迁移/数据库断言更新) |
| 42 | **内容效率噪声修正** | 预览库实测:视频级 EB 收缩(κ=60 分钟)+ 播放 ≥ 2000 后,效率榜 Top10 全为 ≥110 分钟视频;「什么哦吼吼」4 条不再入榜;综合热度榜同样应用 2000 门槛(637 条) |
| 43 | **榜单交互改版** | 排序/时间改为下拉;时间筛选实测 `year=2026` 640 条、`year=2025` 156 条(合计 796);`sort=published` 可用;`/api/overview` 返回 years `[2026,2025]`;`year=1800` 返回 400 |
| 44 | **第 10 轮全量离线测试** | 32 个 `tests/test_*.py` 全部通过;新增 `test_video_ref.py`、`test_library.py`,重写 `test_telegram.py`,删除收藏夹同步相关测试 |
| 45 | **迁移 v5** | 历史 `video.status='unfaved'` 归一为 `active`;`app-pre-v4-*` 自动备份;`folder` / `folder_resource` 保留 |
**未验证(需授权或外部配合)**:
- 真实 Telegram 私聊文本入库与按钮回调(长轮询与解析逻辑已离线测试);
- 真实 `b23.tv` 短链跳转(客户端跟随重定向逻辑已离线测试);
- 手机扫码 `auth` 完整链路;
- 长期(>72h)每小时扫描的风控表现;
- `favsort.py` 对真实收藏夹的重排序(写操作,需用户确认计划后手动执行)。
**本轮有意不做**:基于粉丝数的"破圈"榜单(只采集与回填数据,见 §14)。
---
## 14. 下一步
P0、P1、P2、P3、第 9 轮与第 10 轮已实施;P2 交付了只读本地看板与可重复生成的 7 天预览数据。
第 10 轮(2026-11-01)交付:
1. 收藏夹解耦:删除 `folder_sync`、`FavoriteActionService`、`app.py sync` 与调度同步任务;`folder` / `folder_resource` / `folder_id` 仅保留历史;
2. 迁移 v5:历史 `unfaved` 归一为 `active`;
3. 入库:候选确认与 Telegram 私聊手动入库统一走 `services/library.py`,只写本地库并补当天快照;
4. 手动入库解析:`video_ref.extract_video_refs`(BV/av/URL/`b23.tv`)+ `bilibili.client.view?bvid=` / `resolve_short_link`;
5. Telegram:`allowed_updates` 增加 `message`;按钮改为「入库 / 忽略 / 拉黑」(`add:`,兼容 `fav:`);
6. 写鉴权收口:删除 `write_client.deal`,`bilibili.write` 仅 `favsort.py` 重排序使用;
7. 配置移除 `schedule.folder_sync_minutes` / `full_folder_refresh_hours`;
8. 看板文案改为「历史收藏夹」,候选页改为「入库」。
仍待处理:
1. 真实 Telegram 文本入库 / 按钮回调 / `b23.tv` 跳转的补充验证记录;
2. 基于粉丝数的"破圈"维度(播放/入库时粉丝数、粉丝分段、UP 中位数)——数据已就绪,榜单未实现;
3. P4(容器化 + 反代)继续保持可选。
第 12 轮(2026-11-01)交付:**基础访问统计(PV/UV)**:
1. 迁移 v7 新增 `visit(stat_date, ip, path, hits, first_at, last_at)` 聚合表;
2. 前端 `sendBeacon` 埋点 `POST /api/visit`;服务端内存缓冲、每分钟批量落库,关闭时 flush;
3. 来源 IP 兼容「域名直连公网 EIP / Cloudflare / EdgeOne」:nginx 用 realip 按 CDN 网段归一化到 `X-Real-IP`,应用仅在 socket 对端可信时采信(见 `deploy/nginx/`);
4. `/system` 系统状态页展示近 30 天 PV/UV 与 Top 页面;Web 只读边界放宽为显式 POST 白名单;
5. 纯函数 `client_ip.py` 与内存缓冲 `web/visits.py` 均有离线单元测试。
> 本地数据:第 9 轮起本地只保留可再生的预览库 `data/preview/app.db`(`tools/seed_preview_db.py` 生成),
> 原 `data/app.db` 真实副本已删除;需要重新生成或恢复时,用 `--source data/backup/app-local-before-reseed-20261101.db`。