940 lines
56 KiB
Markdown
940 lines
56 KiB
Markdown
# 咻咻满 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 占用或 no_source 则 10 分钟重试,CLI: snapshot-seed) / 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`。
|