keyboard-leo/dsh-reading-workbench ↗★ 0
dsh-reading-workbench
DSH 阅读工作台:书架、Markdown/TXT 阅读、划线批注与对话联动 适合需要在DSH中阅读多格式电子书,并让Agent直接读取正文与批注的用户。
설치
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:keyboard-leo/dsh-reading-workbenchreading-workbench 阅读工作台插件
把 DeepSeek Harness(DSH)变成阅读工作台:书架、分页阅读器、划线批注,并让 Agent 能直接读到你在读的正文。
功能
- 书架:封面网格;支持 Markdown / TXT / HTML / EPUB / MOBI / PDF,另可把网页书源抓取成本地章节书
- 分页阅读器:单页 / 双页,滚轮与键盘翻页,进度按「页码 + 百分比」双锚点保存
- 划线批注:文本偏移锚点,页面重排后仍能恢复;支持与对话联动
- 阅读设置:字号、行距、页宽、明暗主题;TXT 可切换「自动合并折行 / 保留原样」
- Agent 工具:
reading_books(书目)、reading_highlights(划线)、reading_excerpt(正文节选)
安装
dsh plugin --profile
add dsh-reading-workbench
尚未发布到 npm 时,也可以直接用源码目录安装:在 profile 的 package.json 里以 link: 指向本目录,
并把包名加进 dsh.profile.bundles。Host 半代码改动需重启 DSH 才生效(Node ESM 模块缓存,
disable/enable 不重载)。
截图
待补。市场卡片优先展示作者策展的截图,且只接受 GitHub 图床地址。
开发状态
说明:下文出现的
docs/...路径属于开发工作区(dsh-plugin/docs),不在本仓库内, 仅作为设计依据与实测记录的引用。
状态:三期完成并已通过真实例验收(2026-10-19);本轮新增滚轮翻页 + TXT 段落重排。 v1 的多格式支持(EPUB / MOBI / PDF)已在真实实例通过验收。
TXT 重排(2026-10-19 本轮):TXT 正文从「一个大
pre原样保留源文件换行」改为 按段边界判据合并折行 + 每段一个p+ 首行缩进 2em + 段间距,并提供 「换行:自动合并 / 保留原样」设置项。判据、偏移契约与全库实测依据见docs/txt-reflow-design.md; 独立对抗验收报告见docs/diag-txt-linebreak/2026-10-19-reflow-verification.md。全部自测套件全绿(21 套既有 + 本轮新增
txt-layout-selftest193 项、 独立验收txt-reflow-check1817 项、真实浏览器几何txt-reflow-browser-check18 项);tsc 0 错误。 真实实例验收:三格式 + 封面 + 设置 + 划线 PATCH + 进度锚点全部通过(fiberPhase: active)。 本文档是该插件的唯一权威设计来源,随开发持续更新。 遵循工作区根目录agents.md规范;运行时契约以当天 inspect 为准,基准探查见docs/dsh-runtime-notes.md(构建实测经验在第 10、11 节)。
Host 半运行在 vm 沙箱里(2026-10-19 两次真实事故,必读)
插件 install 成功、产物正确、静态 import 全部可解析,但 DSH 里 fiberPhase 一直 failed、
全部 REST 路由 401/404。两轮排查抓到两个独立根因。
事故一:沙箱里没有 process / Buffer
apply 第一行的 process.env.DSH_HOME 直接抛 ReferenceError: process is not defined,
cordis 记 _error、state 转 FAILED,连 14 条路由一起消失;writeJson 里的
Buffer.byteLength 是第二颗地雷(首次响应才炸)。
修复:数据目录改由 config.dshHome / ctx.get('sandbox'|'storage'|'workspace') 提供并回落 ~/.dsh;
编解码统一 TextEncoder / TextDecoder;readBody 返回 Uint8Array。
事故二:ctx.tools 没写进 inject
修完事故一仍然 failed。这次用宿主自己的启动参数起副本、把 stderr 落盘,才看到真话:
reading-workbench: Error: cannot get property "tools" without inject
at …/lib/index.mjs:1612:12
cordis 里读未 inject 的服务是抛异常而不是返回 undefined——于是
if (ctx.tools === undefined) 这种"判空跳过"自己就会炸,且炸在 ctx.effect 回调里
会被记成整条 fiber FAILED,try/catch 兜不住。
修复:inject = ['webServer', 'tools'];服务读取移到 effect 之外并显式 try/catch,
取不到就只少三个工具,书架与阅读照常。
排查心法:list_plugins 只给一个 fiberPhase: "failed" 字符串,没有异常文本。
最快的路径永远是抓宿主 stderr,别靠本地猜。
防线:scripts/sandbox-contract-check.mjs(已接进 pnpm build / pnpm check)——
在不带 process 的 vm 沙箱 + 真实 cordis + 白名单 ctx Proxy 里跑 apply,断言 ACTIVE,
并带多条断言/反证(还原 process.env 必 FAILED;漏声明 tools 时只少工具、不拖垮宿主)。
完整事故复盘见 docs/m2-agent-tools-notes.md 第三、四节。
文本编码契约(2026-10-19 事故三,必读)
症状:导入一本 GBK 老 txt(蓝天航空公司的空姐.txt,1.05 MB)后满屏菱形问号
(�),夹杂零星拉丁/西里尔/希腊字母;同一本书打开还特别慢。
根因(两层,缺一不可):
- 编码:
File.text()/readFile(path,'utf8')都是「UTF-8 宽松解码」, GBK 字节被逐段解成 U+FFFD(�);恰好构成合法 UTF-8 序列的字节对被解成别的文字 (D5 B9→ 希伯来文等)。更糟的是导入时把已损坏的字符串写回书库副本 (library/.txt),损坏被永久固化 —— 该副本 2.44 MB,含 681,143 个替换符。 - 性能:txt 没有章节结构,旧实现把整本书当成一个排版单元塞进 CSS 多栏容器
(
column-fill: auto+ 固定高度),55 万字要一次排出 400~600 列。
现在立的规矩(改代码时必须守住):
- 导入只收原始字节:客户端用
file.arrayBuffer()走二进制通道 (/books/upload-binary,format=txt|md也走它),Host 侧统一入口store.importTextBookFromBytes(paths, meta, bytes)。 任何地方都不得先解码再上传/落盘。 - 解码严格、二选一、失败即报错:
shared/text-encoding.ts的decodeTextBytes按BOM → UTF-8(fatal) → GB18030(fatal) → 抛 TextDecodeError判定, 绝不接受宽松解码产出的替换符。GB18030 是 GBK/GB2312 的超集,覆盖中文老书。 - 两道损坏闸门:
- 落库前
inspectReplacementDamage(>1% 且 ≥20 个)拒绝导入(importBookFromContent); - 打开时同一判据命中就报错「本书导入时被错误解码损坏,请重新导入」
(
store.readBookContent)—— 已损坏的副本无法修复,只能重导, 绝不把乱码当正常内容渲染给用户。
- 落库前
- 副本恒为规范 UTF-8:
library/.只有这一种编码,读取端不再猜。
txt 虚拟单元(性能):client/txt-units.ts 的 splitTextUnits 把正文切成若干单元
(章节标记行优先,否则约 2 万字一处;整本无换行的长文按字符强制切),
阅读器一次只渲染一个单元。偏移契约:unit.text === text.slice(unit.start, unit.end),
且 start 是全书字符偏移 —— 因此划线锚点仍是全书偏移,旧划线无需迁移,
渲染时用 toUnitRange 换算成单元内偏移。实测同一本书:556,817 字 → 27 个单元,
最大单元 36,364 字,单次排版量降到整本的 6.5%。
配套自测:scripts/encoding-selftest.mjs(编码表驱动 + 损坏判据 + 切分偏移契约)、
scripts/host-upload-selftest.mjs 的 10~12 号用例(GBK 二进制导入 / 非法编码拒绝 /
旧 JSON 通道拦替换符)。已损坏的历史数据用 docs/diag-blue-sky-encoding/repair-damaged-import.mjs
重导(自动备份副本与 data.json)。
Agent 工具(M2,2026-10-16)
三个只读工具把书库接进 DSH 会话(在对话里问「我有哪些书」「那本小说讲了什么」时,Agent 可直接查):
| 工具 | 参数 | 说明 |
|---|---|---|
reading_books | — | 书架列表(书名/作者/格式/进度);render 输出人读友好的书单 |
reading_highlights | bookId? limit? | 划线批注(含笔记),按时间倒序;可跨书查 |
reading_excerpt | bookId offset? length? | 正文节选(单次上限 4000 字),章节书剥成纯文本、图片占位为 [图片] |
关键实现决策:不 import @deepseek-ai/dsh-tools
首版直接 import { defineTool } from '@deepseek-ai/dsh-tools',结果在测试阶段就炸了:
dsh-tools 0.1.0-rc.6 的 ESM 依赖 @deepseek-ai/dsh-llm 的 CallId 导出,
而本机运行时的 dsh-llm 0.2.0-rc.2 已把它改名 ToolCallId —— 版本族错位,import 阶段即抛
SyntaxError: does not provide an export named 'CallId',会炸掉整个宿主启动。
复盘 defineTool 的源码后确认它只是「schema 编译 + 参数校验包装」的纯数据变换,
因此按同形态自实现(含参数校验、ToolArgsError、parametersToJsonSchema),
不引入任何 @deepseek-ai/* 运行时依赖,工具对象与官方形态逐字段对齐。
tools-selftest 验证:注册形态、schema 编译、execute 输出、render 文本、过滤/倒序/limit、HTML 剥离、越界与报错,共 26 项。
设计取舍:工具只读(不提供写入/删除)——划线由人类在 GUI 里做,Agent 只消费。
划线批注(M3,2026-10-16)
正文选中文字 → 浮动「划线」按钮 → 入库并即时高亮;工具栏「划线 N」打开笔记抽屉。
定位模型:文本偏移锚点(重排后仍可恢复)
页码/屏号会随字号与视口变化,文本不会,因此划线锚点记录「章节 + 单元内字符偏移 + 长度」:
章节书:mark:ch=2:off=17:len=9 (ch 为章节序;段间 : 分隔、段内 key=value)
文本类:mark:off=120:len=30
- 恢复时按偏移在单元文本里重建 `` 包裹;找不到(内容改版)静默跳过,不报错——
text始终随划线存库,笔记栏永远能展示原文。 - 分隔符刻意设计:段间
:、段内=。首版把 key/value 也用:连接(mark:ch:5:off:17),split 后 key 与 value 分家、全部字段解析失败——mark-selftest的往返断言抓住了它。 - 选区超长(>600 字符)拒绝并提示,防止把整章划成一条。
- 笔记随划线存库,
PATCH /highlight?id=更新(正文不可改,只允许 note);空串即清除。
交互细节
- 高亮恢复用「逐节点切分 + Range 包裹」而非
surroundContents一把梭——划线常跨段落,后者直接抛错;包不住的片段退化为 extract/insert,再不行就跳过该片段(不破坏 DOM)。 - 点正文里的高亮 → 打开笔记抽屉;抽屉里「定位」按偏移比例换算目标屏并平滑滚动到该句。
- PDF 暂不支持划线(pdf.js 文字层是另一套坐标体系,待做)。
UI v2:界面结构与交互(2026-10-16 重构)
用户反馈「看书界面太难看」,因此把 UI 从「左窄书架 + 右正文」改成先书架、后沉浸阅读两态:
┌─ 书架态 ────────────────────────────────────────────┐
│ 书架 12 本 [搜索] [排序] [导入书籍] │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │封面│ │封面│ │封面│ │封面│ │封面│ ← 2:3 书封网格 │
│ └────┘ └────┘ └────┘ └────┘ └────┘ 底部进度条 │
│ 书名 书名 书名 书名 书名 │
├─ 阅读态(点书进入,占满整屏)──────────────────────┤
│ ‹返回 书名 [章节目录▾] [自适应|单页|双页] A- A+ │
│ ┌──── 分页正文(CSS 多栏,一屏 1~2 栏)────┐ │
│ │ 左右点击热区翻页 / 方向键 / 空格 / PgUp/PgDn │
│ └──────────────────────────────────────────┘ │
│ ‹ 章节 3/12 · 5/9 页 ▓▓▓▓░░░░ 62% › │
└──────────────────────────────────────────────────────┘
分页是怎么实现的
不切分 DOM、不测量每个段落,而是用 CSS 多栏排版:
- 正文容器固定高度 +
column-fill: auto,整篇内容被排成 N 个等宽列并横向溢出; - 外层视口
overflow: hidden只露出一屏(单页 = 1 列宽,双页 = 2 列宽 + 间距); - 翻页 = 对内层施加
translateX(-屏号 × 步长)。
⚠️ 两个几何量必须分清(曾混用,导致「按键盘下一页切换不准确」):
- flow 宽度 =
screenWidthOf= 可见列宽之和 + 列之间那cols-1个间距(一屏能显示的内容宽);- 翻页步长 =
stepOf= 可见列数 × (列宽 + 间距)(跨到下一屏的实际距离)。二者相差恰好一个
COLUMN_GAP:一屏右边界止于末列右缘,下一屏首列要从「末列右缘 + 间距」处起排。 用screenWidth当步长会每翻一屏累积偏移 48px —— 左侧露出上一列的尾巴、右侧正文被切。
好处是「页码」是真实排版结果:改字号、拉窗口都会自动重排并重算页数;
ResizeObserver + 内容区 load 监听负责补测(图片加载会改变排版宽度)。
列数由设置决定:单页 / 双页 强制,自适应 按容器宽度判断(两列都 ≥ 380px 才并排)。
翻页手感
- 键盘:
→/↓/空格/PgDn前进,←/↑/PgUp后退,Home/End首末屏,Esc返回书架。 - 连发不丢步:
nextPage内部维护一个同步推进的目标屏号(targetViewRef), 不依赖 React state —— 长按方向键时每次 keydown 之间 React 未必已重渲染, 用闭包里的view做自增会连续算出同一个值,表现为「按 5 次只前进 1 屏」。 - 连打不卡动画:单次翻页保留
.16s横向滑动;两次翻页间隔→properties="cover-image"→ id/href 含cover` 的图片项。 明确不做「取 manifest 第一张图」这种猜测:那会把书里的插图当成封面,比没有封面更糟。 - MOBI:EXTH 201/202 封面偏移 + MOBI 头 First Image Index 定位图片记录;缺 EXTH 时扫 First Image Index 起始记录。
- 无封面(文本类 / 未声明封面的书 / 提取失败):客户端渲染生成式封面 —— 按书名哈希取色相, 同色系渐变 + 书名/作者排版。色相是「书籍内容色」而非 UI 语义色,因此用 HSL 字面量、不随明暗主题变化。
- 提取结果缓存在
library//cover.+cover.json(含「确认无封面」标记,避免反复解压重试)。 /cover对无封面返回 404(不是 500):客户端据此渲染生成式封面。- 老书目(
hasCover字段缺失)在首次列书架时懒回填并落盘,无需手动迁移。
阅读设置
settings.reader = { fontSize, lineHeight, theme, columns, txtReflow } 经 Host 持久化(/settings),
改字号/版式立即生效并落盘,下次打开保持。lastOpenedBookId 记录上次打开的书,重启 GUI 后回到原书。
txtReflow(仅对 .txt 生效,工具栏上叫「换行」):
| 值 | 行为 |
|---|---|
auto(默认) | 按段边界判据合并折行,段落重排(标准中文小说版式) |
preserve | 不合并折行,源文件每个非空行独立成段(判据出问题时的退路) |
老 data.json 没有该字段时由 normalizeReaderSettings 收敛为 auto,无需手动迁移。
支持的格式
| 格式 | 解析位置 | 阅读方式 | 进度锚点 | 已知边界 |
|---|---|---|---|---|
.md / .markdown | Host(按字节判定编码后原文入库) | marked 渲染 + DOMPurify | v2;page;pct | — |
.txt | Host(按字节判定编码后原文入库) | 段落重排(判定段边界、合并折行、每段一个 p;可切「保留原样」);切虚拟单元逐单元渲染 | v2;chapter;page;pct | 单元按章节标记或 ~2 万字切分;旧 v2;page;pct 锚点按百分比折算到单元;重排后渲染文本 ≠ 原文,划线靠偏移映射换算(见第 8 节本轮记录) |
.html / .htm | Host(原文入库) | 消毒后直接注入 | v2;page;pct | 客户端按 txt 通道上传(当纯文本读) |
.epub | Host(fflate 解压 + OPF/spine/ncx 解析) | 章节懒加载 + 章内分页 | v2;chapter;page;pct | 图片走 /asset;嵌套目录层级压平 |
.mobi | Host(PDB/MOBI 头 + PalmDOC LZ77 解压) | 单章 HTML 复用章节链路 | v2;chapter;page;pct | 仅 PalmDOC;HUFF/CDIC(AZW3/KF8)明确报错;不提取书内图片记录 |
.pdf | Client(pdf.js 主线程 fake worker) | canvas 逐页渲染,支持单/双页与缩放 | v2;page;pct | 划线暂不支持 |
web(网页书源) | Host(ctx.web.fetch 抓取 → 提段落纯文本 → 逐章落盘) | 复用章节链路(目录 + 按章懒加载) | v2;chapter;page;pct | 目前只支持 alicesw.tw;目录页会被抓取后端截断,靠章节链补齐;正文不保留原站任何标签与图片 |
设计原则:重活放 Host(解压、解析、封面提取),Client 只保留视图 —— 既避开 client seed 模块的体积约束,也让 client-bundle-check 这道闸门容易守住。
网页书源(2026-10-19)
在书架里填一个网页小说 URL,插件把作品抓下来存成本地章节书(format: 'web'),之后就是普通阅读。
调研背景与站点实测见工作区 docs/web-source/(可行性报告 + 抓取通道 + 站点解析规格三份)。
设计取舍(每条都有实测依据)
| 决策 | 理由 |
|---|---|
| 正文抓下来存成本地快照,不做在线阅读直连 | 划线锚点是字符偏移、进度锚点是页码+百分比、读取链路只认 library/ 副本;正文一旦在两次阅读之间变化,划线会静默错位(mark-anchor.ts 找不到就跳过不报错) |
落成章节书(manifest.json + 单章 HTML),照抄 mobi 形态 | readEpubManifest / /content?chapter=n / safeZipPath 路径防护 / purgeBookFiles 全部零改动复用;1221 章若塞成单个 txt 会退化成全书偏移锚点 |
抓取放 Host 侧 ctx.web.fetch | 沙箱里 fetch 是 trap(调用即抛);Client 半不能加第三方依赖,且跨域抓取受 CORS 限制 |
| 只存段落纯文本,重写成 ` | |
| ` | 抓来的 HTML 原样入库会把注入面从客户端一个消毒点扩散到所有消费方(Agent 工具、未来的导出) |
| 不引 HTML 解析库 | 目标站正文容器内只有 ` |
;字符串扫描足够,jsdom更不可能进沙箱(依赖 node 内置与process`) |
抓取是「前端轮询驱动的分片」,不是后台循环
沙箱里 setTimeout / setInterval 都是 trap,写不出后台定时任务;1221 章 × ≥1.5s ≈ 30 分钟的抓取也不可能塞进一个 HTTP 请求。所以:
POST /api/reading-workbench/sources/web { url } → 抓目录 + 建书 + 抓首章
POST /api/reading-workbench/sources/web/fetch { bookId, count? } → 按序抓 1~5 章(幂等,可中断)
GET /api/reading-workbench/sources/web/status ?id= → { total, fetched, pending, errors }
节奏由请求往返天然提供;web.json 记录 fetched: number[] 与各章源 URL,重进工作台可接着抓。
两个真实陷阱(都已固化进自测)
- 目录页会被抓取后端截断:
ctx.web.fetch的maxBodyChars默认 100,000 字符,而目标站目录页实测 135,879 字符(169,457 字节按 UTF-8 解码后)⇒ 尾部章节抓不到。 解法是目录懒扩展:抓正文页时顺手取data-nurl(下一章链接),抓完已知最后一章就把下一章追加进manifest.json,如此顺链补到末章(nextUrl为空)。截断时warnings会明确告知用户。 - 真实页面 div 不配平(实测 48 个
):最初用「标签配平」划定正文范围,配平失败后回落到整页扫描,把导航/页脚的当成正文(还骗过了 50 字阈值)。 改为extractBodyParagraphs:从正文容器起点顺序吃掉连续,遇到非结构元素即停 —— 天然有界、不依赖配平。自测里有一条专门断言「正文里不能出现分類/最新章節/使用手機掃碼」。
抓取通道:宿主不可用时自动回退到内置
抓取走两条通道,由插件配置 webFetchMode 选择:
| 取值 | 行为 |
|---|---|
host | 只用宿主 ctx.web.fetch(带 SSRF 防护、地址固定等完整策略) |
builtin | 只用插件内置的受限 HTTPS 抓取器(src/web-fetch-fallback.ts) |
auto(默认) | 优先宿主;宿主通道不可用时自动落到内置,并把原因写进宿主日志 |
为什么必须有内置通道:dsh-web 默认同时注册两个抓取提供方(本地 http 与远端
deepseek-fetch-api),fetchProvider 一旦没有固定,每次调用都抛 WEB_PROVIDER_AMBIGUOUS ——
整个网页书源会不可用,而错误看起来还像站点问题。用户环境实测正是这种情况
(profile 里没有任何 fetchProvider 配置),光靠报错解决不了。
内置通道的安全边界(比宿主通道更窄,不是"绕过安全策略"):
- 只允许
https:; - 只允许书源适配器声明支持的域名(当前
alicesw.tw/alicesw.com); - 只跟随同源重定向,最多 3 跳;
- 响应体上限 8 MB、超时 30 s;
- 不发送
Accept-Encoding、不回传 cookie、不带任何凭据; - 严格 UTF-8 解码,失败即报错(不把 U+FFFD 静默混进正文)。
准入检查都在发请求之前完成,因此可在离线自测里断言(非法 URL / 非 https / 白名单外域名 /
伪装子域 alicesw.tw.evil.com / 环回 / 内网地址,共 6 条)。
顺带的好处:内置通道不受 provider 的 100,000 字符上限约束,目标站目录页
(实测 135,879 字符)能一次抓全 1221 章,不再需要目录懒扩展兜底。
懒扩展仍然保留,因为 webFetchMode=host 时那条上限真实存在。
抓到哪读到哪:按需抓取(2026-10-19 用户实测反馈后新增)
用户反馈:抓了一本书,只有第 1 章能读;点第 2 章报 加载失败: chapter file missing,
而且「无法区分哪个章节已经下载」。
根因(两层,都要修):
- 设计缺陷:抓取进度靠「书架面板的轮询循环」推进,而阅读是并列的另一条路径 —— 用户进阅读器、重启 GUI、或想直接读第 500 章,都会卡住。把「能不能读到内容」的前提押在 「后台把前面所有章抓完」上是错的。
- 反馈缺失:
/content?chapter=n在文件缺失时只回一句chapter file missing;/sources/web/status只报「已抓 N 章」这种聚合值,答不了「我想读的那一章抓了没有」。
修法:
GET /content?id=&chapter=n对网页书按需抓取:该章文件不存在时,Host 同步抓取这一章 (1~4 秒)再返回正文;抓不到才报错(502+ 中文可行动说明)。 ⇒ 「点到哪章读哪章」,不再依赖后台进度。/sources/web/status新增fetchedIndexes: number[]:阅读器的章节目录据此逐章标记 已下载 / 未下载。- 轮询续抓保留,但它降级为后台补齐(离线阅读、批量下载),不再是「能不能读」的前提。
为什么这件事函数层测试抓不到:web-source-selftest 覆盖适配器与编排,全绿;
问题出在路由面(/content 的缺失分支)与跨路径的时序假设上。
因此新增路由级套件 web-source-instance-check.mjs:把 makeRoutes 挂到真实
node:http 服务上,用真实 HTTP 往返验证建书 / 状态 / 按需抓取 / 续抓 / 错误码(28 项)。
失败纪律(对齐「审核不能有假阳性」)
- 抓取失败的错误码逐条翻成中文可行动提示(含内置通道的 9 个 code),不是抛原始英文;
- 正文段落 < 1 或总字数 < 50 → 抛错并附 URL,绝不静默存回一章空正文;
- 建书时首章抓取失败 → 回滚(删书目与目录),书架上不会出现打不开的空书;
- 抓取通道完全不可用(宿主无
ctx.web且配置为host)→ 网页书源路由回 501 并说明「书架/导入/阅读不受影响」,其余功能照常(fiberPhase仍是 active); - 单章失败 → 进
web.json的errors(章序 + URL + 中文原因),不阻断其他章。
已知限制
- 只支持 alicesw.tw(
SiteAdapter已是注册表形式,加站点只需新增一个适配器)。xchina.co不可用:直连 TCP 超时 + 经代理遇 Cloudflare JS 挑战。 - 目录页可能被宿主抓取后端截断(provider 上限 100,000 字符 vs 目录页 135,879 字符)。建书时若检测到截断,会自动改用插件内置通道(无字符上限)重抓一次完整目录并写进
warnings;只有补充抓取也失败时才按已知章数建书,剩余部分靠目录懒扩展补齐。 - 未抓的章节点开会自动抓取(1~4 秒),阅读器章节目录逐章标记
✓ 已下载/↓ 未下载;后台续抓只负责批量补齐,不再是「能不能读」的前提。 - 不保存原站图片/插图;不保留内联强调。
- 重新抓取会让旧划线失配,首版不提供整本覆盖式重抓(只按序补抓缺失章)。
- 站点改版会让选择器失效 —— 此时表现为「解析失败」并报错,不会抓到错误内容。
验证
node scripts/web-source-selftest.mjs # 66 项:HTML 工具 / 适配器(真实样本 1221 章)/ 反证 / 内置通道准入 / 抓取编排(幂等、目录懒扩展、失败不阻断)
node scripts/web-source-live-check.mjs --live # 16 项:**真实访问站点**,验证「目录截断 → 懒扩展」在真实网络下接得上(5 次请求、1.5s/章)
web-source-selftest.mjs 不依赖网络(适配器吃 docs/web-source/samples/ 的真实落盘样本,编排用假抓取函数),已接入 pnpm check。
web-source-live-check.mjs 需要显式 --live 才跑,避免在 CI 里打站点。
本次踩到并修掉的第三类沙箱事故:可选服务不能写进 inject
第一版按「服务必须 inject」的既有纪律写了 inject: ['webServer','tools','web'],构建时沙箱契约自测立刻炸:
FAIL 修复后 Host 半 fiber 为 ACTIVE state=0 ← 既不是 ACTIVE(2) 也不是 FAILED(3)
FAIL