Files
re-xxm/project.md
T

828 lines
43 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 与一键加入)已实施**
> 最后更新:2026-10-31(第 8 轮:P2 只读看板与预览数据落地)
> B 站探测均为只读(GET),尚未调用 `fav/resource/deal`、`fav/resource/sort`
---
## 0. 结论摘要
**判定:可行,工程难度中等偏低。** 四个功能点都有稳定可用的官方 Web 接口,且匿名即可取数。
| 功能点 | 主力接口 | 实测 |
|---|---|---|
| 视频发现 | `wbi/search/type` | ✅ `code=0`,匿名可用,单关键词上限 800~1000 条 |
| 收藏夹同步(库的真相源) | `fav/resource/ids` + `fav/resource/infos` | ✅ infos **一次 400 条 / 0.2s**,664 条全量 2 次请求 |
| 每日统计 | `view?aid=` | ✅ 7 项指标齐全,0.07~0.14s/条,无风控 |
| 一键加入 | `fav/resource/deal` + `fav/resource/sort` | 排序语义前作已验证;加入待授权实测 |
| 看板 | 本地 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 两个数据来源的关系
```
人工加收藏(兜底、可信) ──┐
├──► video 表(status=active)──► 每日统计 ──► 看板
搜索候选 + 点击确认(补充) ──┘
```
- **收藏夹是真相源**:用户手工加进去的,默认就是"符合条件",系统自动入库;
- **搜索是补漏**:只发通知,点确认才入库,同时顺手加进目标收藏夹;
- **日更对象** = 库内 `active` 视频(跨年度夹累计),与收藏夹当前内容解耦;
- **黑名单只挡"发现"**:收藏夹里已有的黑名单 UP 视频一律当普通视频处理,不判断、不排除、也不反向更新黑名单。
### 1.5 配置与凭据(统一放配置文件)
所有可变项与敏感信息都在一个配置文件里(建议 `config.json`,`chmod 600`,`.gitignore` 排除):
```jsonc
{
"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"
},
"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, "folder_sync_minutes": 60,
"snapshot_at": "00:01", "candidate_ttl_days": 7 },
"notify": { "min_score": 2, "max_items_per_message": 10 },
"classify": {
"require_title": "咻咻满",
"positive": ["reaction", "re", "反应", "看", "听", "初听", "首次", "点评", "锐评", "路人", "震惊", "双人"],
"charge": ["充电", "加急", "专场"],
"negative": ["歌切", "切片", "纯享", "搬运", "完整版"],
"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**,启动时只打印"已配置/未配置";
- 容器里把配置目录挂成**可写卷**(`-v ./config:/config`,权限 600),因为 `auth` 扫码续期后要回写 cookie;
- `python app.py auth --account read|write` 对指定账号扫码续期,只替换该账号身份字段、保留该账号设备指纹;
- `read` 只用于年度收藏夹同步、快照和查询;`write` 只用于目标收藏夹、`deal` 和 `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 次拿全 | 收藏夹监控 |
| 收藏夹批量详情 | `GET x/v3/fav/resource/infos` | 是 | ✅ **一次 400 条**,0.2s;664 条返回 648(缺 16) | 元数据同步/导入 |
| 加入收藏夹 | `POST x/v3/fav/resource/deal` | 是 + csrf | 文档明确,待实测 | 一键加入 |
| 收藏夹内排序 | `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 含 "咻咻满"(全半角/大小写归一化后),否则直接丢弃
up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进)
score = 0
+2 标题含 reaction|re|反应|看|听|初听|首次|点评|锐评|路人|震惊|双人
+2 标题含 充电|加急|专场
+2 时长 > 60 分钟
+1 时长 > 30 分钟(与上一条不叠加,取大)
-5 标题含 歌切|切片|纯享|搬运|完整版
-3 UP 为 咻咻满本人(mid=37754047)
通知阈值 = 2
0 <= score < 2 且含"咻咻满" → 进看板"候选池"(7 天后删除),不通知
score < 0 → 丢弃
```
**几条设计依据**:
- 对现有 664 条收藏回测,**92.6% 的标题含 reaction 类词**,规则召回足够;误判代价只是"多发/少发一条通知",最终由人点击决定;
- **"直播回放"不再算负向词**:确实有人先直播看、再发直播回放,这类仍是 reaction;改由"UP 黑名单"处理专发回放/切片的号;
- **时长是强正向信号**:实测这批 reaction 视频时长普遍 100~336 分钟(`105:05`、`158:24`、`336:31`),30/60 分钟阈值能把大多数切片(十几分钟)区分开;
- 时长直接取**搜索结果自带的 `duration` 字段**(格式是 `分钟:秒`,超过 60 分钟写作 `105:5`),**不需要额外请求**;
- UP 黑名单在配置文件里维护,可热加载,后续从"误通知"记录里一键加入。
### 3.4 通知内容
每条候选一条消息,包含:
- 封面缩略图 + 标题 + UP 名 + 发布时间 + **时长** + 当前播放/点赞
- 三个动作按钮:
- **「收藏」**:加入目标收藏夹并入库(见 §7)
- **「忽略」**:该 aid 标 `rejected`,不再提醒(同一轮同 UP 的其他候选不受影响)
- **「拉黑」**:把该视频的 **UP 拉入 `up_blacklist`**,同时把该 UP 的其他待决策候选一并标记为 `rejected`;**已入库/收藏夹里的该 UP 视频不受影响**
- 「打开视频」链接(`https://www.bilibili.com/video/<bvid>`)
**黑名单语义(明确边界)**:
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 个数据点"的视频标注"样本不足"。
---
## 5. 每日数据追踪
### 5.1 任务
```
每天 00:01:00(Asia/Shanghai):
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 分钟 |
- 00:01 启动,最晚 01:00 前完成,不影响白天使用;
- 若单条 0.1s + sleep 1.2s ≈ 1.3s,实际按 1.2s 估算偏保守。
### 5.3 幂等与口径
- 唯一键 `(stat_date, aid)`,`INSERT ... ON CONFLICT DO UPDATE`;
- `stat_date` 用本地日期,不用 UTC(避免 00:01 落到前一天);
- 失败重试 3 次后写 `ok=0` 占位,**不写假数据**;看板显示"缺失"而不是 0;
- 完成标记之外还要检查当天待采集合:新入库/恢复视频立即补采,`ok=0` 项在续跑时重试;
- 强制重采开始前先写未完成标记,中断后仍可续跑;
- 日增量查询时用 `LAG()` 跳过缺失日,不插值。
### 5.4 粉丝数追踪(新增)
```
每天 00:01 与视频快照同一个任务里,追加 1 次请求:
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(降级时标记)
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);
```
**要点**
- `status` 是核心状态机:
- `pending` 搜索候选,等确认(只在 `candidate` 表,不进 `video`)
- `active` 在库,参与日更
- `unfaved` 用户取消收藏,**仍继续日更**,看板可筛
- `removed` 视频失效(`-404` / `attr=9` / `infos` 缺失),停止请求但保留历史
- `dropped` 用户手动"移出库",**停止发现与追踪**;若之后又出现在收藏夹,同步任务会自动改回 `active`
- `rejected` 用户点过"忽略",不再通知
- `candidate` 与 `video` 分表:候选可能永远不入库,避免污染主表;候选 `first_seen` 超过 **7 天**且 `decision IS NULL` → **直接删除 candidate 行**(按你的偏好),去重由 `seen_aid` 保证,不会重复通知;
- `seen_aid` 只存 `(aid, first_seen, source)`,每条约 30 字节,按 5 条/天增长,10 年也不到 100KB;
- `up_blacklist` 只影响发现:`folder_sync` **不查**这张表,收藏夹里黑名单 UP 的视频按普通视频入库;
- `folder_id` 的取值遵循 §1.3:优先同 UP 最早视频所在夹,其次当前自然年夹;
- 不预存"日增量",全部查询时算(历史数据修正后不会自相矛盾)。
---
## 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` 从 `config.json` 读(见 §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 中“增长”标注为**播放增量**(区间内绝对增量),“增速”标注为**播放增速**(增量 / 基线播放)。两者基线都取不早于 `最新快照 - N 天` 的最近一条成功快照。
```
d7 = (view_today - view_{today-7}) / 实际间隔天数
r7 = (view_today - view_{today-7}) / max(view_{today-7}, 1)
```
| 榜 | 口径 | 基数门槛 |
|---|---|---|
| 累计热度 | §8.1 | — |
| 近 7 天播放增量 | `d7(view)` | — |
| 近 7 天增速 | `r7(view)` | `view >= 200` |
| 互动率 | `(like+coin+fav)/view` | `view >= 100` |
| 单项榜 | 7 项直接排序 | — |
| UP 榜 | 按 `up_mid` 聚合 Σ/AVG | — |
**冷启动**:不足 2 天数据的视频用"发布后 24h 增量",并标注"样本不足";缺失日不插值。
### 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 天开始。
---
## 9. 看板
### 9.1 页面
| 页面 | 内容 |
|---|---|
| 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量、采集状态 |
| 榜单 | Tab:综合热度/播放/点赞/投币/收藏/转发/评论/弹幕/互动率/播放增量/播放增速;视频与 UP 范围切换;筛选与搜索 |
| 视频详情 | 元数据 + 七项指标 + 单指标趋势(7/30/90/全部)+ 同 UP 对比 + 快照表(含失败标记) |
| UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、平均互动率 |
| **粉丝页** | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(标注起始日期)+ 最近 14 天记录;支持 7/30/90/全部与自选起止日期,范围不超过一年按日绘制,超过一年按每月峰值日期绘制;只画粉丝,不叠加视频数据 |
| **候选池** | 候选、得分、通知时间、剩余 TTL;只读展示,操作指向 Telegram/CLI |
| **系统状态** | 最近任务、采集成功率、收藏夹同步时间、下次任务、备份、鉴权、黑名单 |
### 9.2 技术
- 单 `index.html` + 原生 ES Module,无构建链;
- History API 路径路由(`/rank`、`/video/{aid}`、`/up/{mid}`),服务端对无扩展名的非静态路径回退 `index.html`;
- 排序、筛选和趋势指标切换用 `replaceState` 更新 URL 并重新取数,保留滚动位置,不整页刷新;
- 图表本地 vendored ECharts(Apache-2.0,离线);
- `/api/*` JSON,前端只渲染;
- 移动端优先,底部 5 项导航;桌面端左侧导航;
- 首页增长榜在不足 2 天快照时显示空态,不伪造 0。
### 9.3 只读边界与鉴权
- P2 Web 业务接口只读,写方法统一 `405`;数据管理继续通过 Telegram/CLI/数据库;
- 本地回环地址且未配置 `web.auth_token` 时免登录;
- 配置 token 后启用登录页 + HttpOnly Cookie,或使用 `Authorization: Bearer <token>`;
- 非回环地址且未配置 token 时拒绝启动;
- 不做多用户(YAGNI)。
---
## 10. 部署
仓库已提供:
```text
Dockerfile 单容器镜像(只安装 httpx 0.28.1)
compose.example.yml 挂载 ./config.json → /config、./data → /data(复制为 compose.yml 后使用)
.dockerignore 排除凭据、数据、测试与本地环境
config.docker.example.json 服务器配置样例(0.0.0.0 + /data 绝对路径)
```
- 单容器跑「调度 + 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(每 60min) / folder_sync(每 60min) / snapshot(每日 00:01)
web 线程:ThreadingHTTPServer :8080
storage:SQLite WAL,单写线程
```
---
## 11. 风险清单(更新)
| 风险 | 影响 | 概率 | 缓解 |
|---|---|---|---|
| 搜索排序抖动 + 只取 2 页 | 漏抓个别新视频 | 中 | 每小时扫一次 + 收藏夹监控兜底 + 候选池;业务上可接受 |
| 搜索结果噪声 | 通知太吵 | 中 | 轻量规则降噪 + 人点确认;不达阈值进候选池 |
| 收藏夹 1000 条上限 | 夹满后无法加入 | 中 | 接近 950 时预警;夹满则 `deal` 失败 → **报错并通知**(不自动改夹),由人工新建年度夹并更新配置 |
| 同 UP 归属导致旧夹持续增长 | 2026 夹被 2027 视频撑爆 | **高** | 与上一条同一处置:预警 + 失败告警 + 人工建新夹;不加自动改夹逻辑 |
| 黑名单误伤 | 漏掉正常 reaction | 低 | 黑名单可随时移除;被排除的 UP 不写候选表,但可从日志审计 |
| `infos` 静默丢条 | 误判同步成功 | **高** | 用 `请求 ids - 返回 ids` 判断缺失;缺失即 `removed` |
| `infos.reply` 恒 0 / 缺 like/coin/share | 统计不全 | **确定** | 日更以 `view` 为唯一事实来源,`infos` 只做同步/降级 |
| GET 链接被预览预取 | 误加入收藏夹 | 中 | GET 只渲染页面,写操作走 POST |
| `deal` / `sort` 写失败 | 收藏夹状态不一致 | 中 | 先查 ids 幂等、失败重试、`fav_action` 记录、页面展示失败原因 |
| 视频失效(当前 5.4%) | 日更报错 | **高** | `-404` / `attr=9` / 缺失 → `removed`,不再请求 |
| 接口下线(`archive/stat` 已 404) | 功能中断 | 中 | 接口层抽象 + 降级(旧版 search / infos 三项)+ 监控告警 |
| 风控 `-352/-412` | 采集中断 | 低(实测未见) | 限速 1.2s + 抖动 + 指数退避 + 告警 |
| 跨年切换遗漏 | 新视频进错夹 | 低 | 按 pubdate 选夹 + 1 月 1 日提醒 |
| SQLite 并发 | 写冲突 | 低 | WAL + 单写线程 |
| cookie 过期 | 收藏夹相关接口失败 | 中 | 使用 `python app.py auth --account read|write` 扫码续期;`view`/搜索匿名可用作降级 |
---
## 12. 待确认
**无。** 三轮问答已覆盖:收录口径、发现策略、收藏夹归属与容量、通知渠道与动作、黑名单语义、候选池生命周期、粉丝追踪、部署形态。
进入定稿/实施前只需要你提供**运行期凭据**(不建议写进本文档):
- Telegram `bot_token` + 私聊 `chat_id`
- B 站 cookie(也可以先用 `auth` 扫码生成)
- 年度收藏夹 `media_id`(已有 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 | **P2 只读页面** | 首页/榜单/视频/UP/粉丝/候选/系统 7 个页面在 390px 与 1280px 下无脚本错误 |
| 36 | **P2 只读边界** | 业务 POST/PUT/PATCH/DELETE 均返回 405,请求前后数据库行数不变 |
| 37 | **粉丝历史回填(CSV)** | 从 B 站粉丝历史 CSV 导入 2020-06-03~2026-10-31 共 1788 条,替换旧 91 条;155 个缺失日保持断开 |
**未验证(需授权或外部配合)**:
- `fav/resource/deal` 实际加入 + `sort` 定位(会写收藏夹;需用户点击 Telegram 按钮或授权测试视频);
- Telegram 按钮真实回调(长轮询代码与授权逻辑已离线测试);
- 手机扫码 `auth` 完整链路;
- 各通知渠道的按钮/链接实际表现;
- 长期(>72h)每小时扫描的风控表现;
- 收藏夹真实上限是否 1000。
---
## 14. 下一步
P0、P1、P2、P3 已实施。P2 交付了只读本地看板与可重复生成的 7 天预览数据:
1. 首页、榜单、视频/UP 详情、粉丝曲线、候选池和系统状态页面;
2. 原生 ES Module + 本地 ECharts,本机默认绑定 `127.0.0.1:8080`;
3. 查询、热度、增长和互动率全部在服务端计算,前端只渲染;
4. Web 不提供业务写操作,数据管理继续通过 Telegram/CLI/数据库;
5. P3 的真实 `deal` / `sort` 待用户点击 Telegram 按钮或授权测试视频后补充验证记录。
> P4(容器化 + 反代)继续保持可选。