DeepSeek Harness Plugin Hub

Publish and manage complete Harness Profiles. Discover Plugins for your next setup.

Explore

PluginsPresetsDocsNews

Community

Publish a pluginContactReport an issue

Resources

Plugin Hub on GitHubDeepSeek HarnessSystem statusPrivacy notice
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

Independent and unofficial. Not affiliated with, authorized by, or endorsed by DeepSeek.

Rank Scan — DSH Plugin for DeepSeek Harness
← Plugins
R

@dsh-external/dsh-rank-scan

Rank Scan

DSH ranking scanner plugin: manually or on a schedule, scan rankings across multiple sites, show changes, and select what to view in the panel

The plugin will be installed here. Keep web if you are unsure.

npx -y @deepseek-ai/dsh plugin --profile web add github:kilo-s/dsh-rank-scan#bff39b88a8ca42449f982660297e5369ea580ad0
READMECompatibilityVersions

Compatibility and provenance

Rank Scan is published as @dsh-external/dsh-rank-scan and currently resolves to version 0.1.0. The Hub verifies its manifest and preserves the exact installation source for reproducible installs.

DSH compatibility
*
Runtime surfaces
web
Release source
github
Registry updated
10/16/2026

Versions

0.1.0stable
10/16/2026

Related plugins

Loading related plugins…

Latest
0.1.0
DSH
*
HMR
Process restart
Tree shaking
Safe tree shaking not declared
Unpacked size
Unavailable
Files
Unavailable
Surface
web
License
BSD-3-Clause
Source
github
GitHub
★ 0
Weekly downloads
0
Last push
10/16/2026
View source ↗
README badge

Click the badge to copy Markdown for your README.

Do you maintain this Plugin?Claim benefit · Priority security scan

Verify the GitHub repository declared in package.json to manage this listing. After you claim it, Hub will prioritize a security scan of the current version and publish the result when it passes.

Claim this Plugin →
Report an issue
DeepSeek Harness Plugin Hub
ProfilesPluginsCategoriesNewsDocsSign inManage Profiles
ProfilesPluginsCategoriesNewsDocsSign in

Related plugins

More verified plugins in search-research.

Free Searchdsh-free-searchFree web search for DeepSeek Harness: 24 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude Dejadsh-dejadeja-vu memory for DeepSeek Harness: the session history of forty other coding agents, searchable and recalled before each step — a local index, no LLM.Builtin Browserdsh-builtin-browserShared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (dsh_browser_auth), CBladebrobladebroStealthy and efficient agentic browser driver for AI agents. Few tools, full control, real stealth, max token efficiency.

README

@dsh-external/dsh-rank-scan

DSH 扫榜插件(定时 + HTTP 路由 + 面板;不注册 DSH 工具)。说一句就扫,扫完在面板里看;不推送、不登录、不抓正文。

0. 快速开始(一页版,先看这个)

装:两步(先退出 DSH,再跑一条命令)

第 1 步:从 Releases 下载最新那个 *.tgz。 第 2 步:完全退出 DSH 桌面版,然后:

pwsh -File scripts\install.ps1 -Spec "<刚下载的 tgz 的完整路径>"

(macOS / Linux:bash scripts/install.sh desktop <tgz 路径>。开发时想直接挂本地目录:-Spec "link:<插件目录>"。)

装完重新打开 DSH 就能用。装的时候必须退出 DSH,这是 DSH 自己的规矩(官方原话:profile "desktop" is managed exclusively by the Electron application)—— 不是本插件的要求,也不需要重启电脑。

想更省事:装了 dsh-market 的话,以后可以在面板「抓什么」页里检查并一键更新。

提示:本仓库不含编译产物(lib/ 会复制源码注释、sourcemap 还带绝对路径),所以不要用 dsh plugin add <本仓库URL> 直接装 —— 请用上面的 Release 包。 想自己从源码构建见 §构建与自测。

验:重开 DSH → 左侧栏会话列表下面应出现「扫榜」入口;或访问 http://127.0.0.1:19387/api/rank-scan/health,返回 {ok:true,...} 即通。

卸:pwsh -File scripts\uninstall.ps1(macOS / Linux:bash scripts/uninstall.sh)。卸载会清掉插件、路由残留与 junction,但保留数据目录 %USERPROFILE%\.dsh\data\rank-scan(配置 / 书签 / 书页缓存都在里面)——想彻底清理自己删这个目录。

侧栏没有「扫榜」入口怎么办(按顺序看):

  1. DSH 启动日志里搜 did not activate 与 pending (waiting for services:前者 = 面板 bundle 没加载成功,后者 = 缺 timer / webServer 服务;
  2. 访问 /api/rank-scan/health:能返回 JSON ⇒ host 侧活着,问题在面板;连不上 ⇒ 插件没装上;
  3. 重装一次:pwsh -File scripts\install.ps1 -Spec "<tgz>"。

数据与隐私:只读站点公开榜单与书页,不登录、不抓正文、不推送;限速 1s/条、每天一轮(定时默认开启,安装当天不自动跑;可在面板「抓什么」页随时关掉,关掉后重载也会保留)。数据只在本地 ~/.dsh/data/rank-scan,不外传。

⚠ 本插件不注册任何 DSH 工具:对外能力只有 /api/rank-scan/* 这些 HTTP 路由 + 面板 UI(面板是手写的 lib/client.js,不经 tsdown 构建)。 插件装配入口是 cordis.patch.yml(由 package.json 的 dsh.bundle.patch 指向)。

现在能做什么

能力状态
rank_scan 工具:立刻扫一次 / 取消本次 / 重试失败榜✅ 可用(起点榜·移动端免 cookie)
五态判定(ok / missing / stale / login_required / truncated)✅
取消(面板/工具,1 个请求内停住)✅
重试本次(只重跑非 ok 的榜,同榜每天 ≤2 次,并回当天记录)✅
防卡死(单请求 15s / 单榜 90s / 单站连败 3 次熔断 / 120s 无进展看门狗)✅
扫描锁 + 心跳(同一时刻只一轮;心跳过期判 crashed 可接管)✅
按数据日期落快照(原子写、同日覆盖=幂等)✅
可选 enrich:逐本反查书页取收藏与精确字数✅(enrich: true,1s/请求)
变化集(rank_diff:五类 + 新入榜/回榜分开 + 在榜天数 + 基准日)✅ 可用
精简索引(永久)/ 保留清理 / 导出(rank_clean / rank_export)✅ 可用
面板(两页签:概览(含「今天的变化」区块)/ 当前榜单)✅ 已浏览器实测(2026-10-14):宿主同源路由 /api/rank-scan/* + 手写 client bundle;改客户端后需刷新页面
面板能力:按站编组 / 列随榜 / 日期回看 / 回落最近可用快照 / 「立刻扫一次·停止·重试」按钮✅
起点 · 签约榜 × 15 分类(默认轻小说;其余分类属拓展,勾了才抓)✅ 可用(移动端口径,与 BB 桌面「签约新书榜」不是同一个榜)
起点图 首订榜(数值型,27 条/日)✅ 可用(实扫 29 条)
起点图 三江·推荐效果(周·7 天收藏增量窗口)✅ 可用(实扫 17 条;未到的天记 null,负增量保留)
刺猬猫 点击榜·周 / 新书榜·周(含换榜判据)✅ 可用
刺猬猫 首订(约)✅ 可用(第三方 newmmapi.yukirus.com,http;标「约值」,空期回退上一期)
番茄 男频新书榜 × 8 分类(数值型;每分类深度可调,上限 30,默认 10)✅ 可用
番茄 在读涨幅榜 / 七日累计(候选池内派生榜)✅ 自动派生,不发请求(不足 7 天标 N/7)
菠萝包 新书榜(点击) / 轻小说热榜(周) / VIP热销榜(周)✅ 可用(首页 SSR,每榜 10 条;书名被站点截断)
版本与一键更新(面板「抓什么」页底部):显示本插件版本;装了 dsh-market 就能一键更新并在需要时按它的裁决重启/刷新;没装则只读显示(npm registry 读数,不给就地更新按钮)✅ 已接线(结构+行为测试覆盖);改了 host 需重新注入才生效
定时每日一扫(默认开启,安装当天不自动跑;可在面板「抓什么」页关掉/改时刻,用户的选择跨重载保留;按站分时,兜底 22:30)✅ 实跑已核(2026-10-15 14:22 定时扫描 17/17 ok);触发判据与「重启不重扫」有单测覆盖
实网全量扫码

用法

// 立刻扫一次(默认「签约榜 × 轻小说」)
rank_scan {}
// 只扫某榜
rank_scan { "boards": "qidian.sign.cat12" }
// 顺带补收藏/精确字数(每本 1 请求,1s 间隔,会明显变慢)
rank_scan { "boards": "qidian.sign.cat12", "enrich": true }
// 只重跑当天失败的榜(不发新请求给已 ok 的榜)
rank_scan { "retry": true }
// 停止当前扫描(下一个请求间隙生效)
rank_scan { "action": "cancel" }

数据目录默认 ~/.dsh/data/rank-scan/(可用插件配置 dataDir 改):

<dataDir>/<数据日期>/<boardId>.json   快照(rows + metric)
<dataDir>/<数据日期>/scan.json        本次扫描元信息(attempt / outcome / progress / 每榜状态)
<dataDir>/state/scan.lock.json        扫描锁(含心跳时间)
<dataDir>/state/retries.json          各榜当日重试次数
<dataDir>/state/cancel.json           取消标志

构建与测试

scripts/build.sh 靠 junction/symlink 把 dsh checkout 的依赖链进 node_modules;受限环境禁止 symlink (实测 fs.symlinkSync → EPERM、fs.cpSync → EIO;PowerShell Copy-Item 可以),因此 dev_build_plugin 会以「Linking build dependencies」后退出 1 失败。

本包因此设计成 零运行时依赖:host 侧不 import @deepseek-ai/dsh-tools / schemastery, 工具用普通对象直接 ctx.tools.register({...});client 侧手写 window.__ModuleLoader__.load({ id, factory }) 形态的 bundle(tsdown 未安装)。

构建与自测:

# 构建(把 src/ 编译成 lib/)
npm install            # 只为拿到 typescript/@types/node(本插件运行期零依赖)
npx tsc -p tsconfig.json

# 跑全部离线测试(node:test,不联网)
pwsh -File scripts/test-local.ps1      # 或逐个跑:node tests/xxx.test.mjs

lib/ 是编译产物,不在仓库里(它会把源码注释原样复制一份,且 sourcemap 含绝对路径)。 需要可分发的包时用 npm pack —— Release 页的 tgz 就是这么产出的。 若只想验证而不想装依赖:CI(.github/workflows/build.yml)在打 tag 时会自动构建并挂到 Release。

更新:重新跑 §0 的安装命令即可(DSH 桌面版需要先退出);装了插件市场的话可以在面板里一键更新。

测试

tests/*.test.mjs 全是离线测试(node:test,零依赖,不联网):

  • scan.test.mjs 五态判定 / 本地数据日期 / 幂等覆盖 / 取消
  • qidian.test.mjs 夹具解析 / 适配器端到端 / 条数异常→missing
  • qidian-fixture.test.mjs 真实响应夹具(tests/fixtures/qidian-sign-cat12.html)
  • qidian-book.test.mjs 书页解析 / enrich 重试≤2 且失败留 null / enrich 计入预算

站点改版时的排查顺序:先跑夹具测试 → 不行就看 tests/fixtures/ 里的真实响应哪里变了。

设计约束(改代码前先读)

  • 词汇统一:站(站点)/榜(榜单)/快照(某榜某数据日的行集)/数据日期/变化集(与上一数据日的差异)/抓取集(抓哪些榜)/显示集/状态五态(ok / missing / stale / login_required / truncated)
  • 缺失一律 null,绝不补 0;日期取本地自然日(忌 toISOString(),北京时间凌晨会差一天)
  • 采集纪律:限速 1s/请求、每日 1 次、失败重试 ≤2、原子写、同日覆盖
  • ⚠️ 不要用 fs.cpSync 复制目录:已实测递归复制目录会 EIO Access is denied(导出在数据目录上直接失败); 一律逐文件 copyFileSync(scan/files.ts 的 copyDirDeep)
  • 唯一测试 seam = runScan({ sites, clock, store, budget, enrich }):站点/时钟/存储/预算全部注入
  • 数据目录与工作区无关;插件不写工作区内文件

面板(三个页签 · 可拖动可缩放浮窗 · 2026-10-14 重做)

  • 浮窗:标题栏拖动移动、右下角把手缩放,位置与尺寸存 localStorage(刷新后保持);右上「收起」缩成小按钮。

  • 概览页:一行汇总(数据日期 / 基期 / N 个榜 / 全部正常或异常数 / 隐藏数 / 上次扫描)+ 按站分组的单表(不再每站重复一遍表头); 异常榜单独折叠区(点「展开异常 N 个榜」才列);下方接「今天的变化」。无基准时涨/新/退一律「—」,不报 0。

  • 当前榜单页:站 → 榜 → 日期 → 排序四级控件;「每榜取 N 条」输入框(带出该榜当前值,保存后下次扫描生效); 「补全数据(起点/番茄)」按钮(见下);点任意一行展开行内详情卡(书名/作者/字数/更新时间 + 「在站点打开」+「复制链接」)。

  • 抓什么页:站 → 榜种 → 分类 两级勾选(含「抓 / 显示」两列)+「恢复默认日常抓取」。改动下次扫描生效。

  • 列口径(2026-10-14 定案):

    • 数值型榜必带主指标列:起点图首订=首订、番茄=在读、刺猬猫首订(约)=首订(原始)、派生榜=在读/对照读数
    • 起点:子分类 + 字数(站点文本「10.09万字」)+ 收藏(默认空,靠补全;空格显示「—」)
    • 起点图 三江:期初收藏 + 今日增量(单列)+ 每日增量 + 已结算天数
    • 番茄:字数 + 在读(+ 有基准时的「在读变化」);已删「站方升降 / 更新时间」(明确不要)
    • 刺猬猫 / 菠萝包:核心列;菠萝包另有评分/字数/收藏(只有前 2 名有值,空格「—」)
    • 「在榜天数」只在真有历史时不占列之外才出现(当天无历史 ⇒ 整列不出现,不留一列「—」)
  • 补全数据(enrich,按需手动):

    • 起点:逐本反查书页 ⇒ 精确字数 + 收藏(适配器内置 enrich,1 请求/本)
    • 番茄:逐本打 /api/book/info ⇒ 干净书名/作者(榜单接口本身因字体反爬缺字;接口不吐书名时回退抓书页 og:title)
    • 结果就地覆盖当天该榜快照与 scan.json(幂等,可重复补);刺猬猫/菠萝包暂不支持(会明确回话,不静默)
  • 跳转:DSH Web GUI 没有内嵌浏览器/侧栏浏览器槽位(已查:无 browser-in-sidebar API 与"在线浏览器"入口), 所以点书名不会在 DSH 侧栏里打开;现方案 = 行内详情卡 + 「在站点打开」(新标签)+ 「复制链接」。

  • 宿主路由(同源、只读本地快照,渲染不联网):

  • ⚠️ 路由前缀不得带尾斜杠:宿主 match() 的规则是 pathname === prefix || pathname.startsWith(prefix + '/'), 所以带尾斜杠的 '/api/rank-scan/' 永远匹配不到 '/api/rank-scan/overview'(会被更短的 '/api' 抢走,症状是全站 404 not found)。 注册必须用不带尾斜杠的 '/api/rank-scan',handler 内再对 pathname 归一化尾斜杠。

  • ⚠️ webServer 服务要晚绑定:运行时注入的实例上直取 ctx.webServer 拿不到,要用 ctx.inject(['webServer'], …)(见 dsh-genui 的做法)

  • 宿主路由(同源、只读本地快照,渲染不联网):

    • GET /api/rank-scan/overview 概览:数据日期 / 基准日 / 五态计数 / 隐藏榜数 / 每站每榜状态与涨新退 / 按站编组
    • GET /api/rank-scan/board?boardId=&date=&sort= 当前榜单:列随榜、缺失显示「—」、当天没快照时回落最近可用并标注日期

v11.1 · 起点图历史日期为空:日期没补零(2026-10-15)

v11 修了「永远抓当日」,但历史日期还是空。 当时的截图显示:日期选 2026-10-01、点「直接查看」, 结果是 0 行 + 一条容易误导的说明。

根因:URL 被拼成了 /shouding/2026-10-1(日没补零)。站点对这个路径返回 200 + 26 字节空页 ⇒ 解析出 0 行 ⇒ 抛出「该日期无数据」,于是看着像「站点没这天的数据」。

请求状态字节/info/ 链接
/shouding/2026-10-1200260
/shouding/2026-10-01200172849291

修法:统一走 ymd(date)(src/sites/qidiantu.ts 导出,内部 padStart(2,'0')), 适配器里三处手工日期串全部换掉。实测:/day 2026-10-01 → 287 行(首本 神宠供应商)、 10-05 → 5 行、10-11 → 15 行、10-14 → 62 行,各日期结果互不相同 ✅

附带发现:这个「未补零 ⇒ 空页」的坑在手工编辑源码时又被我踩了一次 —— tools.edit 对 「String(d.getDate()).padStart(2,'0')」这类行尾长表达式做替换时很容易把尾部吞掉。 现在有单测钉住(ymd(2026-10-01) === '2026-10-01' + 断言路径里不出现 /\d{4}-\d-\d)。

同时修掉:首订页的「作者」恒为空

首订页第 7 格是 等级 作者(如 Lv.5 骆驼祥爷! / 白金 轻泉流响),而原实现去匹配 /author/ 链接 —— 首订页根本没有这种链接 ⇒ 作者列永远空。现在从第 7 格去掉等级前缀取作者, /author/ 链接只作退路。实测:赤羽老师 / 宇智波番长 / 骆驼祥爷! 都出来了。

「抓取并存入本地」会跳回最新 —— 已改

原因:存盘后调 reloadBoard(),而「日期」下拉的状态 sd 还是空 ⇒ 按最新重新取。 现在存盘后把 sd 设为该日期(由 useEffect 触发带 date 参数的刷新); 「直接查看」也把该日期临时补进下拉选项,两种方式都能在界面上看出「在看哪天」。 实测:存 2026-10-11 后,日期下拉 = 2026-10-11,表格 15 行、首本「同时穿越…」✅

v11 · 四项修复(2026-10-15)

1. 起点图历史日期直查根本没按日期查(真 bug,最严重)

症状:日期下拉里不同日期显示的作品完全一样。

根因:sites/qidiantu.ts 里读 ctx.dateOf 决定要不要拼日期路径:

const want = (ctx as any).dateOf as string | undefined
const url = want && want !== todayStr ? '.../shouding/' + want : '.../shouding/'

但 dateOf 在整个仓库里没有任何地方被设置过(grep dateOf 只有这一处读取)⇒ want 恒为 undefined ⇒ 永远请求当日 /shouding/。于是「直接查看 10-11」拿回的是今天的榜 —— 数据没变,所以看着像「都一样」。

修法:直接用 ctx.now() 推出目标日期(/day 与 /repair 路由本来就把 now 设成目标日期 12:00 +08:00)。

实测:/day 2026-10-11 → 15 行(同时穿越…/半岛铁拳…),/day 2026-10-14 → 62 行(骆驼祥爷!…)✅ 有区分了。

反证:直接抓站点也证明历史是有的 —— /shouding/2026-10-11 23679 字节 / 首本「同时穿越…」, /shouding/2026-10-14 49353 字节 / 首本「骆驼祥爷!」;/shouding/2026-10 整月 634 行。 所以是我们没按日期请求,不是站点没数据。

2. 三江补「字数」(走起点收藏那套缓存 + 开关)

三江那个 16 格表里没有字数字段([0]名次 [1]分类 [2]书名 [3]作者 [4]等级 [5]首订 [6]首日 [7]期初收藏 [8]期累计 [9..15]七个日增量),只能从起点书页取 (m.qidian.com/book/<id>/ 的 "wordsCnt",三江书号就是起点书号)。

  • qidianEnrichBoard 放开支持 qidiantu.*;书页一律用起点移动端适配器取。
  • ★ 只补字数,绝不覆盖它自己的收藏:三江的 collectBase 是起点图口径(期初收藏), 与起点作品页的收藏不是同一个数 ⇒ 照起点那样连 collect 一起写会把「期初收藏」悄悄换掉(口径污染)。 为此加了 mergeStat():起点图分支只写 wordsCnt + wordsFrom/wordsAt。
  • 沿用 state.qidianEnrich(当前 = true)这一个开关,同时管「起点收藏/精确字数」与「三江字数」。 postScanEnrich 的通道从 qidian.* 扩到 qidian.* + qidiantu.*(三江预算 30,实测只需 17)。
  • 实测:三江补全 25.3s / 17 请求 / 17 本,字数 194958 / 220380 / …;第二次 0 请求(缓存 17 命中); collectBase 保持 27178 / 19866 / 16888 …(没被起点收藏覆盖 ✅)。

3. 定时 18:19「没跑」——其实跑了,只是被启动宽限期压后

结论:设置生效了,不是没生效。 时间线:

  • 18:18:11 插件注入 ⇒ 宽限期开始(当时默认 5 分钟)
  • 18:19 到点,但 shouldFireTimer 的宽限期未过 ⇒ 不跑
  • 18:23:12 宽限期一过立刻补跑 ⇒ trigger=timer、19/19 完成、补全 17 个榜 131 请求
  • 19:26 那次 lastTimerRun=2026-10-15、lastTimerState=ok 已落盘

改动:宽限期 5 分钟 → 60 秒(它的目的只是躲开宿主启动期竞态,60s 足够); 并且被宽限期压住时写一条 timer.log,下次不用再猜:

[..] 到点但处于启动宽限期:还需 47s 才允许跑定时扫描(scanTime=18:19)

4. 顺带核对

  • 三江默认排序(期初收藏)实测 27178 / 19866 / 16888 / 16167 / 15956 / 13470 ✅ 降序
  • 计时日志里 18:23 那轮的补全警告:已补全 17 个榜的字数/收藏(131 请求) —— 定时任务里 enrich 是跑的

v10.5 · 番茄「分类」列恒为空(2026-10-15)

现象:番茄在读周榜的「分类」列没显示。

根因:番茄的行把分类存在 extra.catId(数字,如 1140/258), 而列渲染读的是 extra.cat(文本) —— 起点/起点图/刺猬猫那几个站正好是文本,所以只有番茄这边恒为「—」。

这个 bug 不止影响周榜:番茄的每一个单分类榜(fanqie.m.new.*)同样只有 catId,分类列同样是空的。一并修掉。

修法:宿主侧解析,客户端不需要知道映射表 —— view.ts 新增纯函数 fanqieCatName(catId) / withFanqieCat(extra):

  • 有 extra.cat 文本 ⇒ 原样保留(不动其它站);
  • 只有 catId ⇒ 用 FANQIE_CATS 解析成中文名写进 extra.cat;
  • 未知 id / 没有 id ⇒ 不写(列显示「—」,不编造)。

接线两处:panelBoard 的行映射(所有番茄榜)+ derive.ts 的派生行(rows.push 的 extra 直接过一遍 withFanqieCat)。

实测:番茄周榜 240/240 行有分类名(传统玄幻 / 东方仙侠 / 悬疑脑洞 …); 番茄单榜 1140 → 东方仙侠;起点图三江 历史 / 轻小说、起点签约 轻小说 均未受影响。

这一类 bug(列读的键 ≠ 数据里存的键)本轮已出现两次:ratio(列在但没渲染分支)与 catId↔cat。 以后加列时,顺手在 API 实测一下「该列有没有值」,比只看列头存在更可靠。

v10.4 · 派生榜排序失效 + 补字数(2026-10-15)

  1. 番茄在读周榜「按在读排序没反应」——真 bug:panelBoard 的派生分支提前 return, 而通用排序代码在这之后 ⇒ 派生榜的 sort 参数从来没被应用过(不只是 read,所有排序项都失效)。 现在派生分支自己排序:sort || defaultSortFor(boardId)。 顺带补 sortVal 的 read 分支(番茄的行 value 是「今日涨幅」,在读在 extra.read, 没有这个分支时按在读排等于所有行相等)。 实测:sort=read → 646614 / 633687 / 592233 … ✅;sort=periodDelta → 25356 / 10773 … ✅
  2. 在读周榜不显示作品字数:字数一直在番茄快照里(extra.wordNumber,实测 240/240 行都有), 但派生行没把它带出来。现在 derive.ts 建池子时顺便收集每本书的 words/catId 并合并 (同一本书可能只出现在某个分类榜里,所以跨榜取第一个有值的),派生行带上 → 面板显示 「分类 / 在读 / 字数 / 今日涨幅 / 期累计 / 第1~7天」。
  3. 起点图三江的字数:数据源里没有,加不了列。实测拉 qidiantu.com/bang/1/6/<期首> 的表格, 每行 16 个单元格:[0]名次 [1]分类 [2]书名 [3]作者 [4]等级 [5]首订 [6]首日 [7]期初收藏 [8]期累计 [9..15]七个每日增量 —— 没有任何字数字段。 要加只能逐本进书页抓(三江一期 62 本 ≈ 62 请求 + 缓存),成本模型与「起点收藏补全」同级, 所以没有擅自加,等需求方定。这是数据源限制,不是配置漏了。

v10.3 · 「期累计」按已有数据窗口起算(2026-10-15)

需求方的问题:番茄周榜的「期累计」为什么不按已有数据的七天快照统计?

查证:本期 10-12 ~ 10-18,但数据是从 10-14 才开始抓的 —— 2026-10-12 / 2026-10-13 本地根本没有快照。原实现硬拿期首(周一 10-12)当基准 ⇒ 整榜 240 行期累计全是「—」,一行有用的都没有。 该质疑成立。

改法(derive.ts):期累计基准日 = 期首优先,期首缺快照就退回「本期内最早有数据的日期」:

const baseDate = valueAt(key, periodStart) != null
  ? periodStart                                            // 期首有快照 ⇒ 标准口径
  : (dates.find((d) => d < today && valueAt(key, d) != null) || null)  // 期首缺 ⇒ 退回最早数据日
const periodDelta = v != null && bv != null ? v - bv : null
  • 实测首行:210819 − 185463 = 25356(基准日 10-14)✓ 有值的行 48/240(只有 10-14、10-15 都出现过的书才算得出)
  • 必须标明基准被推后:extra.baseDate / baseIsPeriodStart 一起回传,说明行写 ⚠ 期首 2026-10-12 没有快照,「期累计」按本期内最早有数据的 2026-10-14 起算。 否则用户会以为「期累计」是整周的量,其实只是两天的量。
  • 语义澄清:基准日 == 今天 ⇒ 期累计 0(本周内还没涨,是事实,不是缺失);只有今天才入池(期内没有更早的自己)才是 null。
  • 等 10-19 起有「整周期」数据后,基准自动回到周一,说明行里的 ⚠ 自动消失(逻辑不变,只是条件不再命中)。

为什么不用「最近 7 个已有数据日」(滚动窗口):那正是 v10 退掉的口径 —— 每本书基准是它自己 7 天前那条, 入池晚的书天然吃亏,横向不可比。固定期首才是可比的根基,所以这里只在「期首确实缺快照」时退让一步,并显式标注。

v10.2 · 列精简 + 番茄周榜逐日统计 + 面板界面记忆(2026-10-15)

  1. 起点图三江去掉「名次」列(需求方:它按收藏增量捞书,名次没意义)。columnsFor 里 dropRank 集中判断 (三江 + 番茄派生榜都去掉名次;派生榜的名次就是「今日涨幅倒序」,与涨幅列重复)。
  2. 三江去掉「已结算天数」列,排序项也一并去掉(sortOptionsFor 末尾新增兜底过滤: 排序项必须对应该榜真实存在的列,否则列被口径删掉后下拉里会残留一个按不存在字段排的项)。
  3. 番茄在读周榜补齐「逐日增量」(需求方:番茄周榜参考三江设计)——不再只有「今日涨幅/期累计」, 而是本期内每一天各占一列(第1天…第7天),第 N 天 = 第 N 天在读 − 第 N−1 天在读。
    • 第 1 天恒为 null(期首是基准,不是「第 1 天的涨幅」);
    • 缺任一侧就 null,绝不跨缺口算(中间缺一天 ⇒ 那天与它的下一天都算不出来);
    • 三江同样补了「期累计」(已结算各日之和)。
  4. 说明行写具体日期(需求方:不要「周一~周日」): 本期 2026-10-12 ~ 2026-10-18:已结算 2/7 天(有数据的日期:10-14、10-15)。⚠ 应已过 4 天,缺的那几天本地没有快照 —— 「已结算」= 本地真有数据的天数,不是从周一开始过了几天。这是最容易误判成 bug 的点。
  5. 面板界面记忆(需求方):收起面板再打开(以及刷新页面)要回到原界面。做法:
    • 模块级 sel store(tab / 站 / 榜)写 localStorage,useState 初始化时读回;
    • ★ store 必须挂在模块级 window.__rankScanShared 上,不能建在 apply() 里:本会话观察到 loader 可能把 factory 跑两遍,store 建在 apply 里会让「入口按钮」和「浮窗」各拿一份, 点入口面板不开(订阅不在同一个 store 上);
    • 入口按钮的 onClick 从 toggle 改成幂等 setOpen(true):万一入口被注册两份,toggle 会互相打架。

排障备注(省下一次走错路)

点「扫榜」入口看不出反应时,先确认点的是哪颗按钮:DSH 的 sidebar.panellist 会把插件入口 包进它自己的一行 <button class="_2H3hWW_panelRow">,我们的入口按钮嵌在这一行里面 (DOM 里因此有两颗文本都叫「扫榜 ▸ N榜」的 button)。用 document.querySelectorAll('button') 取第一个 会拿到外层那颗,点它当然不会开面板——它根本不是我们的 handler。 判断方法:我们那颗有 title="展开扫榜面板(今天 N 个榜有数据)"。

v10.1 · 「已结算天数」的真实含义 + 三江补期累计与排序(2026-10-15)

当时的疑问:「今天周四,从周一算不该是结算 3 天吗?」

答案:已结算 = 本地真有数据的天数,不是「从周一开始过了几天」。 周一是期首、周四是今天 ⇒ 日历上过了 4 天,但本地快照只有 10-14、10-15 两份 ⇒ 实际结算 2 天。两个数不一样不是算错,而是「缺数据」。

  • 说明文案现在把实际有数据的日期列出来,并用「应已过 N 天」点出缺口: 本周已结算 2/7 天(周一~周日;有数据的日期:10-14、10-15)。⚠ 周一~今天应已过 4 天,缺的那几天本地没有快照(不算结算)
  • 面板顶部另有一行「基期 2026-10-12 · 数据日期 2026-10-15」,即「期首 → 今天」的具体日期。
  • 对照:起点图三江同期显示已结算 4 天(周一~周四)——因为它一次请求就把整期 7 个格子拿回来了, 不存在缺天。两个榜数字不同是数据来源不同,不是口径不一致。

三江为什么没有累计增量(发现):因为从来没算过。三江原始数据只给 「期初收藏 + 7 个每日增量格子(未到的天为 null)」,前后端都只做了逐日展示, 没有把已结算的各日加起来。现在补上:

  • 新列 期累计 = 已结算各日增量之和(未到的天是 null ⇒ 天然不计入;全期未结算 ⇒ 「—」不是 0)。 实测首行 1531+1186+886+587 = 4792,与「今日 − 期初」一致。
  • 排序项(需求方 2026-10-15):默认(期初收藏) ← 默认排序 / 期初收藏 / 今日增量 / 期累计增量 / 已结算天数,全部倒序。 实现方式:view.ts#defaultSortFor(boardId) 纯函数 + 单测;三江未显式选排序时按 collectBase 排。

番茄派生榜改「自然周」口径(v10 · 2026-10-15)

动作:两个派生榜(fanqie.derived.delta 在读涨幅榜 / fanqie.derived.week 七日累计) 合并为一个 fanqie.derived.period「在读周榜(候选池内)」,旧的退掉。

为什么必须落固定周期(需求方提出的关键问题):

  • 滚动「最近 7 个数据日」:每本书的基准是它自己 7 天前那条 ⇒ 入池 3 天的书只有 3 天涨幅, 却和入池 5 年的书同榜比,横向不可比;
  • 「从入池起累加每日涨幅」在数学上 恒等于今天的在读数(自己 − 自己入池那天),等于没做统计;
  • ⇒ 照搬 起点图三江榜的口径:同一期首日 + 期内累加每日增量 + 已结算 N 天, 全站共用同一个基准日,N 本书才可比。

周期定义 = 自然周(周一~周日)(需求方选 A):

  • 期首 = 该周周一(weekStart(),本地时区);面板「基期」显示的它就是期首;
  • 本期累计 = 今日在读 − 期首在读;期首当天入池/期中入池 ⇒ null(显示「—」,不伪造 0);
  • 已结算 N/7 天:照搬三江的「已结算天数」,一眼看出这期数据够不够;
  • 0 额外请求(全部从已落盘快照算),与采集纪律一致。

列与排序(需求方 2026-10-15):

  • 列:名次 / 书名 / 作者 / 在读 / 今日涨幅 / 本期累计 / 已结算;
  • 默认名次 = 今日涨幅倒序;排序项另给 今日涨幅 / 本期累计涨幅 / 在读(都是倒序);
  • 不出「在榜天数」(派生榜没有快照,且与「池内天数」重复)、不出「日期」下拉(派生榜总是算最新一期)。

口径选择(值得记住的边界):「今日涨幅」的比较基准也落在期内,不跨期去取上一期最后一天。 否则周一那天会出现「本期累计 = 0」而「今日涨幅 = 200」的自相矛盾。代价:每周一所有书的「今日涨幅」都是「—」 (诚实表达「本周还算不出涨幅」)。

迁移:readState() 里有幂等迁移 —— 看到两个旧 id 就换成 fanqie.derived.period 并写回 config.json (否则「抓什么」看不见新榜、保存时又把旧的写回去)。默认抓取集也加了它,新安装默认就有。

测试:tests/derive.test.mjs 重写为 7 条(自然周边界 / 默认名次与累计 / 期中入池 / 期首当天 / 跨周 / 多榜并集 / 只派生一个榜)。

「在榜天数」统一口径(v9.6 · 2026-10-15)

现象:起点只有「轻小说(cat12)」有在榜天数,其它子分类没有;番茄部分分类也没有。 看起来像「分类之间没统一」,实际是两个叠在一起的 bug:

  1. 列的出现条件是错的 —— shouldShowDays(site, priorDataDatesForBoard) 要求「该榜有 ≥2 个数据日」。 而 cat21/cat4 是当天才进抓取集的(只有 2026-10-15 一份快照)⇒ 判成「没历史」⇒ 整列不出。 改成:该榜有任何本地数据就出列(一天也出;新书当天显示「1 天」,而不是整列消失)。
  2. 天数的历史来源是错的 —— daysOnBoard 的历史原本来自 slim 精简索引(historyFor(indexes,…)), 而 slim 是历史产物:早期抓取集只有 1 个榜时写下的 slim 只登记了那一个榜 ⇒ 后来加入的榜(起点其它子分类、番茄等)即使抓了多天也拿不到历史。 更糟的是 diff 只在有基准日时存在,单日快照的榜所有行 days 都是 null(列就算显示也是空的)。 改成 historyFromSnapshots(boardId, effDate):直接遍历该榜自己的快照文件累积历史,与抓取集/交付时间无关。 并且 days = daysOnBoard(key, effDate, histSnap) 独立于 diff 计算。

验收(20 个榜逐个核对):

榜datesdays 列取值
qidian.sign.cat1210-14/10-15有1–2
qidian.sign.cat21 / cat410-15有(原来无)1
fanqie.m.new.262 / 1014 / 25710-15有(原来无)1
番茄其余分类(两周数据)10-14/10-15有1–2
刺猬猫 3 榜 / 菠萝包 3 榜 / 起点10-14/10-15有1–2
起点图 首订/三江10-14/10-15无(口径:第三方聚合源)—
刺猬猫「首订(约)」10-14/10-15无(口径:第三方约值榜)—
番茄派生榜 ×2无无(它是按日期区间动态计算的视图,没有单一 dataDate)—

即「所有真实榜单统一都有这一列」,剩下的例外全是明确口径排除的,不是漏配。

面板修正(v9.5 · 2026-10-15)

  1. 起点图「直接查看」历史日期看起来没反应 —— 数据确实换了(/day 0.1s 回 62 行), 但界面上没有任何地方标出「你现在看的是哪天」,而且「排序」还沿用着上一个视图的选择 ⇒ 像是没生效。 现在:① 切到直查视图时把排序复位成「默认(名次)」;② 顶部加醒目横幅 「📅 正在查看直查日期 YYYY-MM-DD(未落盘…)」+「回到最新」按钮。

    注意:没有把这个日期塞进「日期」下拉——那正是前几轮要求去掉的行为(下拉只列该榜本地真有的快照)。

  2. 排序是正序(要倒序) —— panelBoard 里是 num(a)-num(b)(升序),所以按字数/收藏/首订排出来是「最小的在前」。 改成 compareDesc,一律倒序(最大的在前)。 ★ 顺手挖出一个隐藏 bug:num() 原来把缺失值变成 +Infinity,倒序时 Infinity 会窜到榜首 ⇒ 没数据的行会被顶到第一名。现在 num() 返回 null,compareDesc 显式把缺失值压到最后。 实测:sort=words → 323677,316457,…;sort=collect → 19294,17286,…;sort=value → 1953,1555,…(首订缺失值在第 40 行 / 共 62 行)。
  3. 菠萝包去掉「作品评分」 —— 站点页面上那个「N 分」是无意义装饰条目。 已进 hideColumnsFor(sfacg.* ⇒ 隐藏 score),排序项也随列集合自动消失(实测只剩 默认/名次/字数/收藏)。

口径修正(v9.4 · 2026-10-15)

  1. 「变化」列在番茄显示的是在读差(真 bug) —— computeBoardDiff 对 value 型榜把行 delta 算成 「主指标数值差」⇒ 番茄的行 delta = 9492(在读差),与「在读变化」列完全重复。 新增纯函数 view.ts#usesRankDelta(boardId) + 单测:有真实名次的榜(番茄/起点/刺猬猫榜单/菠萝包) 一律改用 名次差(上一数据日名次 − 今日名次,正=上升,|Δ|≤2 记 0);起点图与刺猬猫「首订(约)」不出。 实测修复后番茄:变化=1 / -1 / 0,在读变化=9492 —— 两列不再重复。
  2. 列口径统一收进纯函数 view.ts#hideColumnsFor(boardId) + applyColumnPolicy(有单测):
    • 起点图(第三方聚合源,非榜单):不出 delta(它没有「名次变化」这个概念);
    • 起点图三江:变化已由「今日增量」+ 7 列「每日增量」表达 ⇒ delta 重复,去掉;
    • 刺猬猫「首订(约)」:不出「在榜天数」。
  3. 刺猬猫/菠萝包 字数与收藏之前是空的 —— 不是列没定义,是扫描后的自动补全没生效: 榜单页本身给不出这两个字段(刺猬猫 0/30,菠萝包只有前 2 名有),必须进书页取。 实测:显式调 POST /api/rank-scan/enrich?boardId=… 时 刺猬猫 30/30、菠萝包 10/10 都能补上 (菠萝包顺带把被站点截断的书名也还原成完整名)。本轮已对 2026-10-15 的 5 个榜补齐。
  4. 番茄「在榜天数」 —— 口径未变(该榜有 ≥2 个数据日才出);10-15 只有一份数据日,明天自然出现。

排查用到的硬证据:ciweimao.* 快照 collect有值=0/30 words有值=0/30(14:24 那份), 而 sfacg.* 是 2/10;显式 enrich 后两者都变成满值。所以问题在「自动补全没跑/被打断」,不在解析器。

★ 事故与闸门:客户端 bundle 语法错误会让整个入口消失(v9.3 · 2026-10-15)

现象:扫榜面板有时完全看不到——左栏入口没有、浮窗没有,但 /api/rank-scan/* 接口正常。 页面 console 里只有一句 @dsh-external/dsh-rank-scan: import failed + web boot: 1 entry did not activate, 再往上翻才看到 Unexpected token '||'。

根因:用 edit 改 lib/client.js 时,old_string 只覆盖到换行前, 把一个孤立的 || '—' 留在了 if { … } 块之后 ⇒ bundle 语法错误 ⇒ client 模块 import 失败 ⇒ 面板整体挂不上。 当时两道"检查"都放过了它:

  • node --check lib/client.js 返回 0 —— 这个 bundle 以模板字符串开头,CJS 解析路径没有真正检查到内部;
  • tsc 只编译 host 侧,从不看 lib/client.js。

现在的闸门:tests/client-bundle.test.mjs(已进测试集,跑 node tests/*.test.mjs 就会跑)

  1. 用 new Function(src) 真的把 bundle 解析一遍(语法错误必抛);
  2. 守住四个结构特征:__ModuleLoader__.load / shell.overlay / sidebar.panellist / 两个 localStorage 键 / openLink(ctx, url) 的单参数兼容分支。

改 lib/client.js 之后必须跑一次测试集(至少 node tests/client-bundle.test.mjs),不要只信 node --check。

面板修复(v9.2 · 2026-10-15)

  1. 起点图「收订比」显示空 —— 后端数据一直在(实测 39/62 行有正值,如 9.9 / 6.3 / 12.1), 是前端 cell() 缺 ratio 分支,落到兜底 dash() 显示「—」。已补: 起点图对「首订暂缺」的行会把收订比写成 0/-1(伪造值)⇒ 一律按缺失显示「—」。
  2. 三江「每日增量」挤在一格 —— 原来 daily 是一个聚合列(7 个 MM-DD +N 用 · 拼成一坨,且每格带重复日期)。 现在拆成 7 个独立列 daily0..daily6(列头「第1天…第7天」,具体日期由 extra.daily 提供), 未到的天显示「—」。回归测试覆盖「7 列要在、旧聚合列要删」。
  3. 「在榜天数」按榜判定 + 起点图强制不出(2026-10-15 二次口径:起点图是第三方聚合源、本身不是榜单,跨天连续在榜对它没意义)。 判据收进纯函数 view.ts#shouldShowDays(site, priorDays) 并有单测;起点图三江/首订永久不出这一列。 其余站判据从「精简索引里出现过」改成 「该榜自己有 ≥2 个数据日」。 原因:slim 是历史产物,早期抓取集只有 1 个榜时写的 slim 里只注册了那一个榜 ⇒ 起点图/刺猬猫/菠萝包即使两天都抓到了也被判「没历史」,白丢一列。 现在:起点图首订/三江、刺猬猫、菠萝包、起点 都有这一列;番茄 262 只有 1 个数据日 ⇒ 暂不出(明天自然出)。 注意:起点图首订的快照在 2026-10-14 是空的,所以它的「在榜天数」目前多为 1,属真实口径。
  4. 按钮作用域 —— 「补齐番茄全部分类」只在该榜属于番茄时显示(原来在每个站都出现,属干扰); 三个按钮都补了明确 tooltip(作用对象 + 请求量级)。

三个按钮到底是什么效果(实测/核对过代码)

按钮实际动作请求量级
保存并重抓本榜POST /depth {boardId, depth} 保存该榜条数,然后 POST /scan {boards: boardId} —— 只重抓"当前打开的这一榜"该榜 ≈1–3 请求
补全本榜POST /enrich?boardId=<当前榜> —— 只补当前榜的行内字段:起点=精确字数+收藏(走书页缓存);番茄=干净书名/作者;刺猬猫/菠萝包=收藏+字数该榜逐本 1 请求(起点 20 / 刺猬猫 30 / 菠萝包 10)
补齐番茄全部分类POST /enrich 逐个跑抓取集里所有 fanqie.m.* 分类榜,面板显示进度8 榜 × 最多 30 本(但只对仍有 PUA/缺名的行发请求,学得越多越快)
  • ★ 重抓的永远是「当前打开的那一榜」,不会连带重扫别的榜(jpost('/scan', { boards: curBoard }))。
  • 「补全本榜」≠ 重扫榜单:它只回头补已落盘快照里缺的字段(幂等,同一天可重复补)。

面板修复(v9.1 · 2026-10-15)

  1. 「看起点图原始页」点了没反应 —— openLink(ctx, url) 的调用方一直只传一个参数(openLink(url)), 于是 url 形参恒为 undefined,函数静默 return。现在两种调用方式都认(单参数 = 只有 url)。 实测:点击后 DSH 右侧栏 iframe 变为 https://www.qidiantu.com/info/<bid>。
  2. 日期下拉串站 —— 日期列表原来取全局 ov.dates(所有数据日期)⇒ 给某个榜查一次历史日期,那个日期就出现在所有站点的下拉里。 现在 /api/rank-scan/board 回 dates[](只有该榜自己有快照的日期),面板只用它。
  3. 「在榜天数」列不该出现在起点图 —— hasHistory 原来是全站判断(任何一个榜有历史 ⇒ 每站都挂一列空的)。 现在按榜查精简索引里有没有它更早的记录 ⇒ 起点图/菠萝包这些没有历史口径的榜自然不出这一列。
  4. 历史查询不要污染日期列表 —— 「直接查看(按日期)」不再把查询日期留在面板状态里;「抓取存入本地」仍是显式按钮(只对该榜该日期落盘)。

定时扫描与面板弹出(v9 · 2026-10-15)

定时扫描的三条硬约束(★ 2026-10-15「重启即重扫」事故后的修法)

事故:定时触发只看 dueNow(过了扫点 + 当天没扫过),而「当天跑过没」记在进程内存里 ⇒ 宿主每次重启都重新判成「该扫」⇒ 重启 → 立刻补跑整站扫描 → 宿主又挂 → 再重启的自我循环。 (当日实测:5 次重启,每次启动后 2 秒起一轮 17 榜 + ~90 请求补全。)

现在必须三条同时成立才允许触发(判据在 src/scan/schedule.ts#shouldFireTimer,纯函数 + 单测):

约束落点防的是什么
① 「今天跑过没」落盘(state.lastTimerRun)defaultState + checkTimer 写回重启后继续记得今天已扫过 ⇒ 重启不重扫
② 启动宽限期 freshMs(默认 5 分钟,插件配置 timerFreshMs 可改)freshMs 入参宿主刚起来不抢资源(启动期竞态)
③ 无「翻开关」迁移(2026-10-16 最终口径)bindTimer默认开、用户可关、重载保留:除面板 /timer 接口外任何代码都不写 timerEnabled

★ 2026-10-16 修(需求方:「我明明打开过定时,谁关的」):③ 的判据原来是 timerSchemaV !== 2, 但同一次迁移又把戳写成 6(后面的 v6 迁移)⇒「6 !== 2」恒真 ⇒ 之后每一次启动都把用户的开关重新关掉 (timer.log 里就是那行「一次性迁移:老配置的定时开关已置为关闭」在每次注入后重复出现)。 判据已钉在「字段缺失」上:新装的 config 由 defaultState 写的就是 6,不会再被误关;老机器首次启动字段确实不存在 ⇒ 仍只关一次。

其它:新安装 timerEnabled=false(面板里自己开);同进程内定时/手动共用一把锁(定时在跑时 POST /scan 回 409); 面板「抓什么」页用 POST /api/rank-scan/timer {enabled, scanTime} 设置,GET /catalog 回读 timer{wired,lastRun,lastState,inFlight}。 诊断入口:<dataDir>/state/timer.log(接线/迁移/每轮开始结束各一行)。

面板弹出的时机

  • 安装成功后第一次打开 DSH 自动弹一次(localStorage['rank-scan-panel:autoopened:v1'] 不存在时弹,并立刻打标记)。
  • 之后一律读上次的开/关状态(rank-scan-panel:open:v1)⇒ 用户关掉后,刷新/重开都不再自动弹;左栏「扫榜」入口随时可再开。
  • 旧版本是模块级 value: true ⇒ 每次刷新都弹,这是本次修掉的 bug。

面板(v6 · 2026-10-15)

  • 入口在 DSH 左侧栏(会话列表下方):「扫榜 ▾ / 扫榜 ▸ N榜」开关;浮窗可拖动+缩放,位置存 localStorage。
  • 所有面板 POST 必须带 JSON body:jpost(p, body) 里写死 Content-Type: application/json(教训:曾漏掉 body ⇒ 抓取集/深度「保存」全部静默失效,接口还回「已保存 N 个榜」)。
  • 每榜「取多少条」保存后立即重抓本榜(否则要等下一次扫描,看起来像没生效)。
  • 抓取集:抓什么 页勾选先改本地,点「保存改动(N 个榜)」才落盘;显示集自动跟随(第三方 ciweimao.buy.latest 必须在清单里,否则保存会把它丢掉)。
  • 书页补全口径(站上榜单页给不全,进书页取):
    • 刺猬猫 总点击/总收藏/总字数(榜单页完全没有这三个字段)
    • 菠萝包 >收藏 N<(总收藏)与 字数:N字(总字数)——别取 收藏:166 / 字数:26321 这类统计块口径
    • 菠萝包书名按 <title> 的第一个 - 截断
  • 扫描后自动补全(state.autoEnrich,默认开):定时与面板「立刻扫一次」都会跑,但只补「本次扫描范围内的榜」(postScanEnrich(only),2026-10-16 起);单榜重抓不补。全站一轮 ≈ 89~100 请求。 (旧口径「只有定时才补全」已作废:那时手动扫会带上别站的书页,白费请求。)
  • 起点图首订支持按日期直查(/shouding/<YYYY-MM-DD>),面板有「查历史日期」。
  • 起点移动端榜单页单页固定 20 条(页面数据 total:172 但翻页接口在反爬挑战后面);番茄/刺猬猫默认 30(每页 10 翻页取)。

番茄字体反爬(v7 · 2026-10-15)

  • 榜单接口的 bookName/author 含 U+E000–U+F8FF 私用区码点(站点 webfont 渲染),同一本书码点稳定。
  • src/scan/fontmap.ts:learnPair(pua原文, 干净名) 等长逐位学映射 → <dataDir>/font-map.json;decodePua 在扫描落盘前与面板渲染前各解一次。
  • 补全(/api/book/info?bookId=)只在「仍有 PUA / 书名缺失 / 仍标可疑」时发请求 ⇒ 学得越多越快;扫描后自动补全已覆盖番茄。
  • 成本口径:每本书 1 请求 + 1s/请求(采集纪律)⇒ 30 本/榜 ≈ 35s;8 个番茄榜全量 ≈ 4–5 分钟。

扫描范围(v7)

  • POST /api/rank-scan/scan 现在读 body:带 {boards:'a,b'} 只扫这些榜(不跑扫描后补全);不带才全站扫 + 自动补全。
  • /api/rank-scan/select 保存抓取集后,面板整体刷新(概览/下拉/侧栏计数同步)。

起点 20 条上限(已查证,2026-10-15 补全)

  • 移动端榜单页 SSR 一页 20 条,页面数据 pageNum:1 / total:172 / isLast:0;URL 变体(pageNum/page/pageIndex/尾段路径)全被忽略。
  • 真实翻页接口 /webcommon/rank/signlist?gender=&pageNum=&catId= 返回 403 code 1403(风控)⇒ 暂不逆风控。
  • 本仓不逆风控,但 BB/云侧(novel-stats/collector/qidian-rank.mjs)早就翻页了,做法值得记下来:
    1. 换通道:抓桌面榜页 https://www.qidian.com/rank/<slug>/chn<N>/page<P>/(不是移动端那个接口);
    2. 用浏览器过一次瑞数挑战换 cookie,存 .secrets/qidian-cookie.txt;之后纯 HTTP 带 cookie 即 200(无 cookie = 202 / 209 B,有 cookie = 200 / 61.5 KB);
    3. 三个坑:分页必须是路径式(?page=P 会 302,直取可能拿到空列表);每页校验 20 条互不重复;data-rid 只是页内序号(5 页 100 本只有 20 个唯一值)⇒ 全局名次按数组下标重算。
    4. 代价:cookie 会过期且必须人工重喂(失效特征 status:202 / bytes:209)——与「新装零配置」冲突,所以本插件仍走移动端免 cookie 通道。
  • ★ 关键区分(2026-10-15 实测):桌面那 100 本也只有书名/作者/分类/子分类/状态/简介,收藏依然是 null。 「抓多少」与「有没有收藏」是两件事:收藏只存在于作品页(m.qidian.com/book/<bid>/,实测 200 / 约 103 KB,含 collect / wordsCnt)。 榜单页四条通道(移动端榜页、桌面榜页、H5 getshare 7 KB、起点图)里,只有起点图那张第三方表格本身带收藏列。

起点收藏与书页缓存(v9 · 2026-10-15)

  • 起点收藏默认关(state.qidianEnrich=false):榜单页给不了,只能逐本进作品页。面板「抓什么」页有开关,勾上才补。
  • 成本靠 <dataDir>/state/qidian-book-cache.json 压:同一本书在 TTL(默认 7 天)内不重复下载 103 KB 页面。 命中 ⇒ 0 请求;未命中/过期/新入榜 ⇒ 1 请求(1s 限速)。
  • 缓存只存书页真能给的字段:collect / wordsCnt / title / fetchedAt;拿不到 collect 的结果不写缓存(避免把失败固化成空值)。
  • 单次补全有请求上限 qidianEnrichBudget(默认 25)兜底:超了剩下的下次继续收,不会一次打穿。
  • 入口:面板按钮「补全起点收藏(只打没缓存的书)」/「清空书页缓存(强制重抓)」;API POST /api/rank-scan/qidianEnrich、POST /api/rank-scan/qidianCache {clear:true}; GET /catalog 的 qidian 段回读 {enrich, cache:{books,newest}, ttlDays}。
  • 勾了开关后,定时与面板「立刻扫一次」都会在扫描后自动补起点(走上面同一套缓存,且只补本次扫描范围内的榜);单榜重抓不补。

周期榜换期(v12 · 2026-10-16)

三个周期榜的「期」怎么走、换期当天会发生什么:

榜期是什么换期空期保护跨期保护
起点图 三江·推荐效果每周一期,期首日 = 周日(URL /bang/1/6/<期首日>)本地按数据日算出本期期首有:当期 0 行 ⇒ 自动退上一期,最多退 2 期,退成功记警告「本期(期首 X)无数据,已回退到上一期(期首 Y)」;三期全空才判 missing有:跨期当天的变化列判为不可比
刺猬猫 首订(约)第三方接口的 issue 期号第三方自己滚期有(原有):当期空 ⇒ 退上一期,最多 2 期有:期号不同 ⇒ 名次变化不可比
番茄 在读周榜自然周(周一~周日),纯本地派生(0 请求)weekStart 算本周一—(不依赖站点换榜)有(原有):今日涨幅/逐日增量的比较基准都落在期内,不拿上一期的日子当基准

跨期不可比(v12 新增):变化列一律拿「上一数据日」当基准,而周期榜换期当天上一数据日属于上一期 ——三江的总增量是「本期累计」、刺猬猫首订(约)的名次是「上一期那一榜上的位置」,两者相减得到的数字 看起来真实、含义却是错的。判据用快照里记的真实期号(三江 extra.periodStart / 刺猬猫 extra.issue), 不用本地日期推算;推不出期号的老快照保守判「不可比」。不可比时:

  • 变化列置空(行上带 extra.periodChanged,面板显示「新期」);
  • 榜说明行写明「⚠ 本期已换期(期号 X → Y):『变化』列跨期不可比」,口径跟着数据一起走;
  • 非周期榜(起点/番茄分类榜/菠萝包等)行为完全不变(照旧环比)。

实现:src/scan/diff.ts#guardCrossPeriod(纯函数 + 单测)、sundayOf(本周日);src/sites/qidiantu.ts 的 三江分支;闸门探针见 scripts/acceptance.ps1 的「周期榜换期」四组。

✅ 17/17 榜 ok(2026-10-14 实跑:起点 20 / 起点图 29+17 / 刺猬猫 10+10+14 / 菠萝包 10×3 / 番茄 8×10)
  • GET /api/rank-scan/changes 今日变化:按站编组,组内固定 新入榜/回榜/上升/下降/退榜;无基准时写「首次扫,无基准」不报 0
  • POST /api/rank-scan/scan / cancel / retry 三个动作按钮
  • GET /api/rank-scan/health 自检:路由是否挂上(返回 port / 数据目录 / 数据日期列表)
  • GET /api/rank-scan/catalog 可选榜清单:站 → 榜种 → 分类 + 是否已勾选/已显示 + 各榜深度
  • POST /api/rank-scan/select 抓取集/显示集(body: {captures?:[], displays?:[]})
  • POST /api/rank-scan/depth 每榜取数(body: {boardId, depth},1–100)
  • POST /api/rank-scan/enrich?boardId= 按需补全该榜当天快照字段(起点/番茄)
  • 客户端:lib/client.js(手写 window.__ModuleLoader__.load({ id, factory }) bundle,仅用平台 react;注册到 shell.overlay 槽位,右上角「扫榜面板」)

  • ⚠️ 客户端改动后需要刷新 GUI 页面才生效(host 侧改动用 dev_reload_package 即可)

  • 截图:docs/rank-v8-capture.png