Files
re-xxm/project.md
T
kairee 337e379514 feat(classify): super-linear positive weighting by hit count
正向改为按命中次数 n(require_title + reaction + charge + weak)做平方加权,使多信号同现的真 reaction 不被单个负向词打穿(1+1>2、1+1+1>5);标题不含 require_title 直接 0 分。库内 796 条有效视频复测:线以下误伤由 47 降至 14。
2026-11-01 19:08:59 +08:00

53 KiB
Raw Blame History

咻咻满 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 配置与凭据(统一放配置文件)

所有可变项与敏感信息都在一个配置文件里(建议 config.json,chmod 600,.gitignore 排除):

{
  "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,
                "snapshot_at": "00:01", "candidate_ttl_days": 7 },
  "discover": { "keywords": ["咻咻满"], "pages": 3, "window_days": 7 },
  "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,启动时只打印"已配置/未配置";
  • 容器里把配置目录挂成可写卷(-v ./config:/config,权限 600),因为 auth 扫码续期后要回写 cookie;
  • python app.py auth --account read|write 对指定账号扫码续期,只替换该账号身份字段、保留该账号设备指纹;
  • 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

第 12 页覆盖 2030 天,而扫描间隔只有 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(每个命中记 1)
n = 1              # 标题含 "咻咻满"
  + count(reaction|反应)
  + count(加急|特急|充电|专场|转场)
  + count(双人|路人|第一次|首次)

score = n * n      # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5)
             + 2   # 时长 > 60 分钟(与下一条不叠加,取大)
             + 1   # 时长 > 30 分钟
             - 6   # 标题含 歌切|切片|纯享|搬运|完整版|录播|录像
             - 6   # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日)
             - 3   # UP 为 咻咻满本人(mid=37754047)

通知阈值 = 4
0 <= score < 4 且含"咻咻满" → 进看板"候选池"(7 天后删除),不通知
score < 0 → 丢弃

几条设计依据:

  • 强正向必须"咻咻满 + reaction"同现:B 站搜索会命中标题根本不含关键词的结果(简介/标签/相似内容),所以"标题含 reaction"本身不足以判定为高置信,必须与 require_title 同时成立;
  • 正向按命中次数超线性加权(n²):多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词(-6)就能把多信号真 reaction 打到线下;1+1>2、1+1+1>5 用平方实现;
  • "听/看"等宽泛词已移除:几乎所有 reaction 标题都含,无区分度;
  • 时长只在已命中 reaction/charge 时加权:避免"完整录播/直播录像"这类既无 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>)

黑名单语义(明确边界):

  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. 数据模型(定稿草案)

-- 视频主表
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);

第 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 从 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 天开始。

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. 部署

仓库已提供:

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) / snapshot(每日 00:01) / candidate_cleanup(每日)
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

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。