dsh-read-url 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-08-24
4
+
5
+ ### 四项改进:NUL 防护 + JSON-LD 元数据 + base href + meta-refresh 跟随
6
+
7
+ 延续维护期原则:零运行时依赖、静态 schema 保 KV-cache(工具 schema 无任何变更)、fail-open 不报错。
8
+
9
+ **修复(实体解码安全)**
10
+ - **NUL/控制字符实体防护**:`decodeTextEntities` 原先对 `�`(NUL)、``(DEL)及 C1 控制区直接 `String.fromCodePoint`,控制字符会混入正文与模型上下文。新增 `fromSafeCodePoint`:C0/C1 中非空白控制字符(0–8、11–12、14–31、127–159)输出空格;`\t\n\r` 作为合法 HTML 空白实体保留(页面用 `
` 表示换行,压成空格会破坏 markdown 段落结构);surrogate 区(U+D800–DFFF)输出 U+FFFD,不再泄漏孤立代码单元。
11
+
12
+ **功能补全**
13
+ - **JSON-LD 元数据(jsonLdMeta)**:大量新闻/文章站的发布时间、作者只存在于 schema.org `<script type="application/ld+json">`。解析首个含 datePublished/author 的 JSON-LD 块(容忍损坏 JSON;支持 @graph、@type 数组、author 为字符串/对象/数组形态;块大小有界防恶意大 script)。合并链:**meta → JSON-LD → byline**——显式 `article:`/`og:` meta 日期保持权威;JSON-LD 的 datePublished 高于通用 `date`/`dc.date` meta(后者常是 last-modified 而非发布时间)。
14
+ - **base href 链接解析(detectBaseHref)**:老论坛/框架页声明 `<base href>` 时,页面内相对链接按文档 URL 解析会落错主机。新增 `detectBaseHref(html, finalUrl)`(head 内第一个 base,属性序无关、协议相对按文档协议解析、空值忽略),`extractLinks` 与 `findNextLink` 统一使用——链接列表、分页跟随、整站爬虫三处同时受益。
15
+ - **meta-refresh 跟随(metaRefreshTarget + 有界循环)**:`<meta http-equiv="refresh" content="0;url=...">` 壳页(链接跳板、反盗链中转、无 JS 的 SPA 入口)只返回跳板 HTML,正文在第二个 URL。新增纯函数 `metaRefreshTarget`(属性顺序无关、大小写不敏感、content 内引号剥离、相对目标按文档 URL 或 base href 解析、非 http(s) 拒绝、**只跟随立即跳转**——定时自刷新如 `content="30; url=..."` 是正常页面,不跟随)。插入点:dispatch 返回 HTML 之后、extract() 之前——先于 SPA 渲染判断,命中时省掉一次 playwright 全渲染。有界跟随最多 3 跳 + normalizeUrl seen-set 防循环;复用 fetchPage 直连/代理竞速路径;**fail-open**:跟随失败(网络错/非 HTML)保留原 HTML 继续静态提取,不报错;跟随后的页面不单独写全局缓存(调用方缓存键仍是原始 URL,壳页重请求会重新跟随)。输出 schema 不变,`finalUrl` 自然反映最终地址。
16
+
17
+ **验证**
18
+ - 单元断言 **88 → 114**(新增 26 条:控制字符实体 6、JSON-LD 5、metaRefreshTarget 纯函数 9、base href 链接 3、readUrl 集成跟随 4——含 2 跳链、循环终止、fail-open)+ 12 SPA 断言全绿;
19
+ - probe.mjs 对抗探针全绿,性能无回归(新增路径全部线性);
20
+ - 实网:24 扩展站 **18/24 OK**(失败均境外连接超时/403 环境边界);152 站回归 **93 OK / 24 THIN+EMPTY / 35 ERR / 0 THREW**,0 崩溃、0 内容性回归(ERR 全为境外超时与 403/412 反爬,与 v1.2.0 基线一致);
21
+ - taptap.cn 当前已上阿里云 WAF JS 验证页(非 meta-refresh 壳页),壳页场景由本地集成测试覆盖(标准/相对/2 跳/循环/fail-open)。
22
+
23
+ ### 同日第二轮:健壮性审查(6 处修复 + JSON-LD 嵌套递归缺陷)
24
+
25
+ 静态通读 + 新测试驱动的健壮性审查:
26
+
27
+ **修复**
28
+ - **extractLinks / findNextLink 正则加界**:锚点属性 `[^>]+`/`[^>]*` 无界——超长属性(或无 `>`)的 `<a` 会让每个锚点位置扫描到串尾,与 v1.2.0「属性扫描统一加 {0,1000} 界」原则不一致。统一改为 `[^>]{0,1000}`,与全项目对抗性防御对齐;超长属性链接被安全跳过,正常链接不受影响(实测 300KB 属性 5–10ms)
29
+ - **meta 属性提取顺序无关(metaContent 两段式)**:`extractMeta` / og:title / og:site_name 原为「property/name 必须在 content 之前」的单段正则——content 在前(`<meta content="..." property="...">`)的真实页面提取为空。改为先定位标签再读 content,与 metaRefreshTarget 同手法;content 值内嵌引号(`他说'你好'`)不再截断
30
+ - **JSON-LD 嵌套结构(新测试抓出)**:原 collect 只展开数组与 @graph,`ItemList → mainEntity → Article`、`WebPage → mainEntityOfPage` 等嵌套结构的 datePublished/author 全部漏掉。改为递归展开全部对象属性(深度帽 20 防恶意嵌套)
31
+ - **charset 嗅探补老式写法**:`<meta http-equiv="Content-Type" content="text/html; charset=gb2312">`(无 charset 属性、HTTP 头无 charset 的 legacy GBK 页)原先识别不到。sniffCharset 增加 http-equiv 分支
32
+ - **JSON-LD 块上限 20k → 100k**:真实新闻站的 article-list JSON-LD 常超 20k,元数据随之丢失。上限放宽(防御性有界不变,元数据在块首节点,扫描成本可控)
33
+ - **markdown 链接括号转义**:`[inner](url)` 的 URL 含 `)`(wiki/框架页常见)会提前终止 markdown 链接。href 中 `(` `)` 百分号编码为 `%28`/`%29`
34
+ - **爬虫噪音扩展**:NOISE_EXT 增加 `m3u8|m3u|mpd|flv|ts`(流媒体 URL 不再进爬虫队列)
35
+
36
+ **验证**
37
+ - 单元断言 **114 → 123**(新增 9 条:meta 属性序颠倒 2、content 引号 1、og 顺序 1、http-equiv charset 1、JSON-LD 大块 1、嵌套 mainEntity 1、markdown 括号 1、爬虫 m3u8 噪音 + 超长属性限时 2)+ 12 SPA 断言全绿;
38
+ - probe.mjs 全绿,无性能回归;
39
+
40
+ ### 同日第二轮:全量审查收尾(4 处一致性修复)
41
+
42
+ 针对全量代码审查(index.js 全流程逐段核查 + 实测验证)发现的一致性/元数据缺陷:
43
+
44
+ **修复**
45
+ - **meta-refresh 跟随后 charset 未更新**:`charset` 在 follow 之前取自壳页,壳页 utf-8 → 目标 GBK 时返回的 charset 标记与正文实际编码不符。`followMetaRefresh` 现在返回跟随目标的 charset,`readUrl` 在跟随后更新
46
+ - **readability base URL 用原始请求地址**:`upgrade()` 传 `url`(原始 URL)给 readability,meta-refresh 跟随/SPA 渲染后文档实际在另一主机,相对链接/图片会按错误 base 解析。改为传当前 `finalUrl`(SPA 路径显式传 `rr.finalUrl`)
47
+ - **markdown 代码块实体未解码**:`<pre>` 内 `&lt;div&gt;` 输出字面量(与普通文本不一致,浪费 token)。改为「先剥标签、再解码实体」——代码中的实体还原为可读字符
48
+ - **read_url_links 不跟随 meta-refresh 壳页**:read_url 会跟随、read_url_links 只返回跳板页链接——工具间行为不一致。复用 `followMetaRefresh`(fail-open、3 跳有界)
49
+
50
+ **验证**
51
+ - 单元断言 **123 → 126**(新增 3 条:pre 实体解码、跟随 charset 更新、read_url_links 壳页跟随)+ 12 SPA 断言全绿;probe.mjs 32 PASS 无回归
52
+
3
53
  ## [1.2.0] - 2026-08-22
4
54
 
5
55
  ### 修复三处隐患 + 进一步省 token
package/README.md CHANGED
@@ -107,7 +107,7 @@ chars 800+800/12398 · cached
107
107
 
108
108
  ### Tools
109
109
 
110
- **`read_url(url, maxChars?, offset?, mode?, includeLinks?)`** — fetch and extract clean body. Handles HTML pages, JSON APIs (fully compact render with long-value clipping) and RSS/Atom feeds (entry list + `feedCount`); paginated articles (novel/news/forum) are auto-joined up to `paginateMax` pages; page metadata (`published`, `author`) is harvested automatically — meta tags first, with a byline fallback from the body text when meta is absent
110
+ **`read_url(url, maxChars?, offset?, mode?, includeLinks?)`** — fetch and extract clean body. Handles HTML pages, JSON APIs (fully compact render with long-value clipping) and RSS/Atom feeds (entry list + `feedCount`); paginated articles (novel/news/forum) are auto-joined up to `paginateMax` pages; page metadata (`published`, `author`) is harvested automatically — **meta → schema.org JSON-LD → byline, three-tier fallback** (v1.3.0: JSON-LD fills publication time/author on news sites); `<meta http-equiv="refresh">` shell pages (link hops / anti-hotlink relays / no-JS SPA entries) are followed to the real page automatically (≤3 hops, loop-safe, fails open on fetch errors, v1.3.0); pages declaring `<base href>` (frame sites / legacy forums) resolve relative links against it (v1.3.0)
111
111
 
112
112
  | Param | Type | Default | Description |
113
113
  |---|---|---|---|
@@ -233,9 +233,9 @@ const results = await Promise.all([
233
233
  - **Optional enhancement 2 (SPA page rendering)**: run `npm i playwright && npx playwright install chromium` in the DSH profile directory to auto-enable it. When the extracted body is empty and the page is script-heavy (likely Vue/React client-rendered) or a JS-redirect shell (few scripts but an empty `<body>`), the plugin automatically renders it with headless Chromium before extracting (a `rendered` flag tells the model); the rendered text is accepted only when it meaningfully beats the static one (≥20 chars from an empty static extraction — short real content is no longer rejected); **bot-challenge defense** — Cloudflare-style interstitials ("Just a moment...") get an extra 8s poll for the auto-redirect, and if the page still looks like a challenge the rendered result is rejected and the static body kept (an interstitial can carry MORE text than the real body — measured 259 vs 135 chars — and must never be mistaken for an improvement); the interstitial DOM never feeds pagination/link extraction either; rendering waits for the DOM to stabilize (content stops growing) instead of `networkidle` — heartbeat-polling sites never idle, so this avoids 30s timeouts; when not installed it degrades with a clear install hint, never errors — the core stays zero-dependency;
234
234
  - **Boundaries**: login-walled pages are not readable; SPA pages need the Playwright enhancement; **structured data (e.g. which like-count belongs to which comment) is out of text-extraction scope** — this plugin flattens HTML into readable text, so exact field↔value associations are lost; for precise fields, intercept the page's actual data API (see "Real-world validation" below).
235
235
 
236
- ## Real-world validation (2026-08-21, v1.0.0; re-verified 2026-08-22 against DSH 0.1.1-rc.2)
236
+ ## Real-world validation (2026-08-21, v1.0.0; re-verified 2026-08-22 against DSH 0.1.1-rc.2; 2026-08-24 v1.3.0)
237
237
 
238
- 152-site sweep driven by `multi-site.mjs` (committed, re-runnable, 8-way concurrent): **115 OK / 17 expected boundaries (login-walls·captcha·short static pages) / 20 attributed network·anti-bot errors / 0 crashes** (final round with all pre-release fixes in; network-class errors vary ±5 between runs, all environment-attributed). Coverage: CN portals/media, e-commerce (JD/Taobao/Pinduoduo/Suning/Dangdang), video (Bilibili/iQiyi/Youku/Mango), music, games, novels (Qidian/Zongheng/JJWXC legacy GBK), Q&A/forums, government, universities (Tsinghua/PKU/Fudan/SJTU…), Traditional-Chinese TW/HK (PTT/LTN/UDN), JP/KR portals (Yahoo JP/Hatena/goo/naver/daum), overseas tech docs (GitHub/dev.to/react.dev/nodejs.org/rust/go/python), feeds, JSON APIs, encoding stress (GBK/GB2312/Big5/gb18030), anti-bot & network boundaries. All errors are environment-attributed (Wikipedia/Reddit/UDN connect-timeout; W3C/Tieba/NGA/StackOverflow 403; BUPT 412; DNS-fail…) — every one returned as a structured, correctly-attributed error, never a crash.
238
+ 152-site sweep driven by `multi-site.mjs` (committed, re-runnable, 8-way concurrent): **115 OK / 17 expected boundaries (login-walls·captcha·short static pages) / 20 attributed network·anti-bot errors / 0 crashes** (final round with all pre-release fixes in; network-class errors vary ±5 between runs, all environment-attributed). Coverage: CN portals/media, e-commerce (JD/Taobao/Pinduoduo/Suning/Dangdang), video (Bilibili/iQiyi/Youku/Mango), music, games, novels (Qidian/Zongheng/JJWXC legacy GBK), Q&A/forums, government, universities (Tsinghua/PKU/Fudan/SJTU…), Traditional-Chinese TW/HK (PTT/LTN/UDN), JP/KR portals (Yahoo JP/Hatena/goo/naver/daum), overseas tech docs (GitHub/dev.to/react.dev/nodejs.org/rust/go/python), feeds, JSON APIs, encoding stress (GBK/GB2312/Big5/gb18030), anti-bot & network boundaries. All errors are environment-attributed (Wikipedia/Reddit/UDN connect-timeout; W3C/Tieba/NGA/StackOverflow 403; BUPT 412; DNS-fail…) — every one returned as a structured, correctly-attributed error, never a crash. v1.3.0 re-run (direct connect, no proxy): **93 OK / 24 THIN+EMPTY / 35 ERR / 0 THREW**, ERR all overseas connect-timeouts and 403/412 anti-bot (baseline-consistent), zero content regressions.
239
239
 
240
240
  Sweep-driven pre-release fixes (each locked by unit tests): double-escaped RSS descriptions, headerless binary sniffing, JS-redirect shell rendering, **`role="main"` container cut short by nested divs** (gnu.org 165→800 chars), **tiny `<article>` hijacking the main content** (gitlab 71→800 chars), relaxed render-acceptance threshold.
241
241
 
@@ -258,7 +258,7 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
258
258
  | Batch + failure isolation | 4-URL mix | ✅ 2/4 ok, failures isolated |
259
259
  | Site crawl | Ruan Yifeng blog | ✅ 5/5 pages tree map |
260
260
 
261
- - **88 zero-dep assertions** (v1.2.0 adds: sentence-aligned truncation, compact JSON, pagination variants, adversarial-input time bounds, prose &lt; preservation, deep-JSON degradation) (incl. entity decoding, description-budget guard, link dedupe, table-separator escaping, proxy-fallback function, missing-args tolerance, race logic, empty-race guard, schema budget, bare-main pick, worst-case timeout budgets, yml string coercion + clamping, strict-host seam degradation, UTF-16 BOM, Shift-JIS, density filter, pagination join/cap/disable, JSON render, RSS parse, sitemap rejection, 429 Retry-After retry, image alt, code-fence language, metadata, og:description fallback, double-escaped feed, headerless binary sniff, nested role=main, tiny-article fallback, unbalanced-tag degradation, byline fallback, challenge-page detection, isConcurrencySafe declaration + concurrent cache-race smoke) + **12 SPA-test assertions** all green;
261
+ - **126 zero-dep assertions** (v1.3.1 round 2 adds: pre-block entity decoding, follow-charset propagation, read_url_links shell following; v1.3.1 robustness round adds: meta attribute-order, quoted content values, http-equiv charset, oversized JSON-LD + nested mainEntity, markdown paren escaping, m3u8 crawl noise, bounded oversized attributes; v1.3.0 adds: control-char entity sanitization, JSON-LD metadata, base-href link resolution, meta-refresh following ×11; v1.2.0 adds: sentence-aligned truncation, compact JSON, pagination variants, adversarial-input time bounds, prose &lt; preservation, deep-JSON degradation) (incl. entity decoding, description-budget guard, link dedupe, table-separator escaping, proxy-fallback function, missing-args tolerance, race logic, empty-race guard, schema budget, bare-main pick, worst-case timeout budgets, yml string coercion + clamping, strict-host seam degradation, UTF-16 BOM, Shift-JIS, density filter, pagination join/cap/disable, JSON render, RSS parse, sitemap rejection, 429 Retry-After retry, image alt, code-fence language, metadata, og:description fallback, double-escaped feed, headerless binary sniff, nested role=main, tiny-article fallback, unbalanced-tag degradation, byline fallback, challenge-page detection, isConcurrencySafe declaration + concurrent cache-race smoke) + **12 SPA-test assertions** all green;
262
262
  - Real case: on a Xiaoheihe post, comment like-counts (`up` field) could not be attributed from flattened text — **precise fields should come from the page's underlying data API** (e.g. `/bbs/app/link/tree` JSON). This is a shared boundary of text extractors, not a defect.
263
263
 
264
264
  ## Roadmap
@@ -276,6 +276,8 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
276
276
  - [x] Parallel tool-call declaration (isConcurrencySafe; multi-source wall-clock time = slowest site, v1.1.0)
277
277
  - [x] Separate failure cache, label-based race attribution, sentence-aligned truncation, fully compact JSON render (~21% smaller), merged status flags, arrow-wrapped pagination variants (v1.2.0)
278
278
  - [x] Adversarial-page linearization (unclosed-tag/unpaired-lt/attribute-scan bounds + recursion depth cap, fixing a quadratic DoS vector), prose &lt; preservation, proxy-path redirects keep the final URL, deep-JSON degradation instead of crash (v1.2.0 round 2)
279
+ - [x] Control-char entity sanitization (`&#0;`/`&#127;` → space, surrogates → U+FFFD), JSON-LD metadata (meta → JSON-LD → byline), `<base href>` link resolution (frame/legacy-forum pages), `<meta refresh>` shell following (≤3 hops, loop-safe, fail-open, v1.3.0)
280
+ - [x] Robustness round: order-independent meta extraction (content-first no longer missed), recursive JSON-LD nesting (ItemList→mainEntity), legacy `<meta http-equiv="Content-Type">` charset, bounded link/pagination regexes, markdown paren escaping, crawl stream-URL noise filtering (v1.3.1)
279
281
 
280
282
  > From v1.0.0 the plugin enters maintenance mode: bug fixes first, updates kept rare.
281
283
 
package/README.zh.md CHANGED
@@ -107,7 +107,7 @@ chars 800+800/12398 · cached
107
107
 
108
108
  ### 工具
109
109
 
110
- **`read_url(url, maxChars?, offset?, mode?, includeLinks?)`** — 抓取并提取干净正文。支持 HTML 网页、JSON 接口(紧凑重排渲染)、RSS/Atom 订阅源(条目列表 + `feedCount`);分页长文(小说/新闻/论坛)自动拼接至 `paginateMax` 页;页面元数据(`published`、`author`)自动提取——meta 标签优先,无 meta 时从正文署名行兜底
110
+ **`read_url(url, maxChars?, offset?, mode?, includeLinks?)`** — 抓取并提取干净正文。支持 HTML 网页、JSON 接口(紧凑重排渲染)、RSS/Atom 订阅源(条目列表 + `feedCount`);分页长文(小说/新闻/论坛)自动拼接至 `paginateMax` 页;页面元数据(`published`、`author`)自动提取——**meta → JSON-LD → 正文署名行 三级兜底**(v1.3.0 起解析 schema.org JSON-LD,新闻站发布时间/作者显著补全);`<meta http-equiv="refresh">` 壳页(链接跳板 / 反盗链中转 / 无 JS 的 SPA 入口)自动跟随到真实页(最多 3 跳、防循环、失败保留原页不报错,v1.3.0);声明 `<base href>` 的框架/老论坛页,页面内相对链接按 base 解析(v1.3.0)
111
111
 
112
112
  | 参数 | 类型 | 默认 | 说明 |
113
113
  |---|---|---|---|
@@ -233,9 +233,9 @@ const results = await Promise.all([
233
233
  - **可选增强二(SPA 页面渲染)**:在 DSH profile 目录执行 `npm i playwright && npx playwright install chromium` 后自动启用。检测到正文为空且(页面脚本密集 疑似 Vue/React 客户端渲染,或 body 为空的 JS 跳转壳——script 数不多但正文全靠跳转)时,自动用无头 Chromium 渲染后再提取(`rendered` 标记告知模型);渲染结果仅在**显著优于**静态提取时采用(静态为空时 ≥20 字符即接受,防短正文被拒);**人机验证页防御**——Cloudflare「Just a moment...」等指纹页识别后额外轮询 8s 供其自动跳转,仍未通过则拒绝渲染结果、保留静态正文(指纹页文字量可能超过真实正文,实测 259 vs 135 字符,绝不当作提升),指纹页 DOM 也不污染分页/链接提取;渲染采用 `domcontentloaded` + **DOM 稳定轮询**(内容停止增长即收,上限 10s)而非 `networkidle`——心跳轮询站永不空闲,避免 30s 超时;未安装时优雅提示安装方法、不报错——核心保持零依赖;
234
234
  - **边界**:登录墙页面无法读取;SPA 页面需安装 Playwright 增强后渲染读取(未安装时返回明确提示);**结构化数据(如评论的点赞数归属、榜单数值)不在文本提取范围**——本插件把 HTML 扁平化为可读文本,字段与数值的精确对应关系会丢失;需要精确字段时,用 Playwright 拦截页面实际调用的数据 API 获取(见下方「真实世界验证」)。
235
235
 
236
- ## 真实世界验证(2026-08-21,v1.0.0;2026-08-22 适配 DSH 0.1.1-rc.2 复验)
236
+ ## 真实世界验证(2026-08-21,v1.0.0;2026-08-22 适配 DSH 0.1.1-rc.2 复验;2026-08-24 v1.3.0 复验)
237
237
 
238
- 152 站全量实测(`multi-site.mjs` 已提交可复跑,8 并发):**115 OK / 17 预期边界(登录墙·验证页·静态小页) / 20 网络·反爬归因错误 / 0 崩溃**(含全部发布前修复的终态轮;网络类错误逐轮有 ±5 波动,均为环境归因)。覆盖国内门户 / 媒体 / 电商(京东·淘宝·拼多多·苏宁·当当)/ 视频(B 站·爱奇艺·优酷·芒果)/ 音乐 / 游戏 / 小说(起点·纵横·晋江 legacy GBK)/ 问答 / 论坛 / 政府 / 高校(清北复交等 8 所)/ 港台繁体(PTT·自由时报·联合报)/ 日韩(Yahoo JP·Hatena·goo·naver·daum)/ 海外技术站(GitHub·dev.to·react.dev·nodejs·rust·go·python docs)/ 订阅源 / JSON API / 编码压力(GBK·GB2312·Big5·gb18030)/ 反爬与网络边界。错误全部环境归因(维基/Reddit/UDN 连接超时;W3C/贴吧/NGA/StackOverflow 403;北邮 412;DNS 失败等)——每一个都返回结构化、准确归因的错误,无一崩溃。
238
+ 152 站全量实测(`multi-site.mjs` 已提交可复跑,8 并发):**115 OK / 17 预期边界(登录墙·验证页·静态小页) / 20 网络·反爬归因错误 / 0 崩溃**(含全部发布前修复的终态轮;网络类错误逐轮有 ±5 波动,均为环境归因)。覆盖国内门户 / 媒体 / 电商(京东·淘宝·拼多多·苏宁·当当)/ 视频(B 站·爱奇艺·优酷·芒果)/ 音乐 / 游戏 / 小说(起点·纵横·晋江 legacy GBK)/ 问答 / 论坛 / 政府 / 高校(清北复交等 8 所)/ 港台繁体(PTT·自由时报·联合报)/ 日韩(Yahoo JP·Hatena·goo·naver·daum)/ 海外技术站(GitHub·dev.to·react.dev·nodejs·rust·go·python docs)/ 订阅源 / JSON API / 编码压力(GBK·GB2312·Big5·gb18030)/ 反爬与网络边界。错误全部环境归因(维基/Reddit/UDN 连接超时;W3C/贴吧/NGA/StackOverflow 403;北邮 412;DNS 失败等)——每一个都返回结构化、准确归因的错误,无一崩溃。v1.3.0 复验轮(本机直连,无代理):**93 OK / 24 THIN+EMPTY / 35 ERR / 0 THREW**,ERR 全部为境外连接超时与 403/412 反爬(与基线一致),无内容性回归。
239
239
 
240
240
  扫描驱动的发布前修复(全部带单测锁定):RSS 双重转义、无头二进制嗅探、JS 跳转壳渲染、**`role="main"` 嵌套 div 截断**(gnu.org 165→800 字符)、**小 article 劫持主内容**(gitlab 71→800 字符)、渲染接受门槛放宽。
241
241
 
@@ -258,7 +258,7 @@ const results = await Promise.all([
258
258
  | **批量 + 失败隔离** | 4 URL 混合 | ✅ 2/4 成功、失败隔离 |
259
259
  | **整站爬取** | 阮一峰博客 | ✅ 5/5 页树状站点地图 |
260
260
 
261
- - **88 个单元断言**(v1.2.0 新增:长段落句级对齐、紧凑 JSON、分页变体、对抗输入限时、散文 &lt; 保留、深 JSON 降级)(含实体解码、description/schema 预算守卫、链接去重、表格分隔行转义、代理回退函数、空参容错、竞速逻辑、空竞速守卫、裸 main 提取、最坏链路超时预算、yml 字符串强转+钳制、严格宿主 seam 降级、UTF-16 BOM、Shift-JIS、密度过滤、分页拼接/封顶/关闭、JSON 渲染、RSS 解析、sitemap 拒绝、429 Retry-After 重试、图片 alt、代码语言、元数据、og:description 回落、双转义 feed、无头二进制嗅探、嵌套 role=main、薄 article 回落、不平衡标签降级、byline 兜底、人机验证页识别、isConcurrencySafe 声明 + 并发缓存竞态烟雾)+ **12 个 SPA 测试断言**全绿;
261
+ - **126 个单元断言**(v1.3.1 收尾轮新增:pre 代码块实体解码、meta-refresh 跟随 charset 更新、read_url_links 壳页跟随;v1.3.1 健壮性轮新增:meta 属性序颠倒、content 内嵌引号、http-equiv charset、JSON-LD 大块/嵌套 mainEntity、markdown 括号转义、m3u8 噪音、超长属性限时;v1.3.0 新增:控制字符实体防护、JSON-LD 元数据、base href 链接、meta-refresh 跟随 11 条;v1.2.0 新增:长段落句级对齐、紧凑 JSON、分页变体、对抗输入限时、散文 &lt; 保留、深 JSON 降级)(含实体解码、description/schema 预算守卫、链接去重、表格分隔行转义、代理回退函数、空参容错、竞速逻辑、空竞速守卫、裸 main 提取、最坏链路超时预算、yml 字符串强转+钳制、严格宿主 seam 降级、UTF-16 BOM、Shift-JIS、密度过滤、分页拼接/封顶/关闭、JSON 渲染、RSS 解析、sitemap 拒绝、429 Retry-After 重试、图片 alt、代码语言、元数据、og:description 回落、双转义 feed、无头二进制嗅探、嵌套 role=main、薄 article 回落、不平衡标签降级、byline 兜底、人机验证页识别、isConcurrencySafe 声明 + 并发缓存竞态烟雾)+ **12 个 SPA 测试断言**全绿;
262
262
  - 一个真实案例:小黑盒帖子的评论点赞数(`up` 字段)无法从扁平文本确定归属——**精确字段应走页面背后的数据 API**(如 `/bbs/app/link/tree` JSON),这是同类文本提取器的共同边界,不是缺陷。
263
263
 
264
264
  ## Roadmap
@@ -276,13 +276,15 @@ const results = await Promise.all([
276
276
  - [x] 并行工具调用声明(isConcurrencySafe,多源阅读墙钟时间按最慢一站计,v1.1.0)
277
277
  - [x] 失败缓存独立(不再挤占成功缓存名额)、race 错误按 label 归因、长段落句级对齐截断、JSON 完全紧凑渲染 + 长值截断(约省 21%)、状态行合并、分页箭头变体(v1.2.0)
278
278
  - [x] 对抗页面线性化(无配对尖括号/无闭合标签/属性扫描加界 + 递归深度帽,修复 60KB→2.2s 的二次复杂度 DoS 向量)、散文 &lt; 不再被吞、代理重定向保留最终 URL、深 JSON 降级不崩溃(v1.2.0 第二轮)
279
+ - [x] NUL/控制字符实体防护(`&#0;`/`&#127;` 输出空格、surrogate 输出 U+FFFD,不污染模型上下文)、JSON-LD 元数据三级提取(meta → JSON-LD → byline)、`<base href>` 链接解析(框架/老论坛页)、`<meta refresh>` 壳页自动跟随(最多 3 跳、防循环、fail-open,v1.3.0)
280
+ - [x] 健壮性轮:meta 属性序无关提取(content 在前不再漏)、JSON-LD 嵌套结构递归(ItemList→mainEntity)、`<meta http-equiv="Content-Type">` 老式 charset、链接/分页正则统一加界、markdown 括号转义、爬虫流媒体噪音过滤(v1.3.1)
279
281
 
280
282
  > v1.0.0 起进入维护期:以修 bug 为主,减少更新频率。
281
283
 
282
284
  ## 开发
283
285
 
284
286
  ```bash
285
- node test.mjs # 单元自测(转码/提取/Markdown/截断/批量/站点爬取/缓存隔离/配置钳制)
287
+ node test.mjs # 单元自测(转码/提取/Markdown/截断/批量/站点爬取/缓存隔离/配置钳制/控制字符/JSON-LD/base href/meta-refresh)
286
288
 
287
289
  # SPA 渲染真实测试(需 playwright 已安装,未装自动 SKIP)
288
290
  node test-spa.mjs # 12 断言:JS 正文/渲染后链接/工具不崩溃/缓存隔离/JS 跳转壳