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 +107 -0
- package/README.md +7 -6
- package/README.zh.md +7 -6
- package/index.js +280 -44
- package/package.json +1 -1
- package/spa.js +6 -5
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.
|
|
217
|
-
6.
|
|
218
|
-
7. **
|
|
219
|
-
8. **
|
|
220
|
-
9. **
|
|
221
|
-
10. **
|
|
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 `—`/`…` 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.
|
|
217
|
-
6.
|
|
218
|
-
7.
|
|
219
|
-
8.
|
|
220
|
-
9.
|
|
221
|
-
10.
|
|
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 个命名实体,`—`/`…` 等残留不再浪费 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)
|
|
104
|
-
// XML (RSS/Atom feeds)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
out = out
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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:
|
|
901
|
-
//
|
|
902
|
-
// date meta key
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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)
|
|
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
|
|
1296
|
-
// dispatch by content-type / payload shape, render compact.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1706
|
-
|
|
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
|
-
|
|
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,
|
|
2018
|
+
const links = extractLinks(html, 100, pageUrl)
|
|
1783
2019
|
return {
|
|
1784
|
-
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.
|
|
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
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
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
|