dsh-read-url 1.3.1 → 1.6.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,6 +1,158 @@
1
1
  # Changelog
2
2
 
3
- ## [1.3.0] - 2026-08-24
3
+ ## [1.6.1] - 2026-08-30
4
+
5
+ > 迭代 3(效率与缓存轮):缓存 FIFO → LRU、分页/跟随总时间预算、全量收官回归。零 schema 变更。
6
+
7
+ ### 改进
8
+
9
+ - **成功/失败缓存 FIFO → LRU**:原实现按插入序驱逐,32 条容量下「老但热」的条目(长会话中反复读取的同一页)会被后来者挤掉触发重复 fetch。命中即重插为最新槽(Map delete+set),热点页面在 TTL 内永不被驱逐;failCache 同样处理(反复失败的坏 URL 也不会在 TTL 内被挤出导致重复请求)。
10
+ - **meta-refresh 跟随链总预算**:每跳自带 fetch 超时,3 跳最坏 3×timeout——此前未计入工具超时公式(该公式只预留 paginateMax+1 次 fetch + 渲染余量)。整条跟随链共享一个 timeout 预算,超限即停止跟随(保留当前 HTML,fail-open)。
11
+ - **分页链总时间预算**:while 循环增加墙钟守卫 `startedAt + timeoutMs × (paginateMax+1)`——首页或 SPA 渲染变慢时,分页链自动让出预算而不是顶穿工具超时(顶穿意味着整个调用被宿主杀掉、结果全丢;现在至少返回已拼好的部分)。
12
+
13
+ ### 验证
14
+
15
+ - 单元断言 **175 → 176**(+1:LRU 驱逐语义——填满 3 条、命中最老条 a 提升、加入 d 驱逐次老条 b、b 重新 fetch 而 a 仍命中;测试内通过插件自身 dispose 清空共享缓存保证确定性)+ 12 SPA + probe 47 PASS 零 THROW;
16
+ - 收官全量回归:152 站扫描 + site-types 语义探针 + 对抗 probe(见下轮备注/日志)。
17
+
18
+ ## [1.6.0] - 2026-08-30
19
+
20
+ > 迭代 2(站点类型扩展轮):AMP/响应式图片、纯文本家族原生路径、非标准 JSON 消毒。零 schema 变更。
21
+
22
+ ### 新增
23
+
24
+ - **AMP 页面图片**:AMP HTML 用 `<amp-img>` 代替 `<img>`,markdown 模式图片此前全丢——图片预处理同等对待 amp-img(alt/src/data-src 同一套规则)。
25
+ - **`<picture>/<source srcset>` 回退**:响应式布局把真实图片 URL 放在 `<source srcset>`、`<img>` 只留 data: 占位时,首个 source URL 注入 img 兜底;img 自带可用 src 的 picture 不动。
26
+ - **纯文本家族原生路径**:`text/plain` / `text/markdown` / `text/csv` 且前 5000 字符无 HTML 标签形状时不再走 HTML 管线(那会把每行压平成一段)——行结构原样保留(CRLF 归一、行尾空白清理、3+ 空行折叠)。.txt 日志、.md 文档、robots.txt、CSV 数据集逐行可读。被错误标成 text/plain 的真 HTML 页仍走 HTML 管线。
27
+ - **JSON NaN/Infinity 消毒**:JS 手写序列化产物含 `NaN`/`Infinity`(非法 JSON)时 `JSON.parse` 整页失败、退化成 HTML 管线输出原始转储。解析失败后以字面量置 null 重试一次(仅在已非法的 JSON 上应用)。
28
+
29
+ ### 验证
30
+
31
+ - 单元断言 **170 → 175**(+5:amp-img、picture 注入、picture 不动、text/plain 行结构 + 误标 HTML 回归、NaN JSON)+ 12 SPA + probe 47 全绿;
32
+ - site-types.mjs **12 类 10/12 语义通过**(2 例境外连接超时):text/markdown(155 行结构保留)、robots.txt(164 行)实网验证;WordPress feed / `<time>` 博客 / 懒加载图片复验通过。
33
+
34
+ ## [1.5.2] - 2026-08-30
35
+
36
+ > 迭代 1(底层管线审查轮):fetchPage/重试/竞速/解码/spa/爬虫链路的例行审查,2 个真 bug + 2 处健壮性,零 schema 变更。
37
+
38
+ ### 修复
39
+
40
+ - **双次 429 形状崩溃(P0)**:`directFetchOnce` 在任意一次 429/503+Retry-After 都返回 `{ retryAfterMs }` 形状;`directFetch` 对第二次结果不检查原样返回——无代理路径绕过 raceFirstSuccess 的形状过滤,`decodeBuffer(undefined)` 在 `buffer[0]` 处裸抛 TypeError。现在重试预算耗尽时返回明确的 rate-limited error;等待 Retry-After 期间被 abort 则返回 cancelled(原先仍会补打一次请求)。`directFetch` 导出供测试直测(本机 Windows 系统代理无法通过环境变量屏蔽,无代理路径无法端到端复现)。
41
+ - **爬虫不跟随 meta-refresh 壳页(P0)**:`crawlSite` 直连 fetchPage——read_url 已跟随跳板壳,read_url_site 却把壳页当正文爬(无标题跳板文本 + 永远到不了真实内容)。爬虫页处理接入 `followMetaRefresh`(fail-open),提取与出站链接均基于跟随后的文档。
42
+
43
+ ### 加固
44
+
45
+ - **spa.js 渲染轮询**:`evaluate` 连续失败两次(页面已崩)即放弃 DOM 稳定等待,不再对崩溃页烧满 10s;移除 playwright `page.goto` 不支持的 `signal` 选项(静默忽略、误导读者),abort 语义由循环内检查与 catch 承担。
46
+
47
+ ### 验证
48
+
49
+ - 单元断言 **168 → 170**(+2:directFetch 双 429 error 形状(本地 429 服务器直测,确认重试恰一次);read_url_site 跟随壳页落在真实页)+ 12 SPA 断言 + probe 47 PASS 零 THROW 全绿。
50
+
51
+ ## [1.5.1] - 2026-08-30
52
+
53
+ > 例行审查轮:3 个实测坐实的 bug 修复 + 3 项健壮性加固 + 2 项站点适配,零 schema 变更。
54
+
55
+ ### 修复(P0,均有复现用例)
56
+
57
+ - **锚点读 × ld+json 兜底越权**:`extract()` 的 articleBody 兜底不感知锚点语义——定位到 <200 字符的小节时整篇 LD 全文覆盖小节内容(`anchored: true` 却返回全文)。现在锚点命中时跳过该兜底(与 readability 升级的既有守卫对齐);无锚点的薄页仍正常兜底。
58
+ - **feed `content:encoded` 全文丢失**:闭标签正则要求字面 `</content>`,`</content:encoded>` 永不匹配——WordPress 等博客 feed 的全文字段整段丢失只剩标题。改为捕获字段名 + 反向引用配对(`<content:encoded>` 必须配 `</content:encoded>`),顺带杜绝与 `</description>` 跨配对吞错内容。实测 WPJAM/WP学院 feed 每条恢复 200 字符实质摘要。
59
+ - **隐藏/consent 嵌套泄漏**:容器内有先闭合的嵌套 `<div>` 时,非贪婪正则止步于第一个内层闭标签,其余横幅文本泄漏进正文(真实 consent 横幅普遍深嵌套)。改为深度计数平衡扫描(复用 tagBlockAt 思路);扫描窗口 100KB 上限防「大量未闭合开标签」退化;未闭合 fail-open 保留内容。
60
+
61
+ ### 加固(P1)
62
+
63
+ - **batch 单页异常隔离**:`mapLimit` 的 `Promise.all` 无守卫,单页意外 throw 会炸整批。每页 try/catch 转 `{error}`。
64
+ - **batch 列表去重**:同一 URL 多次出现会并发双 fetch。按精确字符串去重(带 `#fragment` 的锚点读语义不同,不与裸 URL 合并)。
65
+ - **`<time datetime>` 作用域收窄**:优先 `itemprop="datePublished"`/`pubdate` 标记的标签;扫描范围从全文档改为剥离噪声后的正文区——页脚/评论区的 time 标签不再劫持发布时间。
66
+
67
+ ### 站点适配(P2)
68
+
69
+ - **noscript SEO 兜底**:SPA 壳把正文放 `<noscript>` 时(stripNoise 剥离的块),正文过薄则取最长的 noscript 块兜底(≥150 字符阈值;"请启用 JS" 类短横幅不触发)。
70
+ - **懒加载图片**:markdown 模式 `<img src>` 缺失或为 data: 占位图时回退 `data-src`/`data-original`/`data-lazy-src`,描述性图片不再丢失。
71
+
72
+ ### 验证
73
+
74
+ - 单元断言 **158 → 168**(+10:锚点守卫正反例 2、content:encoded + 跨配对 1、嵌套隐藏/consent 平衡移除 1、未闭合 fail-open 1、noscript 兜底正反例 2、懒加载回退 1、time 标记优先级 1;batch 上限断言改用 15 个不同 URL 并新增去重断言)
75
+ - probe.mjs **39 → 47**(+8 对抗项:3000 个分散未闭合隐藏 div(窗口有界 408ms)、3000 层 consent 深嵌套完整移除 0.9ms、2000 未闭合 consent、隐藏 table、50k articleBody、畸形 LDJSON、3000 noscript 块、未闭合 noscript——全部无 THROW)
76
+ - 新增 `site-types.mjs` 站点类型语义探针(10 类,按类型断言而非字符数):**8/10 语义通过**,2 例境外连接超时(环境边界)。关键实锤——WordPress feed(content:encoded)摘要恢复、`<time datetime>` 博客 published 提取(2026-08-28)、懒加载站点 markdown 图片 15 张、MediaWiki 变体百科正文干净
77
+ - 152 站全量回归:**103 OK / 25 THIN+EMPTY / 24 ERR / 0 THREW**,OK 站零噪声;5 个首窗字符下降站点经 v1.3.1/v1.4.0 模块 A/B 比对确认均为段落化重排效应,内容完整(总字符量普遍增加)
78
+
79
+ ## [1.5.0] - 2026-08-29
80
+
81
+ > 元数据补全与 token 边角打磨轮。152 站回归确认 1.4.0 无内容性回归后,聚焦三处已核实的缺口(响应大小上限 3MB、RSS 摘要、nav 剔除等此前已就位的项不再重复)。
82
+
83
+ ### JSON-LD `articleBody` 正文兜底
84
+
85
+ 不少新闻/门户站把**全文**放在 `<script type="application/ld+json">` 的 `articleBody` 字段,可见 HTML 却是反爬壳或薄片段——DOM 提取器够不到。现在正文提取不足 200 字符时自动尝试 ld+json 正文兜底(strip 标签 → 段落化,块扫描沿用有界 LDJSON_RE,上限 200K 字符;DOM 正文充分时不触发,绝不用兜底覆盖真实正文)。
86
+
87
+ ### `<time datetime>` 元数据源 + 日期归一化
88
+
89
+ - 发布时间合并链扩展为:显式 `article:`/`og:` meta → JSON-LD datePublished → **`<time datetime>`** → 通用 date meta → byline 文本。语义化 `<time>` 标记通常标示发布时间,优先于常为 last-modified 的通用 date meta。
90
+ - 可识别格式的日期统一归一为 ISO(`2026年8月24日` → `2026-08-24`),覆盖 meta/JSON-LD/time/byline 全部来源;不匹配的格式原样透传。
91
+
92
+ ### markdown 表格行数帽
93
+
94
+ 兼容性对照表(40+ 行 × 多列)是 markdown 模式最大的单点 token 开销。表格超过 25 行时只保留前 25 行并追加一行 `…+N rows` 提示;text 模式不受影响。
95
+
96
+ ### 零宽字符剥离
97
+
98
+ 零宽空格/连接符(U+200B–200D、U+2060、BOM)对读者不可见却计 token,且 152 站实测中确有泄漏(如 vuejs-doc 输出)。textOnly / textLines / blockMd 三条渲染路径统一剥离。
99
+
100
+ ### 评估后保持不变:batch 默认 maxChars = 3000
101
+
102
+ 实测对比:ruanyifeng 全文不足 2000(两窗口无差异);solidot 的 2000–3000 段是第二篇文章的真实正文——下调到 2000 会在聚合页切掉实际内容。且 batch schema 描述含字面 `default 3000`,改动必须动 schema 文本,违背静态 schema 承诺。**结论:维持 3000。**
103
+
104
+ ### 验证
105
+
106
+ - 单元断言 **150 → 158**(+8:articleBody 兜底 2、无日期源不编造 1、time datetime 1、CJK 归一 1、表格帽 1、零宽 2;既有 byline 日期断言同步归一化行为更新)+ 12 SPA 断言全绿;
107
+ - probe.mjs 39 PASS 无回归;
108
+ - 实网:MDN markdown 模式表格静态 HTML 未超帽(兼容表为 JS 渲染),帽由单测覆盖;ruanyifeng/solidot 窗口评估见上。
109
+
110
+ ## [1.4.0] - 2026-08-28
111
+
112
+ > 省token主打轮。经实测复核,原方案中的「nav 导航块剔除」与「RSS/Atom 条目摘要」在 1.3.1 已就位(python-docs 实测 235K 全文 → 20K 内干净正文,导航零泄漏;feed 输出已含每条 ≤200 字符摘要),本轮聚焦真实缺口:隐藏元素剥离、URL 片段定位阅读、段落化正文。
113
+
114
+ ### URL 片段定位阅读(url#section-anchor)——token 大头
115
+
116
+ 超长参考页(MDN / Python docs 一页 20 万+ 字符)此前只能从文档头翻起。现在 `#fragment` 直接定位目标小节:
117
+
118
+ - **容器锚点**(`<section id>` / `<dl id>` 等)→ 深度计数平衡块提取(复用 tagBlockAt,正确处理嵌套),**精确切出该小节**
119
+ - **标题/内联锚点**(`<h2 id>`、`<dt id>`、`<a name>`)→ 从锚点起读到文末(标题引出其后正文),smartTruncate 兜底
120
+ - percent-encoded 中文锚点自动尝试解码形态;未命中锚点降级为全文(不报错)
121
+ - **cache key 保留 fragment**:`#a` 与 `#b` 各自独立缓存条目,绝不互串;offset 续读相对小节起点
122
+ - 命中锚点时跳过 readability 升级(其会以全文覆盖小节语义)与自动分页(拼接后续整页违背定位意图);输出 meta 行加 `#anchor` 标记说明正文起点
123
+ - 实测:python-docs stdtypes.html `#str.startswith` **235,393 → 111,587 字符(-52.6%)**,且默认 6000 字符窗口直接落在目标方法而非文档头
124
+
125
+ ### 隐藏元素与同意弹窗剥离
126
+
127
+ SPA 装饰树(`style="display:none"`)、`hidden` 属性、`aria-hidden="true"`、OneTrust/Cookiebot/GDPR 等 id 挂载的同意横幅此前全部漏网进正文。新增两条有界正则过滤器:
128
+
129
+ - 覆盖 `hidden`(裸属性与带值)、`aria-hidden="true"`(false 保留)、`style` 含 `display:none` / `visibility:hidden`
130
+ - consent id 匹配:`onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-`(大小写不敏感)
131
+ - **`[\s"']` 属性界卫兵**:`data-hidden` / `data-id` 等复合属性名不误伤
132
+ - 未闭合的隐藏元素安全降级为保留内容(lazy 扫描 50K 有界,探针实测 4.9ms)
133
+
134
+ ### 正文段落化(textLines)
135
+
136
+ text 模式原先把全文压成单行平文本,offset 续读从句子中间切开。现在块级边界成为段落分隔:
137
+
138
+ - 标题独立成行、`<br>` 保单换行,段落以 `\n\n` 分隔——smartTruncate 的段落对齐真正生效,续读切口落在真实段落边界
139
+ - 标题行尾的永久链接装饰符(`¶` / `§`)剥离(text 与 markdown 双模式)
140
+ - readability 升级路径的 text 输出同步走段落化(可选增强安装时行为一致)
141
+
142
+ ### 其他
143
+
144
+ - 正文 <60 字符且无渲染提示的页面补一条恒定成本的提示行(「正文极短(可能登录墙或反爬拦截)」),模型不再对 40 字符页面盲目 offset 续读
145
+ - `renderResult` 导出(供测试断言输出文案)
146
+
147
+ ### 验证
148
+
149
+ - 单元断言 **126 → 150**(+24:隐藏/consent 剥离 11、锚点切片 8 含 e2e 缓存隔离与 offset 语义、段落接缝 2、装饰符 1、短正文提示 1、既有回归 1)+ 12 SPA 断言全绿
150
+ - probe.mjs 对抗探针 +7(3000 隐藏 div、未闭合隐藏、2000 consent 横幅、超长 style 前缀、正则特殊字符锚点、4000 层嵌套平衡块、畸形百分号解码)全绿,最慢 4.9ms
151
+ - 实网:python-docs 锚点定位实测(上文数据);152 站回归基线不变
152
+
153
+ ## [1.3.1] - 2026-08-25
154
+
155
+ > 注:v1.3.0(2026-08-24 开发完成)未独立发布——三项工作(v1.3.0 四项改进 + 08-25 健壮性轮 + 审查收尾轮)合并发布为 1.3.1。以下内容即该合并版本的完整变更。
4
156
 
5
157
  ### 四项改进:NUL 防护 + JSON-LD 元数据 + base href + meta-refresh 跟随
6
158
 
package/README.md CHANGED
@@ -210,19 +210,23 @@ const results = await Promise.all([
210
210
  ## Token economy (core)
211
211
 
212
212
  1. **Body text only by default** — no redundant headings/keywords/images/word-count fields; take them via params only when needed;
213
- 2. **Paragraph-aligned truncation + offset continuation** — 6,000 chars by default (~3,000 tokens), cut at paragraph boundaries to keep semantics; output notes a single line `(chars 6000/12990 — truncated, continue via offset)`; resume starts at the given offset, sliced from cache — **no repetition of already-read text** (measured 0+500 → 500+500, no overlap); offset past the end returns empty instead of repeating the head;
214
- 3. **`text` mode first** — Markdown structure is opt-in;
215
- 4. **Compact text render** — the model sees a `title:` header + body directly, no JSON parsing; `siteName` is omitted when identical to the hostname; every status hint is one short line (truncated / cached / rendered), no verbose paragraphs;
216
- 5. **Two-tier cache** — successful results cached per URL for 5 minutes (repeat reads hit cache: fewer network calls and fewer model retries); **failed results cached for 30 seconds** so a broken URL never triggers a re-fetch loop;
217
- 6. **KV-cache friendly (DeepSeek cost tuning)** — tool schema/description stay **static text** (no config values embedded), so changing config never invalidates the reusable prompt prefix and KV cache keeps hitting. DeepSeek's cache-hit tokens cost about 1/10 of misses — the more stable the prefix, the cheaper the run (same analysis as the official `tool-web` docs);
218
- 7. **Batch shares the cache** — `read_url_batch` reuses the same cache (repeat batches hit it directly) and caps each page at 3,000 chars (below the single-page 6,000) to bound total output;
219
- 8. **Compact fixed cost** — the four tool descriptions total ~900 chars (audited by a budget assertion in tests, kept static for KV-cache); extended HTML-entity decoding (45 named entities) means leftovers like `&mdash;`/`&hellip;` never waste tokens or render as mojibake.
213
+ 2. **Paragraph-aligned truncation + offset continuation** — 6,000 chars by default (~3,000 tokens), cut at paragraph boundaries to keep semantics (text mode separates paragraphs with `\n\n`, so the cut lands on a real boundary, not mid-sentence); output notes a single line `(chars 6000/12990 — truncated, continue via offset)`; resume starts at the given offset, sliced from cache — **no repetition of already-read text** (measured 0+500 → 500+500, no overlap); offset past the end returns empty instead of repeating the head;
214
+ 3. **URL fragment reading (v1.4.0)** — `url#section-anchor` jumps straight to the target section of a long reference page: container anchors slice out exactly that block (depth-counted balanced extraction), heading anchors read from the heading onward; the cache key keeps the fragment so `#a`/`#b` never cross-pollute, and offset continues relative to the section start. Measured on python-docs `#str.startswith`: **235,393 → 111,587 chars (-52.6%)**, the default 6000-char window landing on the target method instead of the document head;
215
+ 4. **Hidden-element & consent-banner stripping (v1.4.0)** — `display:none`/`visibility:hidden` decoration trees, the bare `hidden` attribute, `aria-hidden="true"`, and OneTrust/Cookiebot/GDPR banners mounted by id no longer leak into the body; a bounded attribute-separator guard keeps compound names (`data-hidden`/`data-id`) from false matches;
216
+ 5. **Three-way metadata fallback (v1.5.0)** — the published-time merge chain runs `article:`/`og:` meta → JSON-LD → `<time datetime>` → generic date meta → byline, with recognized formats normalized to ISO; when an anti-bot shell leaves the body too thin, the full ld+json `articleBody` serves as a body fallback; markdown tables beyond 25 rows truncate with a `…+N rows` hint; zero-width characters (U+200B etc.) are stripped uniformly;
217
+ 6. **`text` mode first** — Markdown structure is opt-in;
218
+ 7. **Compact text render** — the model sees a `title:` header + body directly, no JSON parsing; `siteName` is omitted when identical to the hostname; every status hint is one short line (truncated / cached / rendered), no verbose paragraphs;
219
+ 8. **Two-tier cache** — successful results cached per URL for 5 minutes (repeat reads hit cache: fewer network calls and fewer model retries); **failed results cached for 30 seconds** so a broken URL never triggers a re-fetch loop;
220
+ 9. **KV-cache friendly (DeepSeek cost tuning)** — tool schema/description stay **static text** (no config values embedded), so changing config never invalidates the reusable prompt prefix and KV cache keeps hitting. DeepSeek's cache-hit tokens cost about 1/10 of misses — the more stable the prefix, the cheaper the run (same analysis as the official `tool-web` docs);
221
+ 10. **Batch shares the cache** — `read_url_batch` reuses the same cache (repeat batches hit it directly) and caps each page at 3,000 chars (below the single-page 6,000) to bound total output;
222
+ 11. **Compact fixed cost** — the four tool descriptions total ~900 chars (audited by a budget assertion in tests, kept static for KV-cache); extended HTML-entity decoding (45 named entities) means leftovers like `&mdash;`/`&hellip;` never waste tokens or render as mojibake.
220
223
 
221
224
  ## Technical notes
222
225
 
223
226
  - **Encoding**: BOM-first detection (UTF-8 / UTF-16LE / UTF-16BE — byte-level evidence beats any declared charset), then HTTP `Content-Type` charset → HTML meta; built-in `TextDecoder` transcoding (Node 20+ full-icu, so declared Shift-JIS/EUC-JP/GBK/Big5 all decode), GB2312 normalized to GBK, auto-fallback to UTF-8 on mojibake;
224
227
  - **Content dispatch**: a URL is not always an HTML page — JSON APIs are re-rendered fully compact with no indentation, and string values over 1500 chars are clipped with a visible marker (since v1.2.0), RSS 2.0 / Atom feeds parse into an entry list (`title — url` + summary, `feedCount` field, full items via `includeLinks`; entry summaries are iteratively tag-stripped and entity-decoded until stable, so double-escaped `&lt;a&gt;` never leaks as a literal tag); XML sitemaps are rejected with a clear reason (no reading value); everything else falls through to the HTML pipeline;
225
- - **Extraction**: prefers `<article>` (all articles joined on aggregation pages; an unrelated tiny article — e.g. a newsletter card — falls through to `<main>` when its text is under 200 chars and `<main>` exists) / `<main>` / `role="main"`; `role="main"` containers are closed via **depth counting of balanced tags** (nested divs no longer cut the block at the first `</div>` — measured: gnu.org used to lose 7/8 of its body this way); strips `nav/footer/header/aside/form/iframe` and ad-like containers, heuristic fallback to `<body>`; on the body path a **text-density pass** drops link-dominated segments (related posts, category sidebars, hot-article widgets) — pages with standard containers never reach it;
228
+ - **Extraction**: prefers `<article>` (all articles joined on aggregation pages; an unrelated tiny article — e.g. a newsletter card — falls through to `<main>` when its text is under 200 chars and `<main>` exists) / `<main>` / `role="main"`; `role="main"` containers are closed via **depth counting of balanced tags** (nested divs no longer cut the block at the first `</div>` — measured: gnu.org used to lose 7/8 of its body this way); strips `nav/footer/header/aside/form/iframe`, ad-like containers, and **hidden elements & consent banners** (the `hidden` attribute, `aria-hidden="true"`, styles containing `display:none`/`visibility:hidden`, and GDPR banners mounted under ids like onetrust/cookiebot/cybot/gdpr/consent/cookie-law; a `[\s"']` attribute-separator guard keeps compound names such as `data-hidden`/`data-id` from false matches; unclosed hidden elements degrade safely to keeping the content), heuristic fallback to `<body>`; on the body path a **text-density pass** drops link-dominated segments (related posts, category sidebars, hot-article widgets) — pages with standard containers never reach it;
229
+ - **URL fragment anchoring (v1.4.0)**: `url#section-anchor` targets one section of a long document — container tags (section/div/dl/table…) take the **depth-counted balanced block** (nesting handled correctly, exactly that section), heading/inline anchors (`<h2 id>`/`<dt id>`/`<a name>`) read from the anchor to the end (a heading introduces the content after it); percent-encoded non-ASCII anchors are also tried in decoded form; a miss degrades to the full document. The cache key keeps the fragment (`#a`/`#b` cached as separate entries); a hit skips the readability upgrade and auto-pagination (both override located semantics with whole-document semantics); text mode is **paragraph-structured** (block-level boundaries → `\n\n` paragraph gaps, headings on their own lines — paragraph-aligned truncation actually lands on real boundaries and offset continuation cuts at paragraph seams; trailing permalink glyphs `¶`/`§` are stripped from headings);
226
230
  - **Pagination**: auto-follows `rel=next` or a short next-page anchor (下一页 / next / › / » — conservative, no fuzzy guessing); same-host only, loop-guarded, repeated paragraphs across the seam de-duplicated; continuation pages take the fast static path (a paginated SPA chain would cost one full render per page);
227
231
  - **Metadata**: `published` / `author` harvested from meta tags into the output and the status line; when the page has no such meta (ruanyifeng.com ships zero author/date tags) a **byline fallback** harvests the body head (600-char window: `作者:X / 日期:Y` — meta wins, deep-body mentions ignored, markdown link syntax stripped); empty-body pages (login walls, JS shells) fall back to `og:description` as a hint instead of nothing;
228
232
  - **Markdown**: self-written lightweight tag state machine (headings/paragraphs/lists/blockquotes/code/tables/inline bold-italic-links), zero deps; images with alt text render as `![alt](src)` (decorative empty-alt images are dropped), code fences carry the language hint from `language-*` highlighter classes;
@@ -233,12 +237,14 @@ const results = await Promise.all([
233
237
  - **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
238
  - **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
239
 
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)
240
+ ## 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; 2026-08-28 v1.4.0)
237
241
 
238
242
  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
243
 
240
244
  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
245
 
246
+ v1.4.0 re-verification (2026-08-28, proxy environment): anchor read measured on python-docs stdtypes.html — `#str.startswith` takes the full 235,393 chars down to a 111,587-char located slice (**-52.6%**), with both text and markdown output starting exactly at the target method definition; 152-site sweep baseline unchanged.
247
+
242
248
  Plus a **15-item DSH acceptance round** (a real agent driving every read_url tool end-to-end): 12 items fully conformant, the 3 findings all closed — ① Cloudflare's "Just a moment..." interstitial carries MORE text than the real body (259 vs 135 chars) and was mistaken for a render improvement → challenge-marker detection + an extra 8s wait for the auto-redirect + interstitial results rejected; ② ruanyifeng.com ships ZERO author/date meta tags, the byline only exists in body text → byline fallback harvesting the body head (verified: `author=阮一峰 published=2026年8月21日`); ③ PDF rejection now attributes clearly: `Unsupported content-type: application/pdf`.
243
249
 
244
250
  | Category | Sites | Result |
@@ -258,7 +264,7 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
258
264
  | Batch + failure isolation | 4-URL mix | ✅ 2/4 ok, failures isolated |
259
265
  | Site crawl | Ruan Yifeng blog | ✅ 5/5 pages tree map |
260
266
 
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;
267
+ - **150 zero-dep assertions** (v1.4.0 adds 24: hidden/consent stripping ×11 — hidden attribute / aria-hidden / style-hidden / onetrust / cybot / gdpr / compound-attribute guard / aria-hidden=false kept; anchor slicing ×8 — container balanced block / heading-to-end / markdown anchoring / miss-degrades-to-full / percent-decoding / data-id guard / e2e cache isolation & offset semantics / bare URL unaffected; paragraph seams & permalink glyphs ×3; thin-page hint ×1; regression ×1; 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; probe.mjs adversarial probes +7 (every new v1.4.0 regex time-bounded, slowest 4.9ms);
262
268
  - 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
269
 
264
270
  ## Roadmap
@@ -278,6 +284,9 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
278
284
  - [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
285
  - [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
286
  - [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)
287
+ - [x] URL fragment reading (`url#section-anchor`: container balanced-block / heading-to-end, cache isolation, offset relative to the section; python-docs measured -52.6%, v1.4.0)
288
+ - [x] Hidden-element & consent-banner stripping (hidden/aria-hidden/style-hidden + onetrust/cookiebot/gdpr id matching, v1.4.0)
289
+ - [x] Paragraph-structured text output (block boundaries → paragraph gaps, offset continuation cuts at real paragraph boundaries, v1.4.0)
281
290
 
282
291
  > From v1.0.0 the plugin enters maintenance mode: bug fixes first, updates kept rare.
283
292
 
package/README.zh.md CHANGED
@@ -210,19 +210,23 @@ const results = await Promise.all([
210
210
  ## 省 token 设计(核心)
211
211
 
212
212
  1. **默认只给正文**——不返回 headings/keywords/images/字数统计等冗余字段,需要时按参数取;
213
- 2. **段落级智能截断 + offset 续读**——默认 6000 字符(约 3000 token),在段落边界截断保证语义完整,输出行仅一行 `(chars 6000/12990 — 截断,offset 续读)` 引导;续读从指定偏移开始、命中缓存切片,**不重复返回已读前文**(实测 0+500 → 500+500,无重复);offset 越界返回空而非重复开头;
214
- 3. **text 模式优先**——Markdown 结构按需开启;
215
- 4. **紧凑文本 render**——模型直接看到 `title:` 头部 + 正文,无需解析 JSON;`siteName` 与域名相同时省略;状态提示全部一行内(截断/续读/缓存/渲染标记),无长段落废话;
216
- 5. **双层缓存**——成功结果按 URL 缓存 5 分钟(重复读取直接命中,省网络也省模型重试);**失败结果缓存 30 秒**(坏 URL 不会触发重复 fetch 循环);
217
- 6. **KV Cache 友好(DeepSeek 成本特调)**——工具 schema/description 保持**静态文本**(不嵌入配置值),配置变更不会使可复用的 prompt 前缀失效,KV 缓存持续命中。DeepSeek 缓存命中 token 价格约为未命中的 1/10,前缀越稳定越省钱(官方 `tool-web` 文档同款分析);
218
- 7. **批量共用缓存**——`read_url_batch` 内部复用同一套缓存,重复批量读直接命中,且每页默认 3000 字符(低于单页 6000)控制总量;
219
- 8. **固定开销压缩**——4 个工具 description 合计约 900 字符(有断言守卫,保持静态利于 KV 缓存);HTML 实体解码扩展至 45 个命名实体,`&mdash;`/`&hellip;` 等残留不再浪费 token 或显示为乱码。
213
+ 2. **段落级智能截断 + offset 续读**——默认 6000 字符(约 3000 token),在段落边界截断保证语义完整(text 模式段落以 `\n\n` 分隔,切口落在真实段落边界而非句中),输出行仅一行 `(chars 6000/12990 — 截断,offset 续读)` 引导;续读从指定偏移开始、命中缓存切片,**不重复返回已读前文**(实测 0+500 → 500+500,无重复);offset 越界返回空而非重复开头;
214
+ 3. **URL 片段定位阅读(v1.4.0)**——`url#section-anchor` 直接定位长文档目标小节:容器锚点精确切块(深度计数平衡提取)、标题锚点从该处起读;cache key 保留 fragment 保证 `#a`/`#b` 互不串页,offset 相对小节起点。实测 python-docs `#str.startswith`:**235,393 → 111,587 字符(-52.6%)**,默认 6000 字符窗口直接落在目标方法而非文档头;
215
+ 4. **隐藏元素与同意弹窗剥离(v1.4.0)**——`display:none`/`visibility:hidden` 装饰树、`hidden` 属性、`aria-hidden="true"`、OneTrust/Cookiebot/GDPR id 挂载的横幅不再混入正文;复合属性名(`data-hidden`/`data-id`)有界卫兵不误伤;
216
+ 5. **元数据三路兜底(v1.5.0)**——发布时间合并链 `article:`/`og:` meta → JSON-LD → `<time datetime>` → 通用 date meta → byline,可识别格式归一为 ISO;反爬壳页正文过薄时自动用 ld+json `articleBody` 全文兜底;markdown 表格超 25 行截断并附 `…+N rows` 提示;零宽字符(U+200B 等)统一剥离;
217
+ 6. **text 模式优先**——Markdown 结构按需开启;
218
+ 7. **紧凑文本 render**——模型直接看到 `title:` 头部 + 正文,无需解析 JSON;`siteName` 与域名相同时省略;状态提示全部一行内(截断/续读/缓存/渲染标记),无长段落废话;
219
+ 8. **双层缓存**——成功结果按 URL 缓存 5 分钟(重复读取直接命中,省网络也省模型重试);**失败结果缓存 30 秒**(坏 URL 不会触发重复 fetch 循环);
220
+ 9. **KV Cache 友好(DeepSeek 成本特调)**——工具 schema/description 保持**静态文本**(不嵌入配置值),配置变更不会使可复用的 prompt 前缀失效,KV 缓存持续命中。DeepSeek 缓存命中 token 价格约为未命中的 1/10,前缀越稳定越省钱(官方 `tool-web` 文档同款分析);
221
+ 10. **批量共用缓存**——`read_url_batch` 内部复用同一套缓存,重复批量读直接命中,且每页默认 3000 字符(低于单页 6000)控制总量;
222
+ 11. **固定开销压缩**——4 个工具 description 合计约 900 字符(有断言守卫,保持静态利于 KV 缓存);HTML 实体解码扩展至 45 个命名实体,`&mdash;`/`&hellip;` 等残留不再浪费 token 或显示为乱码。
220
223
 
221
224
  ## 技术说明
222
225
 
223
226
  - **编码**:BOM 优先探测(UTF-8 / UTF-16LE / UTF-16BE——字节级证据优先于任何声明),其次 HTTP `Content-Type` charset → HTML meta;内置 `TextDecoder` 转码(Node 20+ full-icu,页面声明的 Shift-JIS/EUC-JP/GBK/Big5 均可正确解码),GB2312 归一为 GBK,检测到乱码自动回退 UTF-8;
224
227
  - **内容类型分发**:URL 不一定是 HTML——JSON 接口紧凑重排渲染(无缩进,超长字符串值 >1500 字符截断并标注,v1.2.0 起缩进 1 格改为完全紧凑),RSS 2.0 / Atom 订阅源解析为条目列表(`标题 — 链接` + 摘要,`feedCount` 字段,`includeLinks` 时附完整 items;条目摘要迭代「剥标签+解实体」直到稳定,双重转义的 `&lt;a&gt;` 不会漏成字面标签);XML sitemap 明确拒绝(对模型无阅读价值);其余全部走 HTML 管线;
225
- - **正文提取**:优先 `<article>`(聚合页多篇合并;无关小卡片 article——如订阅挂件——文本不足 200 字符且页面有 `<main>` 时自动回落 main)/ `<main>` / `role="main"`,`role="main"` 容器用**深度计数找平衡闭合标签**(嵌套 div 不会在第一个 `</div>` 被截断——实测 gnu.org 曾因此只取到 1/8 正文);剥离 `nav/footer/header/aside/form/iframe` 及广告类容器,启发式回归到 `<body>`;body 路径上追加**文本密度过滤**——丢弃链接主导的短块(相关推荐/分类侧栏/热门文章挂件),标准容器页面完全不走此路径;
228
+ - **正文提取**:优先 `<article>`(聚合页多篇合并;无关小卡片 article——如订阅挂件——文本不足 200 字符且页面有 `<main>` 时自动回落 main)/ `<main>` / `role="main"`,`role="main"` 容器用**深度计数找平衡闭合标签**(嵌套 div 不会在第一个 `</div>` 被截断——实测 gnu.org 曾因此只取到 1/8 正文);剥离 `nav/footer/header/aside/form/iframe` 及广告类容器、**隐藏元素与同意弹窗**(`hidden` 属性、`aria-hidden="true"`、`style` 含 `display:none`/`visibility:hidden`、`onetrust/cookiebot/cybot/gdpr/consent/cookie-law` 等 id 挂载的 GDPR 横幅;`[\s"']` 属性界卫兵保证 `data-hidden`/`data-id` 复合属性名不误伤;未闭合元素安全降级为保留内容),启发式回归到 `<body>`;body 路径上追加**文本密度过滤**——丢弃链接主导的短块(相关推荐/分类侧栏/热门文章挂件),标准容器页面完全不走此路径;
229
+ - **URL 片段定位(v1.4.0)**:`url#section-anchor` 定位长文档小节——容器标签(section/div/dl/table 等)取**深度计数平衡块**(嵌套正确、精确切出该节),标题/内联锚点(`<h2 id>`/`<dt id>`/`<a name>`)从该处读到文末(标题引出其后正文);percent-encoded 中文锚点自动尝试解码形态;未命中降级全文。cache key 保留 fragment(`#a`/`#b` 独立缓存);命中锚点时跳过 readability 升级与自动分页(二者都以全文语义覆盖定位语义);text 模式**段落化输出**(块级边界 → `\n\n` 段落分隔,标题独立成行,smartTruncate 段落对齐真正生效,offset 续读切口落在真实段落边界;标题行尾永久链接装饰符 `¶`/`§` 剥离);
226
230
  - **分页拼接**:识别 `rel=next`(标准)或纯「下一页 / next / › / »」短锚文本(刻意保守,不做模糊猜测);同域限定 + 防环;跨页重复段落自动去重;续页走静态快路径(分页 SPA 链每页一次完整渲染不划算);
227
231
  - **元数据**:`published` / `author` 从 meta 标签提取进输出字段与状态行;页面无相关 meta 时(如阮一峰博客零 meta 标签)从**正文头部 600 字符的署名行兜底**(「作者:X / 日期:2026年8月21日」,meta 优先、正文深部提及不误采、markdown 链接语法不泄漏);空正文页(登录墙/JS 壳)回落到 `og:description` 作为提示,不再返回空;
228
232
  - **Markdown**:自研轻量标签状态机(标题/段落/列表/引用/代码块/表格/行内加粗斜体链接),零依赖;带 alt 的图片渲染为 `![alt](src)`(空 alt 装饰图丢弃),代码块围栏带 `language-*` class 里的语言标注;
@@ -233,12 +237,14 @@ const results = await Promise.all([
233
237
  - **可选增强二(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
238
  - **边界**:登录墙页面无法读取;SPA 页面需安装 Playwright 增强后渲染读取(未安装时返回明确提示);**结构化数据(如评论的点赞数归属、榜单数值)不在文本提取范围**——本插件把 HTML 扁平化为可读文本,字段与数值的精确对应关系会丢失;需要精确字段时,用 Playwright 拦截页面实际调用的数据 API 获取(见下方「真实世界验证」)。
235
239
 
236
- ## 真实世界验证(2026-08-21,v1.0.0;2026-08-22 适配 DSH 0.1.1-rc.2 复验;2026-08-24 v1.3.0 复验)
240
+ ## 真实世界验证(2026-08-21,v1.0.0;2026-08-22 适配 DSH 0.1.1-rc.2 复验;2026-08-24 v1.3.0 复验;2026-08-28 v1.4.0 复验)
237
241
 
238
242
  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
243
 
240
244
  扫描驱动的发布前修复(全部带单测锁定):RSS 双重转义、无头二进制嗅探、JS 跳转壳渲染、**`role="main"` 嵌套 div 截断**(gnu.org 165→800 字符)、**小 article 劫持主内容**(gitlab 71→800 字符)、渲染接受门槛放宽。
241
245
 
246
+ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点定位实测——`#str.startswith` 全文 235,393 → 定位切片 111,587 字符(-52.6%),text/markdown 双模式内容均精确起始于目标方法定义;152 站回归基线不变。
247
+
242
248
  另有 **DSH 15 项全功能验收轮**(真实 Agent 调用 read_url 系工具逐项核对):12 项完全符合,3 项问题全部修复闭环——① Cloudflare「Just a moment...」验证页文字量超过真实正文(259 vs 135 字符)被误当作渲染提升 → 挑战页特征识别 + 渲染后额外等待 8s 自动跳转 + 拒绝接受指纹页;② 阮一峰页面零 author/date meta 标签,作者/日期只在正文里 → byline 兜底从正文头部 600 字符提取署名行(实测 `author=阮一峰 published=2026年8月21日`);③ PDF 类型拒绝归因 → 明确报 `Unsupported content-type: application/pdf`。
243
249
 
244
250
  | 类别 | 站点 | 结果 |
@@ -258,7 +264,7 @@ const results = await Promise.all([
258
264
  | **批量 + 失败隔离** | 4 URL 混合 | ✅ 2/4 成功、失败隔离 |
259
265
  | **整站爬取** | 阮一峰博客 | ✅ 5/5 页树状站点地图 |
260
266
 
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 测试断言**全绿;
267
+ - **150 个单元断言**(v1.4.0 新增 24:隐藏/consent 剥离 11(hidden 属性/aria-hidden/style 隐藏/onetrust/cybot/gdpr/复合属性卫兵/aria-hidden=false 保留)、锚点切片 8(容器平衡块/标题到文末/markdown 定位/未命中降级/百分号解码/data-id 卫兵/e2e 缓存隔离与 offset 语义/裸 URL 不受影响)、段落接缝与装饰符 3、短正文提示 1、回归 1;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 测试断言**全绿;probe.mjs 对抗探针 +7(v1.4.0 新正则全部限时通过,最慢 4.9ms);
262
268
  - 一个真实案例:小黑盒帖子的评论点赞数(`up` 字段)无法从扁平文本确定归属——**精确字段应走页面背后的数据 API**(如 `/bbs/app/link/tree` JSON),这是同类文本提取器的共同边界,不是缺陷。
263
269
 
264
270
  ## Roadmap
@@ -278,6 +284,9 @@ const results = await Promise.all([
278
284
  - [x] 对抗页面线性化(无配对尖括号/无闭合标签/属性扫描加界 + 递归深度帽,修复 60KB→2.2s 的二次复杂度 DoS 向量)、散文 &lt; 不再被吞、代理重定向保留最终 URL、深 JSON 降级不崩溃(v1.2.0 第二轮)
279
285
  - [x] NUL/控制字符实体防护(`&#0;`/`&#127;` 输出空格、surrogate 输出 U+FFFD,不污染模型上下文)、JSON-LD 元数据三级提取(meta → JSON-LD → byline)、`<base href>` 链接解析(框架/老论坛页)、`<meta refresh>` 壳页自动跟随(最多 3 跳、防循环、fail-open,v1.3.0)
280
286
  - [x] 健壮性轮:meta 属性序无关提取(content 在前不再漏)、JSON-LD 嵌套结构递归(ItemList→mainEntity)、`<meta http-equiv="Content-Type">` 老式 charset、链接/分页正则统一加界、markdown 括号转义、爬虫流媒体噪音过滤(v1.3.1)
287
+ - [x] URL 片段定位阅读(`url#section-anchor` 容器平衡块/标题到文末、缓存隔离、offset 相对小节,python-docs 实测 -52.6%,v1.4.0)
288
+ - [x] 隐藏元素与同意弹窗剥离(hidden/aria-hidden/style 隐藏 + onetrust/cookiebot/gdpr id 匹配,v1.4.0)
289
+ - [x] text 模式段落化输出(块级边界 → 段落分隔,offset 续读切口落在真实段落边界,v1.4.0)
281
290
 
282
291
  > v1.0.0 起进入维护期:以修 bug 为主,减少更新频率。
283
292
 
package/index.js CHANGED
@@ -100,10 +100,11 @@ export function decodeBuffer(buffer, contentType) {
100
100
  return { text, charset: enc }
101
101
  }
102
102
 
103
- // Content types this plugin can consume. Beyond HTML: JSON (data APIs) and
104
- // XML (RSS/Atom feeds) are read natively — see readUrl's type dispatch.
103
+ // Content types this plugin can consume. Beyond HTML: JSON (data APIs),
104
+ // XML (RSS/Atom feeds) and plain-text families (txt/md/csv — logs, docs,
105
+ // datasets) are read natively — see readUrl's type dispatch.
105
106
  // Note "application/rss+xml": the separator before xml is '+', not '/'.
106
- const FETCHABLE_CT = /text\/html|application\/xhtml|text\/plain|\/json|[+/]xml/i
107
+ const FETCHABLE_CT = /text\/html|application\/xhtml|text\/(?:plain|markdown|csv)|\/json|[+/]xml/i
107
108
 
108
109
  // Retry-After header (seconds form) → ms, capped; undefined when absent/invalid.
109
110
  function retryAfterMs(res) {
@@ -168,14 +169,24 @@ async function directFetchOnce(url, signal, cfg) {
168
169
  }
169
170
 
170
171
  // Direct-connect fetch with a single Retry-After-aware retry on 429/503.
171
- async function directFetch(url, signal, cfg) {
172
+ // Exported for tests: the double-throttle path must always produce an { error }
173
+ // shape — a second `{ retryAfterMs }` would otherwise leak past the race
174
+ // filter into decodeBuffer as a bufferless "success" (bare TypeError there).
175
+ export async function directFetch(url, signal, cfg) {
172
176
  const first = await directFetchOnce(url, signal, cfg)
173
177
  if (first && first.retryAfterMs !== undefined && !(signal && signal.aborted)) {
174
178
  await new Promise((resolve) => {
175
179
  const t = setTimeout(resolve, first.retryAfterMs)
176
180
  if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
177
181
  })
178
- return directFetchOnce(url, signal, cfg)
182
+ if (signal && signal.aborted) return { error: 'cancelled' }
183
+ const second = await directFetchOnce(url, signal, cfg)
184
+ // The retry budget is spent — report the rate limit instead of returning
185
+ // a shapeless { retryAfterMs } (no buffer, no error) to the caller.
186
+ if (second && second.retryAfterMs !== undefined) {
187
+ return { error: 'HTTP 429/503 rate limited (retried once after Retry-After, still throttled)' }
188
+ }
189
+ return second
179
190
  }
180
191
  return first
181
192
  }
@@ -329,6 +340,10 @@ function defuseLt(html) {
329
340
  return html.slice(0, from) + html.slice(from).replace(/</g, ' ')
330
341
  }
331
342
 
343
+ // Invisible-to-readers joiner characters (zero-width space/joiners, word
344
+ // joiner, BOM) cost tokens and leak into copy/paste text — strip at render.
345
+ const ZW_RE = /[\u200b-\u200d\u2060\ufeff]/g
346
+
332
347
  function textOnly(html) {
333
348
  return decodeTextEntities(defuseLt(html)
334
349
  .replace(/<!--[\s\S]*?-->/g, ' ')
@@ -338,9 +353,36 @@ function textOnly(html) {
338
353
  .replace(/<noscript[\s>][\s\S]*?<\/noscript>/gi, ' ')
339
354
  .replace(/<[^>]{1,1000}>/g, ' ')
340
355
  .replace(/[ \t\r\n]+/g, ' ')
356
+ .replace(ZW_RE, '')
341
357
  .trim())
342
358
  }
343
359
 
360
+ // Paragraph-aware variant of textOnly for the body extraction path: block
361
+ // boundaries become paragraph breaks so offset continuation slices at real
362
+ // paragraph seams (smartTruncate aligns on /\n\n+/) instead of mid-sentence,
363
+ // and long reference pages stay readable instead of one flattened line.
364
+ // <br> keeps single newlines; every other tag runs are stripped as spaces.
365
+ function textLines(html) {
366
+ const cleaned = decodeTextEntities(defuseLt(html)
367
+ .replace(/<!--[\s\S]*?-->/g, ' ')
368
+ .replace(/<script[\s>][\s\S]*?<\/script>/gi, ' ')
369
+ .replace(/<style[\s>][\s\S]*?<\/style>/gi, ' ')
370
+ .replace(/<textarea[\s>][\s\S]*?<\/textarea\s*>/gi, ' ')
371
+ .replace(/<noscript[\s>][\s\S]*?<\/noscript>/gi, ' ')
372
+ .replace(/<br\b[^>]{0,1000}\/?>/gi, '\n')
373
+ .replace(/<\/?(?:p|div|section|article|main|h[1-6]|li|ul|ol|table|tbody|thead|tfoot|tr|blockquote|pre|figcaption|dt|dd|dl|figure|fieldset|option)\b[^>]{0,1000}>/gi, '\n')
374
+ .replace(/<[^>]{1,1000}>/g, ' '))
375
+ const lines = []
376
+ for (const rawLine of cleaned.replace(ZW_RE, '').split('\n')) {
377
+ let line = rawLine.replace(/[ \t\r\f\v]+/g, ' ').trim()
378
+ // A heading becomes its own short paragraph in this layout; a trailing
379
+ // permalink glyph on one is anchor decoration, not content.
380
+ if (line.length <= 120) line = line.replace(/\s*[¶§]$/, '')
381
+ if (line) lines.push(line)
382
+ }
383
+ return lines.join('\n\n')
384
+ }
385
+
344
386
  // ---- JSON / RSS / Atom content dispatch ----
345
387
  // A URL is not always an HTML page: data APIs answer JSON and content sources
346
388
  // answer feeds. Both are model-readable when compacted, so read_url handles
@@ -374,7 +416,10 @@ function parseFeed(xml, limit) {
374
416
  const linkTag = /<link[^>]*>([\s\S]*?)<\/link>/i.exec(it)
375
417
  const linkHref = /<link[^>]+href=["']([^"']+)["']/i.exec(it)
376
418
  const link = (linkTag && xmlText(linkTag[1])) || (linkHref && linkHref[1]) || ''
377
- const desc = xmlText(/<(?:description|summary|content)[^>]*>([\s\S]*?)<\/(?:description|summary|content)>/i.exec(it)?.[1] || '')
419
+ // Namespaced fields (WordPress <content:encoded>) pair via a captured
420
+ // name + backreference: the closer must match the SAME name, so a
421
+ // <content:encoded> opener can never pair with a distant </description>.
422
+ const desc = xmlText(/<((?:description|summary|content)(?::[a-zA-Z0-9]+)?)(?:\s[^>]*)?>([\s\S]*?)<\/\1\s*>/i.exec(it)?.[2] || '')
378
423
  if (!title && !link && !desc) continue
379
424
  items.push({ title: title.slice(0, 120), url: link, summary: desc.slice(0, 200) })
380
425
  }
@@ -427,13 +472,16 @@ export function densityFilter(html) {
427
472
 
428
473
  // Depth-counted block extraction: returns html from the opening tag at
429
474
  // openIdx through its BALANCED closing tag. A non-greedy regex would stop at
430
- // the first nested </div>, truncating heavily nested container divs.
431
- function tagBlockAt(html, openIdx, tagName) {
475
+ // the first nested </div>, truncating heavily nested container divs. maxScan
476
+ // bounds the search window — a closer further out than that is treated as
477
+ // absent (keeps hostile many-unclosed-opens inputs linear).
478
+ function tagBlockAt(html, openIdx, tagName, maxScan = Infinity) {
432
479
  const re = new RegExp(`</?${tagName}(?=[\\s>])`, 'gi')
433
480
  re.lastIndex = openIdx
434
481
  let depth = 0
435
482
  let m
436
483
  while ((m = re.exec(html))) {
484
+ if (m.index - openIdx > maxScan) return null
437
485
  depth += m[0][1] === '/' ? -1 : 1
438
486
  if (depth === 0) {
439
487
  const close = html.indexOf('>', m.index)
@@ -443,6 +491,29 @@ function tagBlockAt(html, openIdx, tagName) {
443
491
  return null
444
492
  }
445
493
 
494
+ // Remove every element matched by an OPEN-TAG-ONLY pattern together with its
495
+ // balanced content. The pattern must capture the tag name (group 1) so the
496
+ // scanner counts only same-name open/close pairs. Unclosed matches (no closer
497
+ // within the 100k scan window) are left in place — fail-open. Each match
498
+ // continues from the removed block's end, so removed interiors are never
499
+ // rescanned and total cost stays near-linear in the surviving input.
500
+ const BALANCED_SCAN_WINDOW = 100000
501
+ function stripBalanced(html, openTagRe) {
502
+ openTagRe.lastIndex = 0
503
+ let out = ''
504
+ let last = 0
505
+ let m
506
+ while ((m = openTagRe.exec(html))) {
507
+ const tag = m[1].toLowerCase()
508
+ const block = tagBlockAt(html, m.index, tag, BALANCED_SCAN_WINDOW)
509
+ if (!block) continue // unclosed container — keep the content
510
+ out += html.slice(last, m.index) + ' '
511
+ last = m.index + block.length
512
+ openTagRe.lastIndex = last
513
+ }
514
+ return out + html.slice(last)
515
+ }
516
+
446
517
  export function pickMain(html) {
447
518
  // Aggregation pages (e.g. blog homepages) have one <article> per item:
448
519
  // collect all of them instead of only the first. A tiny <article> (e.g. a
@@ -509,6 +580,16 @@ function stripNoise(mainHtml) {
509
580
  if (box.length) {
510
581
  out = out.replace(new RegExp(`<(${box.join('|')})[\\s>][\\s\\S]*?<\\/\\1>`, 'gi'), ' ')
511
582
  }
583
+ // Elements explicitly hidden from users are invisible decoration or stateful
584
+ // UI (collapsed panels, modal templates) — their text must not leak into the
585
+ // body. The [\s"'] guard keeps `hidden`/`style` from matching inside
586
+ // compound attr names (data-hidden, aria-hidden). Consent/GDPR banners mount
587
+ // by id (onetrust, cookiebot, cybot, gdpr) rather than class, so a second
588
+ // pass keys on the id attribute. Removal is a depth-counted balanced scan —
589
+ // banners are deeply nested and a lazy `[\s\S]*?` would stop at the first
590
+ // inner closer, leaking the rest of the banner text.
591
+ out = stripBalanced(out, /<(div|span|section|p|ul|table)\b[^>]{0,1000}[\s"'](?:hidden\b|aria-hidden\s*=\s*["']?true|style\s*=\s*["'][^"']{0,200}(?:display:\s*none|visibility:\s*hidden))[^>]{0,1000}>/gi)
592
+ out = stripBalanced(out, /<([a-z][a-z0-9]*)\s+id=["'][^"']{0,120}(?:onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-)[^"']{0,120}["'][^>]{0,1000}>/gi)
512
593
  return out.replace(
513
594
  /<([a-z][a-z0-9]*)[^>]{1,1000}class=["'][^"']{0,300}(ad-|ads|advert|banner|sidebar|social|share|comment|popup|modal|cookie)[^"']{0,300}["'][^>]{0,1000}>[\s\S]*?<\/\1>/gi,
514
595
  ' ',
@@ -527,10 +608,29 @@ function escInline(s) {
527
608
  const IMG_SENTINEL = '\u0001'
528
609
  function imgsToMarkdown(html) {
529
610
  const store = []
530
- const out = html.replace(/<img\b[^>]{0,1000}>/gi, (tag) => {
611
+ // AMP pages use <amp-img> instead of <img>; responsive layouts wrap images
612
+ // in <picture><source srcset>. When the <img> fallback carries no usable
613
+ // src (lazy placeholder), the first source URL is injected into it so the
614
+ // normal img pass below still emits the image.
615
+ html = html.replace(/<picture\b[^>]*>([\s\S]{0,50000}?)<\/picture\s*>/gi, (block) => {
616
+ const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
617
+ const imgSrc = imgTag && /(?:^|\s)src=["']([^"']+)["']/i.exec(imgTag[0])
618
+ if (imgTag && imgSrc && !/^data:/i.test(imgSrc[1])) return block
619
+ const source = /<source\b[^>]{0,1000}\bsrcset=["']([^"'\s>]+)/i.exec(block)
620
+ if (!source || !imgTag) return block
621
+ return block.replace(/<img\b[^>]{0,1000}>/i, (img0) => img0.replace(/<img/i, `<img src="${source[1]}"`))
622
+ })
623
+ const out = html.replace(/<(?:amp-)?img\b[^>]{0,1000}>/gi, (tag) => {
531
624
  const alt = /alt=["']([^"']*)["']/i.exec(tag)
532
- const src = /src=["']([^"']+)["']/i.exec(tag)
533
- store.push(src && alt && alt[1].trim() ? `![${alt[1].trim()}](${src[1]})` : '')
625
+ let src = /src=["']([^"']+)["']/i.exec(tag)
626
+ let srcVal = src && src[1]
627
+ // Lazy-load wrappers keep the real URL in data-* attributes; a data: URI
628
+ // src is an inline placeholder spacer, not a usable image reference.
629
+ if (!srcVal || /^data:/i.test(srcVal)) {
630
+ const lazy = /data-(?:src|original|lazy-src)=["']([^"']+)["']/i.exec(tag)
631
+ if (lazy) srcVal = lazy[1]
632
+ }
633
+ store.push(srcVal && alt && alt[1].trim() ? `![${alt[1].trim()}](${srcVal})` : '')
534
634
  return `${IMG_SENTINEL}${store.length - 1}${IMG_SENTINEL}`
535
635
  })
536
636
  return {
@@ -552,6 +652,7 @@ function imgsToMarkdown(html) {
552
652
  // the walker degrades to plain text.
553
653
  const OPEN_TAG_RE = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>/g
554
654
  const MD_MAX_DEPTH = 100
655
+ const MD_TABLE_MAX_ROWS = 25
555
656
  function stripUnmatchedOpeners(html) {
556
657
  const closers = new Set()
557
658
  for (const m of html.matchAll(/<\/([a-zA-Z0-9]+)/g)) closers.add(m[1].toLowerCase())
@@ -615,7 +716,8 @@ export function blockMd(html, depth = 0) {
615
716
  }
616
717
  if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') {
617
718
  const level = '#'.repeat(Number(tag[1]))
618
- out += `\n\n${level} ${inlineMd(inner, depth + 1)}`
719
+ // Trailing permalink glyphs are anchor decorations, not heading text.
720
+ out += `\n\n${level} ${inlineMd(inner, depth + 1).replace(/\s*[¶§]\s*$/, '')}`
619
721
  } else if (tag === 'p') {
620
722
  const t = inlineMd(inner, depth + 1)
621
723
  if (t) out += `\n\n${t}`
@@ -676,7 +778,12 @@ export function blockMd(html, depth = 0) {
676
778
  // unescape cells' escaped pipes before deriving the separator row,
677
779
  // otherwise the --- line is one char wider per escaped pipe
678
780
  const sep = header.replace(/\\\|/g, '|').replace(/[^|]/g, '-')
679
- out += `\n\n${rows.join('\n')}\n${sep}`
781
+ // Compatibility tables (40+ rows × many columns) are the single
782
+ // largest token sink in markdown mode; the hint keeps the model
783
+ // aware the table continues without repeating the cell noise.
784
+ const kept = rows.length > MD_TABLE_MAX_ROWS ? rows.slice(0, MD_TABLE_MAX_ROWS) : rows
785
+ const more = rows.length - kept.length
786
+ out += `\n\n${kept.join('\n')}\n${sep}${more > 0 ? `\n…+${more} rows` : ''}`
680
787
  }
681
788
  } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
682
789
  const t = inlineMd(inner, depth + 1)
@@ -686,7 +793,7 @@ export function blockMd(html, depth = 0) {
686
793
  if (t) out += t
687
794
  }
688
795
  }
689
- return img.restore(out)
796
+ return img.restore(out).replace(ZW_RE, '')
690
797
  }
691
798
 
692
799
  // Read the content attribute of the FIRST <meta> whose property/name equals
@@ -781,6 +888,76 @@ function jsonLdMeta(html) {
781
888
  return { published: '', author: '' }
782
889
  }
783
890
 
891
+ // News portals frequently ship the FULL article text inside ld+json
892
+ // (articleBody) while the visible HTML is an anti-bot shell or a thin
893
+ // fragment — a fallback body source the DOM extractors cannot reach. Returns
894
+ // the first string articleBody found (array values joined), stripped to
895
+ // paragraphs via textLines. Block scan is the same bounded LDJSON_RE.
896
+ function jsonLdArticleBody(html) {
897
+ for (const m of html.matchAll(LDJSON_RE)) {
898
+ let json
899
+ try {
900
+ json = JSON.parse(m[1])
901
+ } catch {
902
+ continue
903
+ }
904
+ const nodes = []
905
+ const collect = (node, depth = 0) => {
906
+ if (!node || typeof node !== 'object' || depth > 20) return
907
+ if (Array.isArray(node)) {
908
+ for (const it of node) collect(it, depth + 1)
909
+ return
910
+ }
911
+ nodes.push(node)
912
+ for (const k of Object.keys(node)) {
913
+ const v = node[k]
914
+ if (v && typeof v === 'object') collect(v, depth + 1)
915
+ }
916
+ }
917
+ collect(json)
918
+ for (const n of nodes) {
919
+ const raw = Array.isArray(n.articleBody) ? n.articleBody.join('\n\n') : n.articleBody
920
+ if (typeof raw === 'string' && raw.trim()) return textLines(raw).slice(0, 200000)
921
+ }
922
+ }
923
+ return ''
924
+ }
925
+
926
+ // SPA shells ship their SEO copy inside <noscript> (the block the stripNoise
927
+ // pass removes from main content). A thin body falls back to the LONGEST
928
+ // noscript block when it carries substantive text; short ones are UI noise
929
+ // ("please enable JavaScript"). Unclosed blocks simply never match — fail-open.
930
+ function noscriptBody(html) {
931
+ let best = ''
932
+ for (const m of html.matchAll(/<noscript[^>]{0,500}>([\s\S]{0,50000}?)<\/noscript\s*>/gi)) {
933
+ const t = textLines(m[1])
934
+ if (t.length > best.length) best = t
935
+ }
936
+ return best.length >= 150 ? best : ''
937
+ }
938
+
939
+ // Semantic <time datetime="..."> — common on blogs/docs that ship no meta or
940
+ // ld+json date. Tags explicitly marked as publication time (itemprop/pubdate)
941
+ // win; otherwise the FIRST time tag in the scoped html is used. Callers pass
942
+ // the noise-stripped main content so footer/comment timestamps (which carry
943
+ // their own <time> tags) are out of scope. Empty attribute is a no-op.
944
+ function timeTagMeta(html) {
945
+ if (!html) return ''
946
+ const marked = /<time\b[^>]{0,1000}(?:itemprop=["']datePublished["']|pubdate\b)[^>]{0,1000}datetime=["']([^"']{1,60})["']/i.exec(html)
947
+ if (marked) return marked[1].trim().slice(0, 40)
948
+ const m = /<time\b[^>]{0,1000}datetime=["']([^"']{1,60})["']/i.exec(html)
949
+ return m ? m[1].trim().slice(0, 40) : ''
950
+ }
951
+
952
+ // CJK / loose-variant dates normalize to ISO so the metadata line stays
953
+ // comparable across sources ("2026年8月24日" → "2026-08-24"). Anything that
954
+ // does not match a recognized shape passes through untouched.
955
+ function normalizeDate(s) {
956
+ const m = /^(\d{4})\s*[年/\-.]\s*(\d{1,2})\s*[月/\-.]\s*(\d{1,2})\s*日?$/.exec((s || '').trim())
957
+ if (!m) return s
958
+ return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`
959
+ }
960
+
784
961
  // Meta-less pages still print the byline inside the body head: "作者:阮一峰
785
962
  // 日期:2026年8月21日". Fill ONLY the missing fields, and only from the first
786
963
  // 600 chars of body text — deeper matches are content, not bylines.
@@ -796,12 +973,47 @@ function bylineFallback(text, meta) {
796
973
  }
797
974
  if (!out.published) {
798
975
  const m = BYLINE_DATE_RE.exec(head)
799
- if (m) out.published = m[1].replace(/\s+/g, ' ').trim().slice(0, 40)
976
+ if (m) out.published = normalizeDate(m[1].replace(/\s+/g, ' ').trim().slice(0, 40))
800
977
  }
801
978
  return out
802
979
  }
803
980
 
804
- export function extract(html, mode) {
981
+ // Locate the element whose id/name equals `anchor` and return the html of its
982
+ // section: for container tags (section/div/dl/…) the element's own balanced
983
+ // block IS the section; for heading/inline anchors (<h2 id>, <a name>, <dt
984
+ // id>) the anchor only marks where the section starts, so everything FROM it
985
+ // onward is kept (the heading introduces the content that follows). Returns
986
+ // null when no element matches — callers fall back to full text.
987
+ // The [\s"'] guard rejects compound attr names (data-id, data-name), and the
988
+ // exact-quoted value means id="prefix-anchor" never matches anchor "prefix".
989
+ const ANCHOR_CONTAINER = /^(?:section|div|article|aside|main|dl|table|ul|ol|details|figure|blockquote|fieldset|form)$/
990
+ function sliceAtAnchor(mainHtml, anchor) {
991
+ if (!anchor) return null
992
+ const esc = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
993
+ const m = new RegExp(`<([a-z][a-z0-9]*)\\b[^>]{0,1000}[\\s"'](?:id|name)\\s*=\\s*["']${esc}["'][^>]{0,1000}>`, 'i').exec(mainHtml)
994
+ if (!m) return null
995
+ const tag = m[1].toLowerCase()
996
+ if (ANCHOR_CONTAINER.test(tag)) {
997
+ const block = tagBlockAt(mainHtml, m.index, tag)
998
+ if (block) return block
999
+ }
1000
+ return mainHtml.slice(m.index)
1001
+ }
1002
+
1003
+ // Anchors may arrive percent-encoded (CJK section names); the document can
1004
+ // carry either form. Try the raw fragment first, then its decoded variant.
1005
+ function anchorCandidates(anchor) {
1006
+ const list = [anchor]
1007
+ try {
1008
+ const dec = decodeURIComponent(anchor)
1009
+ if (dec !== anchor) list.push(dec)
1010
+ } catch {
1011
+ /* malformed percent-encoding — raw form only */
1012
+ }
1013
+ return list
1014
+ }
1015
+
1016
+ export function extract(html, mode, anchor = '') {
805
1017
  html = defuseLt(html) // kills the no-'>'-tail quadratic scans before any picker runs
806
1018
  const titleTag = /<title[^>]{0,1000}>([\s\S]*?)<\/title>/i.exec(html)
807
1019
  const ogTitle = metaContent(html, 'og:title')
@@ -809,21 +1021,36 @@ export function extract(html, mode) {
809
1021
  const langMatch = /<html[^>]{1,1000}lang=["']([\w-]+)["']/i.exec(html)
810
1022
  let main = stripNoise(pickMain(html))
811
1023
  main = stripNoise(revealEscapedTags(main))
1024
+ // A URL fragment scopes the read to one section of a long reference page —
1025
+ // slice at the anchor so only that section is extracted. Unmatched anchors
1026
+ // degrade to the full document instead of failing.
1027
+ let scoped = null
1028
+ for (const cand of anchorCandidates(anchor)) {
1029
+ scoped = sliceAtAnchor(main, cand)
1030
+ if (scoped) break
1031
+ }
812
1032
  let bodyText
813
1033
  if (mode === 'markdown') {
814
- bodyText = blockMd(main).replace(/\n{3,}/g, '\n\n').trim()
1034
+ bodyText = blockMd(scoped || main).replace(/\n{3,}/g, '\n\n').trim()
815
1035
  } else {
816
- bodyText = textOnly(main).replace(/ +/g, ' ').trim()
1036
+ bodyText = textLines(scoped || main).replace(/ +/g, ' ').trim()
817
1037
  }
818
1038
  const meta = extractMeta(html)
819
1039
  const ld = jsonLdMeta(html)
820
- // Merge chain: <meta> → JSON-LD → byline. An explicit article:/og: meta date
821
- // stays authoritative; otherwise a JSON-LD datePublished beats the generic
822
- // date meta key (last-modified stamps are not publication times).
823
- const published = ld.published
824
- ? (meta.publishedExplicit ? meta.published : ld.published)
825
- : meta.published
1040
+ // Merge chain: explicit article:/og: meta date → JSON-LD datePublished →
1041
+ // <time datetime> (semantic markup usually marks publication, unlike the
1042
+ // generic date meta key which is often last-modified) → generic date meta →
1043
+ // byline text. jsonLdArticleBody doubles as a thin-body fallback below.
1044
+ const published = meta.publishedExplicit
1045
+ ? meta.published
1046
+ : normalizeDate(ld.published || timeTagMeta(main) || meta.published)
826
1047
  const author = ld.author || meta.author
1048
+ // Thin-body fallback: anti-bot shells and JS-only pages still carry the
1049
+ // full article in ld+json — prefer it over the short og:description hint.
1050
+ // An anchored read scopes the answer to one section: the ld+json body is
1051
+ // the WHOLE article and would override the located section, so it applies
1052
+ // only to full-document reads.
1053
+ const ldBody = !scoped && bodyText.length < 200 ? jsonLdArticleBody(html) : ''
827
1054
  // Empty-body pages (login walls, JS-only shells) still carry og:description
828
1055
  // — surface it as a fallback hint instead of nothing.
829
1056
  let text = bodyText
@@ -832,6 +1059,17 @@ export function extract(html, mode) {
832
1059
  text = meta.description
833
1060
  usedDescription = true
834
1061
  }
1062
+ if (ldBody && ldBody.length > text.length) {
1063
+ text = ldBody
1064
+ usedDescription = false
1065
+ }
1066
+ // Same thin-body shape, next fallback layer: SPA shells put their SEO copy
1067
+ // in <noscript> (stripped from main) — surface it when substantive.
1068
+ const nosBody = !scoped && text.length < 200 ? noscriptBody(html) : ''
1069
+ if (nosBody && nosBody.length > text.length) {
1070
+ text = nosBody
1071
+ usedDescription = false
1072
+ }
835
1073
  const byline = bylineFallback(text, { published, author })
836
1074
  return {
837
1075
  title: titleTag ? textOnly(titleTag[1]) || titleTag[1].trim() : ogTitle,
@@ -841,6 +1079,7 @@ export function extract(html, mode) {
841
1079
  author: byline.author,
842
1080
  text,
843
1081
  usedDescription,
1082
+ anchored: !!scoped,
844
1083
  }
845
1084
  }
846
1085
 
@@ -1053,6 +1292,10 @@ function sliceFrom(full, offset, maxChars) {
1053
1292
  }
1054
1293
  if (full.rendered) out.rendered = true
1055
1294
  if (full.spaHint) out.spaHint = full.spaHint
1295
+ if (full.anchored) {
1296
+ out.anchored = true
1297
+ out.anchor = full.anchor || ''
1298
+ }
1056
1299
  if (full.paginated > 1) out.paginated = full.paginated
1057
1300
  if (full.feedCount) out.feedCount = full.feedCount
1058
1301
  if (full.published) out.published = full.published
@@ -1142,9 +1385,13 @@ async function followMetaRefresh(html, finalUrl, externalSignal, cfg) {
1142
1385
  let curUrl = finalUrl
1143
1386
  let curCharset = '' // set only when a hop actually happened
1144
1387
  const seen = new Set([normalizeUrl(finalUrl)])
1388
+ // Chain budget: each hop carries its own fetch timeout, so 3 hops could
1389
+ // triple the call's cost — the whole follow chain shares ONE timeout.
1390
+ const t0 = Date.now()
1145
1391
  for (let i = 0; i < MAX_META_REFRESH_HOPS; i++) {
1146
1392
  const target = metaRefreshTarget(cur, curUrl)
1147
1393
  if (!target) break
1394
+ if (Date.now() - t0 > cfg.timeoutMs) break
1148
1395
  const norm = normalizeUrl(target)
1149
1396
  if (!norm || seen.has(norm)) break
1150
1397
  seen.add(norm)
@@ -1166,29 +1413,52 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1166
1413
  const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
1167
1414
  const mode = args.mode === 'markdown' ? 'markdown' : 'text'
1168
1415
  const offset = Math.max(0, Number(args.offset) || 0)
1416
+ // Wall-clock anchor for the pagination budget below (the tool timeout
1417
+ // formula reserves paginateMax+1 fetches + render slack; the in-code guard
1418
+ // keeps a slow page from consuming the budget meant for the rest).
1419
+ const startedAt = Date.now()
1169
1420
 
1170
1421
  // Cache stores the FULL extracted text keyed by url+mode+includeLinks, so
1171
1422
  // continuation reads (offset) and different maxChars hit the same entry —
1172
1423
  // but a links-request must not be served from a links-less cached copy.
1173
- // The key drops the URL fragment: #sections would otherwise split the cache.
1424
+ // The fragment STAYS in the key: a #section read scopes the extraction to
1425
+ // that anchor, so two fragments must never share one cache entry.
1174
1426
  let cacheUrl = url
1175
1427
  try {
1176
- const u = new URL(url)
1177
- u.hash = ''
1178
- cacheUrl = u.href
1428
+ cacheUrl = new URL(url).href
1179
1429
  } catch {
1180
1430
  /* keep raw url on parse failure */
1181
1431
  }
1432
+ // A #fragment scopes the read to one section of a long page. The browser
1433
+ // never sends it to the server, so it must be taken from the REQUEST url —
1434
+ // finalUrl (after redirects/renders) has no fragment.
1435
+ let anchor = ''
1436
+ try {
1437
+ const h = new URL(url).hash
1438
+ if (h.length > 1) anchor = h.slice(1)
1439
+ } catch {
1440
+ /* keep bare url */
1441
+ }
1182
1442
  const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1183
1443
  // Failed fetches are cached briefly so the model doesn't re-request a
1184
1444
  // broken URL in a loop (token + latency saver).
1185
1445
  const fh = failCache.get(cacheKey)
1186
1446
  if (fh) {
1187
- if (Date.now() - fh.time < 30000) return { error: fh.error, cached: true }
1447
+ if (Date.now() - fh.time < 30000) {
1448
+ // LRU touch: re-insert as the newest slot so repeated failures on a
1449
+ // busy session don't evict (and re-fetch) this URL before its TTL ends.
1450
+ failCache.delete(cacheKey)
1451
+ failCache.set(cacheKey, fh)
1452
+ return { error: fh.error, cached: true }
1453
+ }
1188
1454
  failCache.delete(cacheKey)
1189
1455
  }
1190
1456
  const hit = cache.get(cacheKey)
1191
1457
  if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
1458
+ // LRU touch: hot pages survive eviction even when cached long ago — the
1459
+ // Map re-insertion moves the entry to the newest slot.
1460
+ cache.delete(cacheKey)
1461
+ cache.set(cacheKey, hit)
1192
1462
  const sliced = sliceFrom(hit.full, offset, maxChars)
1193
1463
  return { ...sliced, cached: true }
1194
1464
  }
@@ -1198,16 +1468,26 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1198
1468
  return { error }
1199
1469
  }
1200
1470
 
1201
- // Non-HTML payloads (JSON APIs, RSS/Atom feeds) are read natively: fetch,
1202
- // dispatch by content-type / payload shape, render compact. Falls back to
1203
- // the HTML pipeline for everything else.
1471
+ // Non-HTML payloads (JSON APIs, RSS/Atom feeds, plain text files) are read
1472
+ // natively: fetch, dispatch by content-type / payload shape, render compact.
1473
+ // Falls back to the HTML pipeline for everything else.
1474
+ // JSON with JS-only literals (NaN/Infinity — hand-rolled serializers) is
1475
+ // not valid JSON: retry once with the literals nulled rather than dropping
1476
+ // the whole page into the HTML pipeline as a raw-text dump.
1477
+ const parseJsonLoose = (t) => {
1478
+ try {
1479
+ return JSON.parse(t)
1480
+ } catch {
1481
+ return JSON.parse(t.replace(/-?(?:NaN|Infinity)\b/g, 'null'))
1482
+ }
1483
+ }
1204
1484
  const dispatch = async () => {
1205
1485
  const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1206
1486
  if (viaSeam) {
1207
1487
  const t = viaSeam.html.trim()
1208
1488
  if (t.startsWith('{') || t.startsWith('[')) {
1209
1489
  try {
1210
- return { kind: 'json', text: compactJson(JSON.parse(t)) }
1490
+ return { kind: 'json', text: compactJson(parseJsonLoose(t)) }
1211
1491
  } catch { /* not JSON — keep HTML pipeline */ }
1212
1492
  }
1213
1493
  return { kind: 'html', html: viaSeam.html, finalUrl: viaSeam.finalUrl, charset: 'provider-decoded' }
@@ -1218,7 +1498,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1218
1498
  const decoded = decodeBuffer(page.buffer, page.contentType)
1219
1499
  if (/json$/.test(ct)) {
1220
1500
  try {
1221
- return { kind: 'json', text: compactJson(JSON.parse(decoded.text)) }
1501
+ return { kind: 'json', text: compactJson(parseJsonLoose(decoded.text)) }
1222
1502
  } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
1223
1503
  }
1224
1504
  if (/[+/]xml$/.test(ct)) {
@@ -1229,11 +1509,30 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1229
1509
  return { kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks) }
1230
1510
  }
1231
1511
  }
1512
+ // Plain-text families (txt/md/csv): line structure IS the content — the
1513
+ // HTML pipeline would flatten every newline into spaces. Pages that are
1514
+ // actually HTML but mislabelled text/plain keep the HTML pipeline.
1515
+ if (/^text\/(?:plain|markdown|csv)$/.test(ct)) {
1516
+ const head = decoded.text.slice(0, 5000)
1517
+ if (!/<(?:html|body|div|p|table)\b[\s>]/i.test(head)) {
1518
+ const text = decoded.text
1519
+ .replace(/\r\n?/g, '\n')
1520
+ .replace(/[ \t]+\n/g, '\n')
1521
+ .replace(/\n{4,}/g, '\n\n\n')
1522
+ .trim()
1523
+ return { kind: 'text', text, charset: decoded.charset }
1524
+ }
1525
+ }
1232
1526
  return { kind: 'html', html: decoded.text, finalUrl: page.finalUrl || url, charset: decoded.charset }
1233
1527
  }
1234
1528
  const payload = await dispatch()
1235
1529
  if (payload.error) return failWith(payload.error)
1236
1530
 
1531
+ if (payload.kind === 'text') {
1532
+ const full = { url, title: '', siteName: hostOf(url), lang: '', charset: payload.charset, mode: 'text', fullText: payload.text, paginated: 1 }
1533
+ cacheStore(cacheKey, full, cfg.cacheMax)
1534
+ return sliceFrom(full, offset, maxChars)
1535
+ }
1237
1536
  if (payload.kind === 'json') {
1238
1537
  const full = { url, title: '', siteName: hostOf(url), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1 }
1239
1538
  cacheStore(cacheKey, full, cfg.cacheMax)
@@ -1264,7 +1563,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1264
1563
  // The target may use a different encoding than the shell page — reflect it.
1265
1564
  if (followed.charset) charset = followed.charset
1266
1565
 
1267
- let extracted = extract(html, mode)
1566
+ let extracted = extract(html, mode, anchor)
1268
1567
  // Optional readability upgrade: cleaner article extraction when installed.
1269
1568
  const ext = await getReadabilityExtractor()
1270
1569
  // The base passed to readability must be the CURRENT final URL — after a
@@ -1277,14 +1576,16 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1277
1576
  if (clean && clean.length > 0) {
1278
1577
  target.text = mode === 'markdown'
1279
1578
  ? blockMd(clean).replace(/\n{3,}/g, '\n\n').trim()
1280
- : textOnly(clean).replace(/ +/g, ' ').trim()
1579
+ : textLines(clean).replace(/ +/g, ' ').trim()
1281
1580
  }
1282
1581
  } catch {
1283
1582
  // keep heuristic result
1284
1583
  }
1285
1584
  return target
1286
1585
  }
1287
- extracted = upgrade(extracted, html)
1586
+ // Readability would replace the scoped section with the FULL article text;
1587
+ // when an anchor matched, the scoped extraction IS the answer.
1588
+ extracted = extracted.anchored ? extracted : upgrade(extracted, html)
1288
1589
 
1289
1590
  // Optional SPA enhancement: if the page looks client-rendered and static
1290
1591
  // extraction found almost nothing, try headless rendering (playwright).
@@ -1311,7 +1612,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1311
1612
  ? '已渲染但被人机验证页拦截,保留静态提取结果'
1312
1613
  : '已渲染但被人机验证页拦截(无静态内容可用)'
1313
1614
  } else {
1314
- const r2 = upgrade(extract(rr.html, mode), rr.html, rr.finalUrl || finalUrl)
1615
+ const r2raw = extract(rr.html, mode, anchor)
1616
+ const r2 = r2raw.anchored ? r2raw : upgrade(r2raw, rr.html, rr.finalUrl || finalUrl)
1315
1617
  // Accept the rendered text when it meaningfully beats the static one.
1316
1618
  // From an EMPTY static extraction even a short body is a real gain
1317
1619
  // (JS shells can render just 30-60 chars of real content, below the
@@ -1338,10 +1640,16 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1338
1640
  const paginateMax = cfg.paginate === false ? 1 : Math.max(1, Math.min(10, Number(cfg.paginateMax) || 3))
1339
1641
  const startHost = hostOf(finalUrl || url)
1340
1642
  const seen = new Set([normalizeUrl(finalUrl || url)])
1341
- let nextUrl = paginateMax > 1 ? findNextLink(renderedHtml || html, finalUrl || url) : null
1643
+ // An anchored read is scoped to one section — joining the "next page" chain
1644
+ // would append whole pages the fragment never asked for.
1645
+ let nextUrl = paginateMax > 1 && !extracted.anchored ? findNextLink(renderedHtml || html, finalUrl || url) : null
1342
1646
  while (
1343
1647
  nextUrl && paginated < paginateMax &&
1344
1648
  sameHost(nextUrl, startHost) &&
1649
+ // Budget guard: the whole read (initial fetch + renders + chain) must fit
1650
+ // the tool timeout formula (paginateMax+1 fetches). A slow first page or
1651
+ // SPA render eats into the chain's share here instead of overshooting.
1652
+ Date.now() - startedAt < cfg.timeoutMs * (paginateMax + 1) &&
1345
1653
  !(externalSignal && externalSignal.aborted)
1346
1654
  ) {
1347
1655
  const nu = normalizeUrl(nextUrl)
@@ -1359,6 +1667,13 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1359
1667
  nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1360
1668
  }
1361
1669
 
1670
+ // A near-empty body (login wall, anti-bot shell) with no render hint yet
1671
+ // still looks like "the page content" to the model — one constant-cost line
1672
+ // tells it not to keep offset-reading a 40-char page.
1673
+ if (!spaHint && extracted.text && extracted.text.length < 60) {
1674
+ spaHint = '正文极短(可能登录墙或反爬拦截)'
1675
+ }
1676
+
1362
1677
  const full = {
1363
1678
  url: finalUrl,
1364
1679
  title: extracted.title || '',
@@ -1369,6 +1684,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1369
1684
  fullText: extracted.text,
1370
1685
  rendered,
1371
1686
  spaHint,
1687
+ anchored: extracted.anchored || false,
1688
+ anchor: extracted.anchored ? anchor : '',
1372
1689
  paginated,
1373
1690
  published: extracted.published || '',
1374
1691
  author: extracted.author || '',
@@ -1381,7 +1698,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1381
1698
  return sliceFrom(full, offset, maxChars)
1382
1699
  }
1383
1700
 
1384
- function renderResult(value) {
1701
+ export function renderResult(value) {
1385
1702
  if (typeof value === 'string') return value
1386
1703
  const r = value || {}
1387
1704
  if (r.error) return `Error: ${r.error}`
@@ -1396,6 +1713,9 @@ function renderResult(value) {
1396
1713
  }
1397
1714
  if (r.published) meta.push(r.published)
1398
1715
  if (r.author) meta.push(`by ${r.author}`)
1716
+ // Tells the model why the text starts mid-document: the read was scoped to
1717
+ // this fragment, and offset continues from the section start.
1718
+ if (r.anchored) meta.push(`#${r.anchor || ''}`)
1399
1719
  if (r.feedCount) meta.push(`feed · ${r.feedCount} 条`)
1400
1720
  if (r.mode === 'json') meta.push('json')
1401
1721
  if (meta.length) lines.push(meta.join(' · '))
@@ -1408,6 +1728,9 @@ function renderResult(value) {
1408
1728
  if (r.cached) flags.push('cached')
1409
1729
  if (r.paginated > 1) flags.push(`已拼接${r.paginated}页`)
1410
1730
  if (r.rendered) flags.push('rendered')
1731
+ // With text present the hint rides the flags line; with empty text the
1732
+ // dedicated "无可读内容" line below already carries it.
1733
+ if (r.text && r.spaHint) flags.push(r.spaHint)
1411
1734
  if (flags.length) lines.push(`(${flags.join(' · ')})`)
1412
1735
  if (!r.text) {
1413
1736
  if (r.charsStart > 0 && !r.truncated) {
@@ -1582,15 +1905,34 @@ function readUrlBatchTool(ctx, cfg) {
1582
1905
  },
1583
1906
  async execute(args, exec) {
1584
1907
  const a = args || {}
1585
- const urls = (Array.isArray(a.urls) ? a.urls : []).map((u) => String(u).trim()).filter(Boolean)
1908
+ // Duplicate URLs collapse to one fetch (parallel copies would race the
1909
+ // cache and fetch the same page twice); order is preserved. Exact-string
1910
+ // identity: a #fragment read is an ANCHORED read with different
1911
+ // semantics, so it must not merge with its bare URL.
1912
+ const seenUrl = new Set()
1913
+ const urls = (Array.isArray(a.urls) ? a.urls : [])
1914
+ .map((u) => String(u).trim())
1915
+ .filter(Boolean)
1916
+ .filter((u) => {
1917
+ if (seenUrl.has(u)) return false
1918
+ seenUrl.add(u)
1919
+ return true
1920
+ })
1586
1921
  if (!urls.length) return { error: 'urls array is required (1-10 http(s) URLs)' }
1587
1922
  const list = urls.slice(0, 10)
1588
1923
  const perMax = Math.max(500, Math.min(20000, Number(a.maxChars) || 3000))
1589
1924
  const mode = a.mode === 'markdown' ? 'markdown' : 'text'
1590
1925
  const signal = exec && exec.signal
1591
- const pages = await mapLimit(list, 4, (u) =>
1592
- readUrl({ url: u, maxChars: perMax, mode, includeLinks: a.includeLinks === true }, ctx, signal, cfg),
1593
- )
1926
+ // One page throwing must not reject the whole batch — readUrl returns
1927
+ // error objects for expected failures, but any unexpected exception is
1928
+ // converted to one here too.
1929
+ const pages = await mapLimit(list, 4, async (u) => {
1930
+ try {
1931
+ return await readUrl({ url: u, maxChars: perMax, mode, includeLinks: a.includeLinks === true }, ctx, signal, cfg)
1932
+ } catch (e) {
1933
+ return { error: `Unexpected error: ${e && e.message ? e.message : e}` }
1934
+ }
1935
+ })
1594
1936
  const ok = pages.filter((p) => !p.error)
1595
1937
  return {
1596
1938
  total: list.length,
@@ -1663,11 +2005,19 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
1663
2005
  visited.add(norm)
1664
2006
  const page = await fetchPage(url, externalSignal, cfg)
1665
2007
  if (page.error) return { url, depth, error: page.error }
1666
- const html = decodeBuffer(page.buffer, page.contentType).text
2008
+ let html = decodeBuffer(page.buffer, page.contentType).text
2009
+ let pageUrl = page.finalUrl || url
2010
+ // Same shell-following as read_url: a meta-refresh stub would otherwise
2011
+ // be crawled AS the page (title-less hop text pollutes results and the
2012
+ // real content is never reached). Fails open — fetch problems keep the
2013
+ // stub's own HTML.
2014
+ const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg)
2015
+ html = followed.html
2016
+ pageUrl = followed.finalUrl
1667
2017
  const ex = extract(html, 'text')
1668
- const links = extractLinks(html, 100, page.finalUrl || url)
2018
+ const links = extractLinks(html, 100, pageUrl)
1669
2019
  return {
1670
- url: page.finalUrl || url,
2020
+ url: pageUrl,
1671
2021
  depth,
1672
2022
  title: ex.title || '',
1673
2023
  chars: ex.text.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-url",
3
- "version": "1.3.1",
3
+ "version": "1.6.1",
4
4
  "description": "DeepSeek Harness URL reader: fetch any webpage (HTML/JSON/RSS/Atom), auto-detect encoding (UTF-16 BOM/GBK/Big5/Shift-JIS), extract clean main content with auto-pagination, output compact text/Markdown to save tokens. Zero runtime dependencies, no API key, drop-in. Follows meta-refresh shells, reads JSON-LD metadata, honors base href.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/spa.js CHANGED
@@ -58,13 +58,13 @@ export async function renderPage(url, externalSignal) {
58
58
  // 'domcontentloaded' instead of 'networkidle': heartbeat-polling sites
59
59
  // (qq-news, juejin) never go idle and would time out at 30s. Instead wait
60
60
  // for the DOM to stabilize (content stops growing) up to 10s — SPA paint
61
- // usually lands within a couple of seconds of DOM-ready.
62
- // The cooperative signal aborts both the navigation and the poll loop, so
63
- // a cancelled/timed-out call releases the browser immediately instead of
64
- // burning the full poll window.
65
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000, signal: externalSignal || undefined })
61
+ // usually lands within a couple of seconds of DOM-ready. Two consecutive
62
+ // evaluate failures mean the page crashed — stop waiting for growth that
63
+ // can never come.
64
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 })
66
65
  const t0 = Date.now()
67
66
  let prevLen = -1
67
+ let evalFails = 0
68
68
  for (;;) {
69
69
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
70
70
  await page.waitForTimeout(500)
@@ -72,6 +72,7 @@ export async function renderPage(url, externalSignal) {
72
72
  .evaluate(() => (document.body ? document.body.innerHTML.length : 0))
73
73
  .catch(() => -1)
74
74
  if (Date.now() - t0 > 10000) break
75
+ if (len === -1 && ++evalFails >= 2) break
75
76
  // stop once two consecutive reads agree — including empty bodies (a
76
77
  // blank page should not burn the full 10s poll)
77
78
  if (len === prevLen && prevLen >= 0) break