dsh-read-url 1.4.0 → 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,5 +1,112 @@
1
1
  # Changelog
2
2
 
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
+
3
110
  ## [1.4.0] - 2026-08-28
4
111
 
5
112
  > 省token主打轮。经实测复核,原方案中的「nav 导航块剔除」与「RSS/Atom 条目摘要」在 1.3.1 已就位(python-docs 实测 235K 全文 → 20K 内干净正文,导航零泄漏;feed 输出已含每条 ≤200 字符摘要),本轮聚焦真实缺口:隐藏元素剥离、URL 片段定位阅读、段落化正文。
package/README.md CHANGED
@@ -213,12 +213,13 @@ const results = await Promise.all([
213
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
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
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. **`text` mode first** — Markdown structure is opt-in;
217
- 6. **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;
218
- 7. **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;
219
- 8. **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);
220
- 9. **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;
221
- 10. **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.
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.
222
223
 
223
224
  ## Technical notes
224
225
 
package/README.zh.md CHANGED
@@ -213,12 +213,13 @@ const results = await Promise.all([
213
213
  2. **段落级智能截断 + offset 续读**——默认 6000 字符(约 3000 token),在段落边界截断保证语义完整(text 模式段落以 `\n\n` 分隔,切口落在真实段落边界而非句中),输出行仅一行 `(chars 6000/12990 — 截断,offset 续读)` 引导;续读从指定偏移开始、命中缓存切片,**不重复返回已读前文**(实测 0+500 → 500+500,无重复);offset 越界返回空而非重复开头;
214
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
215
  4. **隐藏元素与同意弹窗剥离(v1.4.0)**——`display:none`/`visibility:hidden` 装饰树、`hidden` 属性、`aria-hidden="true"`、OneTrust/Cookiebot/GDPR id 挂载的横幅不再混入正文;复合属性名(`data-hidden`/`data-id`)有界卫兵不误伤;
216
- 5. **text 模式优先**——Markdown 结构按需开启;
217
- 6. **紧凑文本 render**——模型直接看到 `title:` 头部 + 正文,无需解析 JSON;`siteName` 与域名相同时省略;状态提示全部一行内(截断/续读/缓存/渲染标记),无长段落废话;
218
- 7. **双层缓存**——成功结果按 URL 缓存 5 分钟(重复读取直接命中,省网络也省模型重试);**失败结果缓存 30 秒**(坏 URL 不会触发重复 fetch 循环);
219
- 8. **KV Cache 友好(DeepSeek 成本特调)**——工具 schema/description 保持**静态文本**(不嵌入配置值),配置变更不会使可复用的 prompt 前缀失效,KV 缓存持续命中。DeepSeek 缓存命中 token 价格约为未命中的 1/10,前缀越稳定越省钱(官方 `tool-web` 文档同款分析);
220
- 9. **批量共用缓存**——`read_url_batch` 内部复用同一套缓存,重复批量读直接命中,且每页默认 3000 字符(低于单页 6000)控制总量;
221
- 10. **固定开销压缩**——4 个工具 description 合计约 900 字符(有断言守卫,保持静态利于 KV 缓存);HTML 实体解码扩展至 45 个命名实体,`&mdash;`/`&hellip;` 等残留不再浪费 token 或显示为乱码。
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 或显示为乱码。
222
223
 
223
224
  ## 技术说明
224
225
 
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,6 +353,7 @@ 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
 
@@ -357,7 +373,7 @@ function textLines(html) {
357
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')
358
374
  .replace(/<[^>]{1,1000}>/g, ' '))
359
375
  const lines = []
360
- for (const rawLine of cleaned.split('\n')) {
376
+ for (const rawLine of cleaned.replace(ZW_RE, '').split('\n')) {
361
377
  let line = rawLine.replace(/[ \t\r\f\v]+/g, ' ').trim()
362
378
  // A heading becomes its own short paragraph in this layout; a trailing
363
379
  // permalink glyph on one is anchor decoration, not content.
@@ -400,7 +416,10 @@ function parseFeed(xml, limit) {
400
416
  const linkTag = /<link[^>]*>([\s\S]*?)<\/link>/i.exec(it)
401
417
  const linkHref = /<link[^>]+href=["']([^"']+)["']/i.exec(it)
402
418
  const link = (linkTag && xmlText(linkTag[1])) || (linkHref && linkHref[1]) || ''
403
- 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] || '')
404
423
  if (!title && !link && !desc) continue
405
424
  items.push({ title: title.slice(0, 120), url: link, summary: desc.slice(0, 200) })
406
425
  }
@@ -453,13 +472,16 @@ export function densityFilter(html) {
453
472
 
454
473
  // Depth-counted block extraction: returns html from the opening tag at
455
474
  // openIdx through its BALANCED closing tag. A non-greedy regex would stop at
456
- // the first nested </div>, truncating heavily nested container divs.
457
- 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) {
458
479
  const re = new RegExp(`</?${tagName}(?=[\\s>])`, 'gi')
459
480
  re.lastIndex = openIdx
460
481
  let depth = 0
461
482
  let m
462
483
  while ((m = re.exec(html))) {
484
+ if (m.index - openIdx > maxScan) return null
463
485
  depth += m[0][1] === '/' ? -1 : 1
464
486
  if (depth === 0) {
465
487
  const close = html.indexOf('>', m.index)
@@ -469,6 +491,29 @@ function tagBlockAt(html, openIdx, tagName) {
469
491
  return null
470
492
  }
471
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
+
472
517
  export function pickMain(html) {
473
518
  // Aggregation pages (e.g. blog homepages) have one <article> per item:
474
519
  // collect all of them instead of only the first. A tiny <article> (e.g. a
@@ -540,11 +585,11 @@ function stripNoise(mainHtml) {
540
585
  // body. The [\s"'] guard keeps `hidden`/`style` from matching inside
541
586
  // compound attr names (data-hidden, aria-hidden). Consent/GDPR banners mount
542
587
  // by id (onetrust, cookiebot, cybot, gdpr) rather than class, so a second
543
- // pass keys on the id attribute. Every scan run is bounded.
544
- const hidden = /<(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}>[\s\S]{0,50000}?<\/\1\s*>/gi
545
- out = out.replace(hidden, ' ')
546
- const consent = /<([a-z][a-z0-9]*)\s+id=["'][^"']{0,120}(?:onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-)[^"']{0,120}["'][^>]{0,1000}>[\s\S]{0,50000}?<\/\1\s*>/gi
547
- out = out.replace(consent, ' ')
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)
548
593
  return out.replace(
549
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,
550
595
  ' ',
@@ -563,10 +608,29 @@ function escInline(s) {
563
608
  const IMG_SENTINEL = '\u0001'
564
609
  function imgsToMarkdown(html) {
565
610
  const store = []
566
- 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) => {
567
624
  const alt = /alt=["']([^"']*)["']/i.exec(tag)
568
- const src = /src=["']([^"']+)["']/i.exec(tag)
569
- 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})` : '')
570
634
  return `${IMG_SENTINEL}${store.length - 1}${IMG_SENTINEL}`
571
635
  })
572
636
  return {
@@ -588,6 +652,7 @@ function imgsToMarkdown(html) {
588
652
  // the walker degrades to plain text.
589
653
  const OPEN_TAG_RE = /<([a-zA-Z0-9]+)((?:"[^"]*"|'[^']*'|[^'">]){0,1000})>/g
590
654
  const MD_MAX_DEPTH = 100
655
+ const MD_TABLE_MAX_ROWS = 25
591
656
  function stripUnmatchedOpeners(html) {
592
657
  const closers = new Set()
593
658
  for (const m of html.matchAll(/<\/([a-zA-Z0-9]+)/g)) closers.add(m[1].toLowerCase())
@@ -713,7 +778,12 @@ export function blockMd(html, depth = 0) {
713
778
  // unescape cells' escaped pipes before deriving the separator row,
714
779
  // otherwise the --- line is one char wider per escaped pipe
715
780
  const sep = header.replace(/\\\|/g, '|').replace(/[^|]/g, '-')
716
- 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` : ''}`
717
787
  }
718
788
  } else if (tag === 'a' || tag === 'strong' || tag === 'b' || tag === 'em' || tag === 'i') {
719
789
  const t = inlineMd(inner, depth + 1)
@@ -723,7 +793,7 @@ export function blockMd(html, depth = 0) {
723
793
  if (t) out += t
724
794
  }
725
795
  }
726
- return img.restore(out)
796
+ return img.restore(out).replace(ZW_RE, '')
727
797
  }
728
798
 
729
799
  // Read the content attribute of the FIRST <meta> whose property/name equals
@@ -818,6 +888,76 @@ function jsonLdMeta(html) {
818
888
  return { published: '', author: '' }
819
889
  }
820
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
+
821
961
  // Meta-less pages still print the byline inside the body head: "作者:阮一峰
822
962
  // 日期:2026年8月21日". Fill ONLY the missing fields, and only from the first
823
963
  // 600 chars of body text — deeper matches are content, not bylines.
@@ -833,7 +973,7 @@ function bylineFallback(text, meta) {
833
973
  }
834
974
  if (!out.published) {
835
975
  const m = BYLINE_DATE_RE.exec(head)
836
- 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))
837
977
  }
838
978
  return out
839
979
  }
@@ -897,13 +1037,20 @@ export function extract(html, mode, anchor = '') {
897
1037
  }
898
1038
  const meta = extractMeta(html)
899
1039
  const ld = jsonLdMeta(html)
900
- // Merge chain: <meta> → JSON-LD → byline. An explicit article:/og: meta date
901
- // stays authoritative; otherwise a JSON-LD datePublished beats the generic
902
- // date meta key (last-modified stamps are not publication times).
903
- const published = ld.published
904
- ? (meta.publishedExplicit ? meta.published : ld.published)
905
- : 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)
906
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) : ''
907
1054
  // Empty-body pages (login walls, JS-only shells) still carry og:description
908
1055
  // — surface it as a fallback hint instead of nothing.
909
1056
  let text = bodyText
@@ -912,6 +1059,17 @@ export function extract(html, mode, anchor = '') {
912
1059
  text = meta.description
913
1060
  usedDescription = true
914
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
+ }
915
1073
  const byline = bylineFallback(text, { published, author })
916
1074
  return {
917
1075
  title: titleTag ? textOnly(titleTag[1]) || titleTag[1].trim() : ogTitle,
@@ -1227,9 +1385,13 @@ async function followMetaRefresh(html, finalUrl, externalSignal, cfg) {
1227
1385
  let curUrl = finalUrl
1228
1386
  let curCharset = '' // set only when a hop actually happened
1229
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()
1230
1391
  for (let i = 0; i < MAX_META_REFRESH_HOPS; i++) {
1231
1392
  const target = metaRefreshTarget(cur, curUrl)
1232
1393
  if (!target) break
1394
+ if (Date.now() - t0 > cfg.timeoutMs) break
1233
1395
  const norm = normalizeUrl(target)
1234
1396
  if (!norm || seen.has(norm)) break
1235
1397
  seen.add(norm)
@@ -1251,6 +1413,10 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1251
1413
  const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
1252
1414
  const mode = args.mode === 'markdown' ? 'markdown' : 'text'
1253
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()
1254
1420
 
1255
1421
  // Cache stores the FULL extracted text keyed by url+mode+includeLinks, so
1256
1422
  // continuation reads (offset) and different maxChars hit the same entry —
@@ -1278,11 +1444,21 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1278
1444
  // broken URL in a loop (token + latency saver).
1279
1445
  const fh = failCache.get(cacheKey)
1280
1446
  if (fh) {
1281
- 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
+ }
1282
1454
  failCache.delete(cacheKey)
1283
1455
  }
1284
1456
  const hit = cache.get(cacheKey)
1285
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)
1286
1462
  const sliced = sliceFrom(hit.full, offset, maxChars)
1287
1463
  return { ...sliced, cached: true }
1288
1464
  }
@@ -1292,16 +1468,26 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1292
1468
  return { error }
1293
1469
  }
1294
1470
 
1295
- // Non-HTML payloads (JSON APIs, RSS/Atom feeds) are read natively: fetch,
1296
- // dispatch by content-type / payload shape, render compact. Falls back to
1297
- // 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
+ }
1298
1484
  const dispatch = async () => {
1299
1485
  const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1300
1486
  if (viaSeam) {
1301
1487
  const t = viaSeam.html.trim()
1302
1488
  if (t.startsWith('{') || t.startsWith('[')) {
1303
1489
  try {
1304
- return { kind: 'json', text: compactJson(JSON.parse(t)) }
1490
+ return { kind: 'json', text: compactJson(parseJsonLoose(t)) }
1305
1491
  } catch { /* not JSON — keep HTML pipeline */ }
1306
1492
  }
1307
1493
  return { kind: 'html', html: viaSeam.html, finalUrl: viaSeam.finalUrl, charset: 'provider-decoded' }
@@ -1312,7 +1498,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1312
1498
  const decoded = decodeBuffer(page.buffer, page.contentType)
1313
1499
  if (/json$/.test(ct)) {
1314
1500
  try {
1315
- return { kind: 'json', text: compactJson(JSON.parse(decoded.text)) }
1501
+ return { kind: 'json', text: compactJson(parseJsonLoose(decoded.text)) }
1316
1502
  } catch { /* invalid JSON — serve through the HTML pipeline as text */ }
1317
1503
  }
1318
1504
  if (/[+/]xml$/.test(ct)) {
@@ -1323,11 +1509,30 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1323
1509
  return { kind: 'feed', feed: parseFeed(decoded.text, cfg.maxLinks) }
1324
1510
  }
1325
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
+ }
1326
1526
  return { kind: 'html', html: decoded.text, finalUrl: page.finalUrl || url, charset: decoded.charset }
1327
1527
  }
1328
1528
  const payload = await dispatch()
1329
1529
  if (payload.error) return failWith(payload.error)
1330
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
+ }
1331
1536
  if (payload.kind === 'json') {
1332
1537
  const full = { url, title: '', siteName: hostOf(url), lang: '', charset: 'json', mode: 'json', fullText: payload.text, paginated: 1 }
1333
1538
  cacheStore(cacheKey, full, cfg.cacheMax)
@@ -1378,8 +1583,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1378
1583
  }
1379
1584
  return target
1380
1585
  }
1381
- // Readability would replace the scoped section with the FULL article text;
1382
- // when an anchor matched, the scoped extraction IS the answer.
1586
+ // Readability would replace the scoped section with the FULL article text;
1587
+ // when an anchor matched, the scoped extraction IS the answer.
1383
1588
  extracted = extracted.anchored ? extracted : upgrade(extracted, html)
1384
1589
 
1385
1590
  // Optional SPA enhancement: if the page looks client-rendered and static
@@ -1407,7 +1612,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1407
1612
  ? '已渲染但被人机验证页拦截,保留静态提取结果'
1408
1613
  : '已渲染但被人机验证页拦截(无静态内容可用)'
1409
1614
  } else {
1410
- const r2raw = extract(rr.html, mode, anchor)
1615
+ const r2raw = extract(rr.html, mode, anchor)
1411
1616
  const r2 = r2raw.anchored ? r2raw : upgrade(r2raw, rr.html, rr.finalUrl || finalUrl)
1412
1617
  // Accept the rendered text when it meaningfully beats the static one.
1413
1618
  // From an EMPTY static extraction even a short body is a real gain
@@ -1435,12 +1640,16 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1435
1640
  const paginateMax = cfg.paginate === false ? 1 : Math.max(1, Math.min(10, Number(cfg.paginateMax) || 3))
1436
1641
  const startHost = hostOf(finalUrl || url)
1437
1642
  const seen = new Set([normalizeUrl(finalUrl || url)])
1438
- // An anchored read is scoped to one section — joining the "next page" chain
1439
- // would append whole pages the fragment never asked for.
1643
+ // An anchored read is scoped to one section — joining the "next page" chain
1644
+ // would append whole pages the fragment never asked for.
1440
1645
  let nextUrl = paginateMax > 1 && !extracted.anchored ? findNextLink(renderedHtml || html, finalUrl || url) : null
1441
1646
  while (
1442
1647
  nextUrl && paginated < paginateMax &&
1443
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) &&
1444
1653
  !(externalSignal && externalSignal.aborted)
1445
1654
  ) {
1446
1655
  const nu = normalizeUrl(nextUrl)
@@ -1696,15 +1905,34 @@ function readUrlBatchTool(ctx, cfg) {
1696
1905
  },
1697
1906
  async execute(args, exec) {
1698
1907
  const a = args || {}
1699
- 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
+ })
1700
1921
  if (!urls.length) return { error: 'urls array is required (1-10 http(s) URLs)' }
1701
1922
  const list = urls.slice(0, 10)
1702
1923
  const perMax = Math.max(500, Math.min(20000, Number(a.maxChars) || 3000))
1703
1924
  const mode = a.mode === 'markdown' ? 'markdown' : 'text'
1704
1925
  const signal = exec && exec.signal
1705
- const pages = await mapLimit(list, 4, (u) =>
1706
- readUrl({ url: u, maxChars: perMax, mode, includeLinks: a.includeLinks === true }, ctx, signal, cfg),
1707
- )
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
+ })
1708
1936
  const ok = pages.filter((p) => !p.error)
1709
1937
  return {
1710
1938
  total: list.length,
@@ -1777,11 +2005,19 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
1777
2005
  visited.add(norm)
1778
2006
  const page = await fetchPage(url, externalSignal, cfg)
1779
2007
  if (page.error) return { url, depth, error: page.error }
1780
- 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
1781
2017
  const ex = extract(html, 'text')
1782
- const links = extractLinks(html, 100, page.finalUrl || url)
2018
+ const links = extractLinks(html, 100, pageUrl)
1783
2019
  return {
1784
- url: page.finalUrl || url,
2020
+ url: pageUrl,
1785
2021
  depth,
1786
2022
  title: ex.title || '',
1787
2023
  chars: ex.text.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-url",
3
- "version": "1.4.0",
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