dsh-read-url 1.8.1 → 1.9.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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.1] - 2026-09-28
4
+
5
+ ### 修复
6
+
7
+ - 链接、分页、base 与 meta-refresh 按真实属性读取,支持等号空格和合法无引号属性;忽略 data-href/data-rel 等伪属性,识别 rel 中的 next 标记。
8
+ - meta-refresh 正确解码实体并保留 URL 中的分号及查询参数,避免跳转地址被截断。
9
+ - 有界标签扫描识别引号内的 `>`,空 href 不吞掉后续锚点;锚点正文单向扫描,避免损坏 HTML 触发反复搜索。
10
+ - 链接扫描与站点爬取在取消后返回 cancelled,避免返回旧跳转壳或部分结果作为完整成功;Retry-After 等待结束或取消时清理监听与计时器。
11
+ - 站点正文摘要按 maxCharsPerPage 显示,取消渲染层额外的 80 字符截断;默认仍只输出站点结构。
12
+ - 对抗探针等待异步检查完成,异常设置非零退出码,避免未完成的 Promise 被打印为通过。
13
+
14
+ ### 验证
15
+
16
+ - 352 个零依赖单元断言、15 个真实 Chromium SPA 断言、59 项对抗探针通过;新增 30 条单元检查与 6 条未闭合锚点探针。原始维护缺陷在 v1.8.1/v1.9.0 同输入均可复现,属于既有问题。
17
+ - 152 站最终实网回归:103 OK / 20 THIN / 5 EMPTY / 24 ERR / 0 THREW,OK 站零噪声。Vue、React、酷狗及 Hatena 同 HTML 的全文续读与 800 字输出对照 v1.9.0 一致;微博动态波动经同 HTML/DOM 对照一致,错误站点集合未变。
18
+ - DSH 0.1.7-rc.2 官方包字节差异及社区升级卡(截至 rc.1)交叉核查完成;v1.9.0 和当前维护代码各通过 17 项真实官方服务集成检查,发布版 patch 另通过 4 项真实加载器检查。已核查接口无需适配修改;受控 provider 检查不等同于完整宿主 UI/模型端到端验证。
19
+ - 未闭合且含 href 的锚点 2.5 万/5 万/10 万输入本机约 27/57/118 ms;保留有界属性策略与零运行时依赖。四工具 description/schema 仍为 1,159/1,876 字符。
20
+
21
+ ## [1.9.0] - 2026-09-19
22
+
23
+ ### 新增
24
+
25
+ - 新增仅 profile 可配置的 `directFetchOrigins`,默认空列表。按协议、域名和端口精确匹配,命中后直接读取,显式跳过宿主 provider 及其 SSRF 检查;未命中的初始 URL 维持原有行为,不因任意 provider 错误自动直连。
26
+ - 例外链的 HTTP 重定向、meta-refresh、分页及爬取子页面均受完整列表约束;HTTP 跳转逐跳校验,每次尝试最多 5 跳并检测循环。例外不进入 curl 代理竞速或 SPA 渲染,失败边界/静态读取提示保留到工具输出。
27
+ - 成功和失败缓存按规范化后的完整配置隔离;非法配置明确报错,工具参数不允许扩大直连范围。双语 README 增补配置方法及信任边界。
28
+
29
+ ### 验证
30
+
31
+ - 322 个零依赖单元断言、15 个真实 Chromium SPA 断言、53 项对抗探针通过;新增 22 条直连策略检查与 3 条 SPA 限制检查。
32
+ - 受控 HTTP 服务与 provider 桩覆盖默认拒绝、显式直连、重定向、分页、四工具、缓存隔离、取消、超时及正文上限。
33
+ - 152 站实网回归:105 OK / 23 THIN+EMPTY / 24 ERR / 0 THREW,OK 站零噪声。Vue、React、酷狗三个已知 NOISE 站同输入 v1.8.1 对照的 text/Markdown 全文与 800 字符窗口逐字一致;ERR 均为网络、反爬、无效地址或预期不支持的类型。
34
+ - 四工具 description 共 1,159 字符,参数 schema 共 1,876 字符;零运行时依赖,新增正则有界。
35
+
3
36
  ## [1.8.1] - 2026-09-19
4
37
 
5
38
  ### 修复
package/README.md CHANGED
@@ -47,7 +47,7 @@ DSH agents can search (getting links and snippets) but lack the step of "reading
47
47
 
48
48
  Implemented per official docs (`docs/capability-seams.md`, `docs/cordis-primer.md`, `docs/tool-execution-pipeline.md`):
49
49
 
50
- 1. **Web access via the `ctx.web` capability seam** — use `ctx.web.fetch({ url }, signal)` and its decoded `body.kind/content` result for reads, redirects and crawl requests; fall back to direct retrieval when the service or provider is unavailable. Provider HTTP errors and policy decisions remain errors;
50
+ 1. **Web access via the `ctx.web` capability seam** — use `ctx.web.fetch({ url }, signal)` and its decoded `body.kind/content` result for reads, redirects and crawl requests; fall back to direct retrieval when the service or provider is unavailable. Provider HTTP errors and policy decisions remain errors. The optional `directFetchOrigins` profile setting selects explicit direct retrieval for trusted origins;
51
51
  2. **Reversible side effects** — the session cache is registered under `ctx.effect`, auto-cleared on plugin unload (temporal composability);
52
52
  3. **Cooperative tool-call timeout** — `ToolDefinition.timeoutMs` declares the budget, `execute(args, exec)` forwards `exec.signal` to fetch; the timeout policy is enforced by the pipeline, never exposed to the model;
53
53
  4. **Model-facing simplicity** — render emits compact text (`title:` header + body); the model consumes it directly with no JSON parsing. Defaults are the most token-efficient; structured output is opt-in;
@@ -181,6 +181,27 @@ Override via the profile's `cordis.patch.yml` (defaults in the plugin's own `cor
181
181
 
182
182
  Values are coerced and clamped to sane ranges at load — quoted numbers in YAML work, garbage falls back to defaults.
183
183
 
184
+ #### Explicit direct origins (v1.9.0)
185
+
186
+ `directFetchOrigins` defaults to `[]`. For a trusted local reverse proxy (for example, a GitHub hostname mapped by your hosts file), explicitly list the origins in the plugin's **profile configuration**, then reload the plugin/profile:
187
+
188
+ ```yaml
189
+ - id: dsh-read-url
190
+ config:
191
+ directFetchOrigins:
192
+ - https://github.com
193
+ - https://api.github.com
194
+ - https://raw.githubusercontent.com
195
+ ```
196
+
197
+ Listed origins use direct fetch immediately, bypassing the host web provider **and its SSRF checks**. Add only origins you trust; this also permits their DNS/hosts mappings to local or private addresses. The option is not a tool argument. Unlisted initial URLs keep the existing provider selection and fallback behavior; a provider refusal does not enable this exception automatically.
198
+
199
+ Matching is exact after URL normalization: scheme, hostname and port must match; subdomains are separate. At most 32 entries of 2,048 characters each are accepted. Only an HTTP(S) origin with an optional trailing `/` is valid: credentials, paths, queries, fragments, wildcards and ambiguous syntax cause a configuration error. Unlike numeric options, invalid entries do not fall back to defaults.
200
+
201
+ Once a request selects explicit direct retrieval, every subsequent HTTP redirect, meta-refresh, pagination request and descendant crawl stays within the list. Redirects handled internally by the provider remain on the provider path. Direct HTTP requests follow at most 5 redirects per attempt and stop loops early. An outside target is stopped before requesting it; refresh/pagination blocked by this policy retain the readable static result with a hint. Explicit direct reads use neither the plugin's curl proxy race nor SPA rendering, preventing those mechanisms from widening the exception. When `spaRender` is enabled and `read_url` or `read_url_links` would otherwise render a script-only page, they return a static-reading hint. Cancellation, response-size limits and one bounded Retry-After retry still apply. All four tools share the policy; success and failure caches are isolated by the complete normalized list, including after changing or clearing it.
202
+
203
+ Controlled HTTP servers and provider fixtures cover default refusals, explicit routing, redirects, pagination, all four tools, cache isolation, cancellation, timeouts and response-size limits.
204
+
184
205
  ### Output (compact)
185
206
 
186
207
  ```json
@@ -287,6 +308,21 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
287
308
  - SPA cancellation closes the active page; plugin disposal awaits browser shutdown. Valid success-cache entries take precedence over concurrent failures. Crawl filtering uses path boundaries. Charset and Markdown attributes use bounded parsing.
288
309
  - Fixed model input cost remains **1,159 description characters / 1,876 schema characters**, within the 1,250 / 2,000 budgets.
289
310
 
311
+ ### v1.9.0 validation (2026-09-19)
312
+
313
+ - **322 zero-dependency unit assertions, 15 real Chromium SPA assertions and 53 adversarial probes** passed. The suite includes 22 direct-origin policy checks and 3 SPA restriction checks.
314
+ - The 152-site sweep returned **105 OK / 23 THIN+EMPTY / 24 ERR / 0 THREW**, with no noise in OK results.
315
+ - Vue, React and Kugou's known NOISE cases matched v1.8.1 exactly using identical HTML and optional dependencies, in both text/Markdown and full/800-character outputs. Errors were network, anti-bot, invalid-address or expected content-type boundaries.
316
+ - All four tools enforce the explicit origin policy, including redirect/crawl boundaries, cache isolation and visible static-reading hints. Model input remains **1,159 description characters / 1,876 schema characters**, within the 1,250 / 2,000 budgets.
317
+
318
+ ### v1.9.1 maintenance validation (2026-09-28)
319
+
320
+ - **352 zero-dependency unit assertions, 15 real Chromium SPA assertions and 59 adversarial probes** passed. Navigation tests cover real attributes, quoted `>` characters, empty anchors, refresh entities and semicolons; cancellation, retry cleanup and the rendered site-summary limit have regression coverage. Asynchronous probes now await completion and report failures through the exit code.
321
+ - The final 152-site sweep returned **103 OK / 25 THIN+EMPTY / 24 ERR / 0 THREW**, with no noise in OK results. Vue, React, Kugou and Hatena matched v1.9.0 using identical HTML, including full continuation and 800-character outputs. Weibo's variable static/SPA results also matched on identical HTML/DOM snapshots; the error-site set was unchanged.
322
+ - Navigation uses bounded, quote-aware attribute scanning and advances through each anchor body once. Runs with 25k/50k/100k unclosed anchors containing `href` took about **27/57/118 ms** on this machine. The existing 1,000-character attribute boundary remains in place.
323
+ - [DSH 0.1.7-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-rc.2) compatibility was checked against official npm package byte differences and community upgrade notes (available through rc.1). Both published v1.9.0 and this maintenance code passed **17 actual official-service integration checks each**; the published patch passed **4 loader checks** covering loading, execution, configuration remount and removal. Web-provider/runtime contracts remain compatible; no adaptation change is needed for the checked interfaces. These checks used controlled provider responses, not a complete host UI/model session.
324
+ - Core runtime dependencies remain zero. Model input stays at **1,159 description characters / 1,876 schema characters**; site body summaries remain opt-in and respect `maxCharsPerPage` without an extra 80-character render cutoff.
325
+
290
326
  ## Roadmap
291
327
 
292
328
  - [x] Single-page continuation (`offset` parameter)
package/README.zh.md CHANGED
@@ -47,7 +47,7 @@ DSH 的 Agent 能搜索(返回链接和片段),但缺"把 URL 读成干净
47
47
 
48
48
  按官方文档实现(`docs/capability-seams.md`、`docs/cordis-primer.md`、`docs/tool-execution-pipeline.md`):
49
49
 
50
- 1. **网络访问走 `ctx.web` 能力缝**——读取、跳转与爬取使用 `ctx.web.fetch({ url }, signal)` 及解码后的 `body.kind/content`;服务或提供方不可用时回退直连。提供方 HTTP 错误与策略拒绝保留为错误;
50
+ 1. **网络访问走 `ctx.web` 能力缝**——读取、跳转与爬取使用 `ctx.web.fetch({ url }, signal)` 及解码后的 `body.kind/content`;服务或提供方不可用时回退直连。提供方 HTTP 错误与策略拒绝保留为错误。可选的 profile 配置 `directFetchOrigins` 为可信来源选择显式直连;
51
51
  2. **可逆副作用**——会话缓存注册在 `ctx.effect` 下,插件卸载即自动清理(时间可组合性);
52
52
  3. **协作式工具调用超时**——`ToolDefinition.timeoutMs` 声明预算,`execute(args, exec)` 把 `exec.signal` 转发给 fetch,超时策略由管线强制执行,不把超时暴露给模型;
53
53
  4. **模型视角精简**——render 输出紧凑文本(`title:` 头部 + 正文),模型直接消费,无需解析 JSON;默认参数最省 token,结构化能力按需开启;
@@ -181,6 +181,27 @@ chars 800+800/12398 · cached
181
181
 
182
182
  配置在加载时统一强转数字并钳制到合理范围——YAML 里加引号的数字也能用,非法值回退默认。
183
183
 
184
+ #### 显式直连来源(v1.9.0)
185
+
186
+ `directFetchOrigins` 默认为 `[]`。对于可信的本地反向代理(例如通过 hosts 文件映射 GitHub 域名),在插件的 **profile 配置**中显式列出来源,再重载插件/profile:
187
+
188
+ ```yaml
189
+ - id: dsh-read-url
190
+ config:
191
+ directFetchOrigins:
192
+ - https://github.com
193
+ - https://api.github.com
194
+ - https://raw.githubusercontent.com
195
+ ```
196
+
197
+ 命中的来源立即直连,跳过宿主 web provider **及其 SSRF 检查**。仅添加你信任的来源;其 DNS/hosts 映射到本机或私有地址也会被允许。此选项不开放为工具参数。未列出的初始 URL 保留既有 provider 选择与回退行为;provider 拒绝不会自动启用此例外。
198
+
199
+ 按 URL 规范化后的协议、域名、端口精确匹配,子域名需单独配置。最多 32 项,每项不超过 2,048 字符。仅接受 HTTP(S) origin,可带一个结尾 `/`;凭据、路径、查询、片段、通配符和歧义写法都会触发配置错误。此选项与数字选项不同,非法项不会静默回退默认值。
200
+
201
+ 请求选用显式直连后,后续 HTTP 重定向、meta-refresh、分页及爬取子页面均限制在列表内;provider 内部处理的跳转仍属于 provider 路径。直连每次尝试最多跟随 5 次 HTTP 重定向,遇循环提前停止。越界目标在发请求前停止;被此策略阻止的刷新跳转或分页保留可读的静态结果并附提示。显式直连不使用插件的 curl 代理竞速或 SPA 渲染,防止它们扩大例外范围;启用 `spaRender` 且 `read_url` 或 `read_url_links` 原本需要渲染脚本空壳时,会提示仅能静态读取。取消、正文大小限制及一次有界 Retry-After 重试继续生效。四个工具统一遵守此策略;成功与失败缓存按完整规范化列表隔离,修改或清空列表后不会误用旧策略的结果。
202
+
203
+ 受控 HTTP 服务与 provider 桩覆盖默认拒绝、显式直连、重定向、分页、四工具、缓存隔离、取消、超时及正文上限。
204
+
184
205
  ### 输出结构(紧凑)
185
206
 
186
207
  ```json
@@ -287,6 +308,21 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
287
308
  - SPA 取消关闭当前页面,插件卸载等待浏览器关闭;有效成功缓存优先于并发失败;爬取过滤遵循路径边界;编码与 Markdown 属性使用有界解析。
288
309
  - 模型固定输入成本仍为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
289
310
 
311
+ ### v1.9.0 验证(2026-09-19)
312
+
313
+ - **322 个零依赖单元断言、15 个真实 Chromium SPA 断言、53 项对抗探针**通过;包括 22 条直连来源策略检查与 3 条 SPA 限制检查。
314
+ - 152 站实网结果为 **105 OK / 23 THIN+EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。
315
+ - Vue、React、酷狗三个已知 NOISE 站采用同份 HTML 与相同可选依赖对照 v1.8.1,text/Markdown 全文及 800 字符窗口逐字一致;ERR 均为网络、反爬、无效地址或预期不支持的类型。
316
+ - 四个工具统一遵守显式来源策略,覆盖跳转与爬取边界、缓存隔离及可见的静态读取提示。模型固定输入成本为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
317
+
318
+ ### v1.9.1 维护验证(2026-09-28)
319
+
320
+ - **352 个零依赖单元断言、15 个真实 Chromium SPA 断言、59 项对抗探针**通过。导航测试覆盖真实属性、引号内的 `>`、空锚点、刷新实体和分号;取消、重试清理及站点摘要渲染上限均有回归覆盖。异步探针现在等待完成,并通过退出码报告失败。
321
+ - 152 站最终结果为 **103 OK / 25 THIN+EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。Vue、React、酷狗与 Hatena 使用同份 HTML 对照 v1.9.0,全文续读及 800 字输出一致;微博静态/SPA 波动也经同份 HTML/DOM 对照一致,错误站点集合未变。
322
+ - 导航采用有界、识别引号的属性扫描,每段锚点正文只扫描一次。本机 2.5 万/5 万/10 万个含 `href` 的未闭合锚点耗时约 **27/57/118 ms**,保留原有 1,000 字符属性边界。
323
+ - [DSH 0.1.7-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-rc.2) 已完成官方 npm 接触包字节差异核查,并与社区升级卡交叉验证(卡片目前到 rc.1)。发布版 v1.9.0 与当前维护代码各通过 **17 项真实官方服务集成检查**;发布版 patch 另通过 **4 项加载器检查**,覆盖加载、调用、配置重挂和卸载。Web provider/runtime 契约兼容,已核查接口无需适配修改;测试使用受控 provider 响应,未覆盖完整宿主 UI/模型会话。
324
+ - 核心仍为零运行时依赖,模型固定输入仍为 **description 1,159 字符 / schema 1,876 字符**;站点正文摘要仍默认关闭,启用后遵循 `maxCharsPerPage`,不再额外截成 80 字符。
325
+
290
326
  ## Roadmap
291
327
 
292
328
  - [x] 单页多段续读(`offset` 参数)
package/cordis.patch.yml CHANGED
@@ -11,4 +11,5 @@
11
11
  spaRender: true
12
12
  paginate: true
13
13
  paginateMax: 3
14
+ directFetchOrigins: []
14
15
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
package/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // Zero external dependencies (Node 20+ built-ins only).
3
3
  // Aligned with DSH architecture:
4
4
  // - all network access goes through the ctx.web capability seam (official
5
- // docs/capability-seams.md), falling back to global fetch when absent
5
+ // docs/capability-seams.md), except explicit directFetchOrigins; falls
6
+ // back to global fetch when absent
6
7
  // - cache is registered under ctx.effect, so unload fully reverts it
7
8
  // (temporal composability, docs/cordis-primer.md)
8
9
  // - cooperative tool-call timeout via ToolDefinition.timeoutMs + exec.signal
@@ -33,7 +34,8 @@ const DEFAULTS = {
33
34
  cacheMax: 32,
34
35
  spaRender: true,
35
36
  paginate: true,
36
- paginateMax: 3,
37
+ paginateMax: 3,
38
+ directFetchOrigins: [],
37
39
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
38
40
  }
39
41
 
@@ -51,7 +53,52 @@ function cacheStore(key, full, cacheMax) {
51
53
  cache.set(key, { time: Date.now(), full })
52
54
  failCache.delete(key)
53
55
  }
54
- const decoders = new Map()
56
+ const decoders = new Map()
57
+
58
+ // Owner configuration only: tool arguments cannot widen this trust boundary.
59
+ // Preserve scheme/port, reject ambiguous origin syntax, and fail closed on
60
+ // invalid configuration instead of silently accepting a partial allowlist.
61
+ function directOrigins(values = []) {
62
+ const invalid = () => new Error('Invalid directFetchOrigins: expected at most 32 exact http(s) origins without credentials, paths, queries, fragments or wildcards')
63
+ if (!Array.isArray(values) || values.length > 32) throw invalid()
64
+ const origins = new Set()
65
+ for (const value of values) {
66
+ if (typeof value !== 'string' || value.length > 2048) throw invalid()
67
+ const raw = value.trim()
68
+ let u
69
+ try { u = new URL(raw) } catch { throw invalid() }
70
+ if (!raw.toLowerCase().startsWith(`${u.protocol}//`) ||
71
+ !['http:', 'https:'].includes(u.protocol) || u.username || u.password ||
72
+ u.pathname !== '/' || raw.includes('?') || raw.includes('#') ||
73
+ raw.includes('*') || raw.includes('\\') || raw.includes('@') ||
74
+ [...raw].some(c => c.charCodeAt(0) <= 32 || c.charCodeAt(0) === 127)) throw invalid()
75
+ // Do not accept URL parser repairs such as /a/.. or percent escapes in
76
+ // hostnames. Only an authority and one optional trailing slash are valid.
77
+ const authority = raw.slice(raw.indexOf('://') + 3)
78
+ if (authority.includes('%') || authority.replace(/\/$/, '').includes('/')) throw invalid()
79
+ origins.add(u.origin)
80
+ }
81
+ return [...origins].sort()
82
+ }
83
+
84
+ function createRoute(cfg) {
85
+ const origins = directOrigins(cfg.directFetchOrigins)
86
+ return { origins: new Set(origins), key: JSON.stringify(origins), restricted: false, hint: '' }
87
+ }
88
+
89
+ function matchesDirectOrigin(url, origins) {
90
+ try { return origins.has(new URL(url).origin) } catch { return false }
91
+ }
92
+
93
+ function allowedDirectUrl(url, origins) {
94
+ try {
95
+ const u = new URL(url)
96
+ return !u.username && !u.password && origins.has(u.origin) && ['http:', 'https:'].includes(u.protocol)
97
+ } catch { return false }
98
+ }
99
+
100
+ const DIRECT_BOUNDARY_ERROR = 'Direct fetch stopped: target is outside directFetchOrigins or contains credentials'
101
+ const DIRECT_SPA_HINT = '已配置直连仅支持静态读取,未启用浏览器渲染'
55
102
 
56
103
  function getDecoder(enc) {
57
104
  let d = decoders.get(enc)
@@ -124,13 +171,31 @@ function retryAfterMs(res) {
124
171
  }
125
172
 
126
173
  // Single fetch attempt. Never throws; maps abort/timeout/network to { error }.
127
- async function directFetchOnce(url, signal, cfg) {
128
- try {
129
- const res = await fetch(url, {
130
- redirect: 'follow',
131
- signal,
132
- headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
133
- })
174
+ async function directFetchOnce(url, signal, cfg, origins) {
175
+ try {
176
+ let currentUrl = url
177
+ let res
178
+ const seen = new Set()
179
+ for (let hops = 0; ; hops++) {
180
+ if (signal && signal.aborted) return { error: 'cancelled' }
181
+ if (origins && !allowedDirectUrl(currentUrl, origins)) return { error: DIRECT_BOUNDARY_ERROR }
182
+ if (origins) {
183
+ const normalized = normalizeUrl(currentUrl)
184
+ if (seen.has(normalized)) return { error: 'Direct fetch redirect loop' }
185
+ seen.add(normalized)
186
+ }
187
+ res = await fetch(currentUrl, {
188
+ redirect: origins ? 'manual' : 'follow',
189
+ signal,
190
+ headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
191
+ })
192
+ if (!origins || ![301, 302, 303, 307, 308].includes(res.status)) break
193
+ if (res.body) res.body.cancel().catch(() => {})
194
+ const location = res.headers.get('location')
195
+ if (!location) return { error: `HTTP ${res.status} without Location` }
196
+ if (hops >= 5) return { error: 'Direct fetch exceeded 5 redirects' }
197
+ currentUrl = new URL(location, currentUrl).href
198
+ }
134
199
  // Early-return paths must cancel the body stream, or the connection is
135
200
  // held open undrained and cannot return to the keep-alive pool.
136
201
  const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
@@ -150,7 +215,7 @@ async function directFetchOnce(url, signal, cfg) {
150
215
  discardBody()
151
216
  return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
152
217
  }
153
- if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url }
218
+ if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url || currentUrl }
154
219
  const chunks = []
155
220
  let size = 0
156
221
  for await (const chunk of res.body) {
@@ -163,7 +228,7 @@ async function directFetchOnce(url, signal, cfg) {
163
228
  if (!contentType && looksBinary(buffer)) {
164
229
  return { error: 'Unsupported content-type (no header, binary body)' }
165
230
  }
166
- return { buffer, contentType, finalUrl: res.url }
231
+ return { buffer, contentType, finalUrl: res.url || currentUrl }
167
232
  } catch (e) {
168
233
  if (e.name === 'AbortError' || e.name === 'TimeoutError') {
169
234
  return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
@@ -180,15 +245,24 @@ async function directFetchOnce(url, signal, cfg) {
180
245
  // Exported for tests: the double-throttle path must always produce an { error }
181
246
  // shape — a second `{ retryAfterMs }` would otherwise leak past the race
182
247
  // filter into decodeBuffer as a bufferless "success" (bare TypeError there).
183
- export async function directFetch(url, signal, cfg) {
184
- const first = await directFetchOnce(url, signal, cfg)
185
- if (first && first.retryAfterMs !== undefined && !(signal && signal.aborted)) {
248
+ export async function directFetch(url, signal, cfg, origins) {
249
+ const first = await directFetchOnce(url, signal, cfg, origins)
250
+ if (first && first.retryAfterMs !== undefined) {
251
+ if (signal && signal.aborted) return { error: 'cancelled' }
186
252
  await new Promise((resolve) => {
187
- const t = setTimeout(resolve, first.retryAfterMs)
188
- if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
253
+ const done = () => {
254
+ clearTimeout(timer)
255
+ if (signal) signal.removeEventListener('abort', done)
256
+ resolve()
257
+ }
258
+ const timer = setTimeout(done, first.retryAfterMs)
259
+ if (signal) {
260
+ signal.addEventListener('abort', done, { once: true })
261
+ if (signal.aborted) done()
262
+ }
189
263
  })
190
264
  if (signal && signal.aborted) return { error: 'cancelled' }
191
- const second = await directFetchOnce(url, signal, cfg)
265
+ const second = await directFetchOnce(url, signal, cfg, origins)
192
266
  // The retry budget is spent — report the rate limit instead of returning
193
267
  // a shapeless { retryAfterMs } (no buffer, no error) to the caller.
194
268
  if (second && second.retryAfterMs !== undefined) {
@@ -309,6 +383,27 @@ async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
309
383
  return { error: `Web provider failed: ${String((e && e.message) || e).slice(0, 160)}` }
310
384
  }
311
385
  }
386
+
387
+ // Every fetch in a tool call passes here. Once a chain enters an explicit
388
+ // direct origin it stays restricted, including meta refresh and pagination.
389
+ // Never send an exception through curl -L or a browser: both may make further
390
+ // requests outside this origin list. Unlisted initial URLs retain host policy.
391
+ async function fetchResource(ctx, url, externalSignal, cfg, route) {
392
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
393
+ if (route.restricted || matchesDirectOrigin(url, route.origins)) {
394
+ route.restricted = true
395
+ if (!allowedDirectUrl(url, route.origins)) {
396
+ route.hint = DIRECT_BOUNDARY_ERROR
397
+ return { error: DIRECT_BOUNDARY_ERROR }
398
+ }
399
+ const timeout = AbortSignal.timeout(cfg.timeoutMs)
400
+ const signal = externalSignal ? AbortSignal.any([externalSignal, timeout]) : timeout
401
+ const page = await directFetch(url, signal, cfg, route.origins)
402
+ if (page.error) route.hint = page.error
403
+ return page
404
+ }
405
+ return await fetchViaWebSeam(ctx, url, externalSignal, cfg) || await fetchPage(url, externalSignal, cfg)
406
+ }
312
407
 
313
408
  // Decode common HTML entities in already-stripped text. Runs AFTER tag
314
409
  // stripping so escaped tags stay visible to the stripper and only remaining
@@ -1341,22 +1436,60 @@ function decodeUrlAttribute(value) {
1341
1436
  }
1342
1437
 
1343
1438
  // <base href> overrides the document base for relative URL resolution (old
1344
- // forums / frame sites). Per HTML spec only the FIRST <base> in <head> counts;
1439
+ // forums / frame sites). Only the FIRST <base href> in <head> counts;
1345
1440
  // attribute order is arbitrary and the href may be protocol-relative. Empty
1346
1441
  // href (= document URL) is a no-op; non-http(s) targets are rejected. The
1347
1442
  // head scan is bounded — the tag cannot legally appear beyond it.
1348
1443
  function detectBaseHref(html, finalUrl) {
1349
1444
  const headEnd = html.search(/<\/head\s*>/i)
1350
1445
  const head = headEnd >= 0 ? html.slice(0, headEnd) : html.slice(0, 16384)
1351
- const base = /<base\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>/i.exec(head)
1352
- if (!base) return finalUrl
1353
- const href = decodeUrlAttribute(base[1]).trim()
1354
- if (!href) return finalUrl
1355
- try {
1356
- const u = new URL(href, finalUrl)
1357
- return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : finalUrl
1358
- } catch {
1359
- return finalUrl
1446
+ for (const { attrs } of navigationTags(head, /<base(?=[\s/>])/gi)) {
1447
+ if (!Object.hasOwn(attrs, 'href')) continue
1448
+ const href = decodeUrlAttribute(attrs.href).trim()
1449
+ if (!href) return finalUrl
1450
+ try {
1451
+ const u = new URL(href, finalUrl)
1452
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : finalUrl
1453
+ } catch {
1454
+ return finalUrl
1455
+ }
1456
+ }
1457
+ return finalUrl
1458
+ }
1459
+
1460
+ // Honor quoted '>' without nested regex alternatives. Keep the same total
1461
+ // tag-length bound as htmlAttributes; malformed/overlong tags stay inert.
1462
+ function* navigationTags(html, open) {
1463
+ let m
1464
+ while ((m = open.exec(html))) {
1465
+ let quote = '', end = -1
1466
+ const limit = Math.min(html.length, m.index + 1002)
1467
+ for (let i = open.lastIndex; i < limit; i++) {
1468
+ const ch = html[i]
1469
+ if (quote) {
1470
+ if (ch === quote) quote = ''
1471
+ } else if (ch === '"' || ch === "'") quote = ch
1472
+ else if (ch === '<') break
1473
+ else if (ch === '>') { end = i + 1; break }
1474
+ }
1475
+ if (end < 0) continue
1476
+ open.lastIndex = end
1477
+ yield { attrs: htmlAttributes(html.slice(m.index, end)), end }
1478
+ }
1479
+ }
1480
+
1481
+ // Scan each anchor body once; tags without a real href cannot navigate.
1482
+ function* navigationAnchors(html) {
1483
+ const open = /<a(?=[\s>])/gi
1484
+ const close = /<\/a\s{0,1000}>/gi
1485
+ for (const { attrs, end: start } of navigationTags(html, open)) {
1486
+ if (!attrs.href?.trim()) continue
1487
+ close.lastIndex = start
1488
+ const end = close.exec(html)
1489
+ if (!end) break
1490
+ const body = html.slice(start, end.index)
1491
+ open.lastIndex = close.lastIndex
1492
+ yield { attrs, body }
1360
1493
  }
1361
1494
  }
1362
1495
 
@@ -1368,18 +1501,14 @@ function extractLinks(html, limit, baseUrl) {
1368
1501
  // A declared <base href> wins over the document URL (frame-site links would
1369
1502
  // otherwise land on the wrong host).
1370
1503
  const linkBase = detectBaseHref(html, baseUrl)
1371
- // Attribute runs bounded: an unclosed <a whose '>' is far away (or missing)
1372
- // must not make the regex scan the rest of the string at every anchor
1373
- // position. Real attributes never approach 1000 chars.
1374
- const re = /<a\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>([\s\S]*?)<\/a\s*>/gi
1375
- let m
1376
1504
  // Iteration cap: a page whose nav repeats the same links thousands of times
1377
1505
  // would otherwise scan the whole HTML to fill `limit` unique entries. Scan
1378
1506
  // at most limit*4 anchors; dedupe keeps the result bounded regardless.
1379
1507
  let scans = 0
1380
1508
  const maxScans = limit * 4
1381
- while ((m = re.exec(html)) && links.length < limit && scans++ < maxScans) {
1382
- const href = decodeUrlAttribute(m[1]).trim()
1509
+ for (const { attrs, body } of navigationAnchors(html)) {
1510
+ if (links.length >= limit || scans++ >= maxScans) break
1511
+ const href = decodeUrlAttribute(attrs.href).trim()
1383
1512
  if (!href || /^(javascript|mailto|tel|data):/i.test(href)) continue
1384
1513
  let url
1385
1514
  try {
@@ -1395,7 +1524,7 @@ function extractLinks(html, limit, baseUrl) {
1395
1524
  // keeps the link list compact (tokens) without losing coverage
1396
1525
  if (seen.has(url)) continue
1397
1526
  seen.add(url)
1398
- const t = textOnly(m[2])
1527
+ const t = textOnly(body)
1399
1528
  if (t) links.push({ title: t.slice(0, 80), url })
1400
1529
  }
1401
1530
  return links
@@ -1421,6 +1550,7 @@ export function findNextLink(html, baseUrl) {
1421
1550
  // document base, and a "next page" link resolves against it.
1422
1551
  const base = detectBaseHref(html || '', baseUrl)
1423
1552
  const resolve = (href) => {
1553
+ if (!href?.trim()) return null
1424
1554
  try {
1425
1555
  const u = new URL(decodeUrlAttribute(href), base)
1426
1556
  return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null
@@ -1428,21 +1558,20 @@ export function findNextLink(html, baseUrl) {
1428
1558
  return null
1429
1559
  }
1430
1560
  }
1431
- const rel = /<(?:link|a)\b[^>]{0,1000}rel=["']next["'][^>]{0,1000}>/i.exec(html || '')
1432
- if (rel) {
1433
- const href = /href=["']([^"']+)["']/i.exec(rel[0])
1434
- const r = href && resolve(href[1])
1561
+ for (const { attrs } of navigationTags(html || '', /<(?:link|a)(?=[\s/>])/gi)) {
1562
+ const rel = decodeUrlAttribute(attrs.rel || '').toLowerCase().split(/[ \t\r\n\f]{1,1000}/)
1563
+ if (!rel.includes('next')) continue
1564
+ const r = resolve(attrs.href)
1435
1565
  if (r) return r
1436
1566
  }
1437
- const re = /<a\b[^>]{0,1000}href=["']([^"']+)["'][^>]{0,1000}>([\s\S]*?)<\/a\s*>/gi
1438
- let m
1439
1567
  let scans = 0
1440
1568
  // Bounded like extractLinks: a nav bar with thousands of anchors must not
1441
1569
  // scan the whole DOM hunting for a next-marker.
1442
- while ((m = re.exec(html || '')) && scans++ < 400) {
1443
- const t = textOnly(m[2])
1570
+ for (const { attrs, body } of navigationAnchors(html || '')) {
1571
+ if (scans++ >= 400) break
1572
+ const t = textOnly(body)
1444
1573
  if (t.length <= 16 && NEXT_TEXT_RE.test(t)) {
1445
- const r = resolve(m[1])
1574
+ const r = resolve(attrs.href)
1446
1575
  if (r) return r
1447
1576
  }
1448
1577
  }
@@ -1535,18 +1664,15 @@ export function metaRefreshTarget(html, baseUrl) {
1535
1664
  // Locate the refresh <meta> tag first (any attribute order / case), then
1536
1665
  // read content from inside that tag — a forward regex would demand
1537
1666
  // http-equiv before content and miss the reversed order.
1538
- const re = /<meta\b[^>]{0,1000}(?:http-equiv|name)=["']refresh["'][^>]{0,1000}>/gi
1539
- let m
1540
- while ((m = re.exec(html || ''))) {
1541
- const tag = m[0]
1542
- const contentM = /content=("([^"]*)"|'([^']*)')/i.exec(tag)
1543
- if (!contentM) continue // a refresh meta without content is inert
1544
- const content = (contentM[2] || contentM[3] || '').trim()
1667
+ for (const { attrs } of navigationTags(html || '', /<meta(?=[\s/>])/gi)) {
1668
+ if (![attrs['http-equiv'], attrs.name].some(value => value?.toLowerCase() === 'refresh')) continue
1669
+ if (!Object.hasOwn(attrs, 'content')) continue
1670
+ const content = decodeUrlAttribute(attrs.content).trim()
1545
1671
  // content="5" | content="0; url=http://…" | content=";url='…'" — the url
1546
1672
  // part may be single/double quoted or bare; "url=" must be a real token.
1547
- const urlM = /\burl\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s;]+))/i.exec(content)
1673
+ const urlM = /\burl\s{0,1000}=\s{0,1000}(?:"([^"]{0,1000})"|'([^']{0,1000})'|([^\s]{1,1000}))/i.exec(content)
1548
1674
  if (!urlM) return null
1549
- const delayM = /^\s*(\d+(?:\.\d+)?)/.exec(content)
1675
+ const delayM = /^\s{0,1000}(\d{1,1000}(?:\.\d{1,1000})?)/.exec(content)
1550
1676
  if (delayM && Number(delayM[1]) > 0) return null // timed refresh: keep the page
1551
1677
  const target = (urlM[1] || urlM[2] || urlM[3] || '').trim()
1552
1678
  if (!target) return null
@@ -1567,7 +1693,7 @@ export function metaRefreshTarget(html, baseUrl) {
1567
1693
  // cache key is the ORIGINAL url, and a shell re-request should re-follow
1568
1694
  // rather than serve a foreign page.
1569
1695
  const MAX_META_REFRESH_HOPS = 3
1570
- async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
1696
+ async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx, route) {
1571
1697
  let cur = html
1572
1698
  let curUrl = finalUrl
1573
1699
  let curCharset = '' // set only when a hop actually happened
@@ -1586,7 +1712,7 @@ async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
1586
1712
  const norm = normalizeUrl(target)
1587
1713
  if (!norm || seen.has(norm)) break
1588
1714
  seen.add(norm)
1589
- const page = await fetchViaWebSeam(ctx, target, hopSignal, cfg) || await fetchPage(target, hopSignal, cfg)
1715
+ const page = await fetchResource(ctx, target, hopSignal, cfg, route)
1590
1716
  if (page.error) break
1591
1717
  const ct = page.kind === 'html' ? 'text/html' : (page.contentType || '').split(';')[0].toLowerCase().trim()
1592
1718
  if (!/html|xhtml/.test(ct)) break
@@ -1603,6 +1729,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1603
1729
  const url = String((args && args.url) || '').trim()
1604
1730
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1605
1731
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1732
+ let route
1733
+ try { route = createRoute(cfg) } catch (e) { return { error: e.message } }
1606
1734
  const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
1607
1735
  const mode = args.mode === 'markdown' ? 'markdown' : 'text'
1608
1736
  const offset = Math.max(0, Number(args.offset) || 0)
@@ -1632,7 +1760,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1632
1760
  } catch {
1633
1761
  /* keep bare url */
1634
1762
  }
1635
- const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1763
+ // Include the complete policy: an unlisted entry may reach a listed origin
1764
+ // through refresh/pagination, and must not reuse content after revocation.
1765
+ const cacheKey = `${route.key}|${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1636
1766
  const hit = cache.get(cacheKey)
1637
1767
  if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
1638
1768
  // Valid content takes precedence over a concurrent fetch failure.
@@ -1700,9 +1830,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1700
1830
  }
1701
1831
  }
1702
1832
  const dispatch = async () => {
1703
- const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1704
- const page = viaSeam || await fetchPage(url, externalSignal, cfg)
1833
+ const page = await fetchResource(ctx, url, externalSignal, cfg, route)
1705
1834
  if (page.error) return { error: page.error }
1835
+ const viaSeam = typeof page.html === 'string'
1706
1836
  const ct = viaSeam ? (page.kind === 'text' ? 'text/plain' : 'text/html') : (page.contentType || '').split(';')[0].toLowerCase().trim()
1707
1837
  const decoded = viaSeam ? { text: page.html, charset: 'provider-decoded' } : decodeBuffer(page.buffer, page.contentType)
1708
1838
  const meta = { finalUrl: page.finalUrl || url, sourceTruncated: page.sourceTruncated === true }
@@ -1782,7 +1912,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1782
1912
  // follow immediately, BEFORE extraction and SPA rendering — a shell that
1783
1913
  // redirects needs no headless round-trip, and extracting the stub is wasted
1784
1914
  // work. Fails open on any fetch problem; the follow is hop-bounded.
1785
- const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx)
1915
+ const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx, route)
1786
1916
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1787
1917
  html = followed.html
1788
1918
  finalUrl = followed.finalUrl
@@ -1826,7 +1956,10 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1826
1956
  let renderedHtml = null
1827
1957
  const needsRender = !extracted.text || extracted.text.length < 200
1828
1958
  const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
1829
- if (cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
1959
+ if (route.restricted && cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
1960
+ spaHint = [spaHint, DIRECT_SPA_HINT].filter(Boolean).join(';')
1961
+ }
1962
+ if (!route.restricted && cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
1830
1963
  const rr = await renderPage(finalUrl || url, externalSignal)
1831
1964
  if (rr.html) {
1832
1965
  renderedHtml = rr.html
@@ -1882,7 +2015,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1882
2015
  const nu = normalizeUrl(nextUrl)
1883
2016
  if (!nu || seen.has(nu)) break
1884
2017
  seen.add(nu)
1885
- const page = await fetchViaWebSeam(ctx, nextUrl, externalSignal, cfg) || await fetchPage(nextUrl, externalSignal, cfg)
2018
+ const page = await fetchResource(ctx, nextUrl, externalSignal, cfg, route)
1886
2019
  if (page.error) break
1887
2020
  const pageFinal = normalizeUrl(page.finalUrl || nextUrl)
1888
2021
  if (!pageFinal || !sameHost(pageFinal, startHost) || (pageFinal !== nu && seen.has(pageFinal))) break
@@ -1898,7 +2031,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1898
2031
  nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1899
2032
  }
1900
2033
 
1901
- // A near-empty body (login wall, anti-bot shell) with no render hint yet
2034
+ if (route.hint) spaHint = [spaHint, route.hint].filter(Boolean).join(';')
2035
+
2036
+ // A near-empty body (login wall, anti-bot shell) with no render hint yet
1902
2037
  // still looks like "the page content" to the model — one constant-cost line
1903
2038
  // tells it not to keep offset-reading a 40-char page.
1904
2039
  if (!spaHint && extracted.text && extracted.text.length < 60) {
@@ -1992,8 +2127,9 @@ function renderLinks(value) {
1992
2127
  if (typeof value === 'string') return value
1993
2128
  const r = value || {}
1994
2129
  if (r.error) return `Error: ${r.error}`
1995
- if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}`
1996
- const lines = [`${r.count} link(s) on ${r.url}:`, UNTRUSTED_NOTICE]
2130
+ if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}${r.spaHint ? `\n提示: ${r.spaHint}` : ''}`
2131
+ const lines = [`${r.count} link(s) on ${r.url}:`, UNTRUSTED_NOTICE]
2132
+ if (r.spaHint) lines.push(`提示: ${r.spaHint}`)
1997
2133
  for (const l of r.links) lines.push(`- ${l.title || l.url} — ${l.url}`)
1998
2134
  return lines.join('\n')
1999
2135
  }
@@ -2033,14 +2169,19 @@ function readLinksTool(ctx, cfg) {
2033
2169
  async execute(args, exec) {
2034
2170
  const url = String((args && args.url) || '').trim()
2035
2171
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
2036
- const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
2037
- const page = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg) || await fetchPage(url, exec && exec.signal, cfg)
2172
+ const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
2173
+ const signal = exec && exec.signal
2174
+ if (signal && signal.aborted) return { error: 'cancelled' }
2175
+ const route = createRoute(cfg)
2176
+ const page = await fetchResource(ctx, url, signal, cfg, route)
2177
+ if (signal && signal.aborted) return { error: 'cancelled' }
2038
2178
  if (page.error) return { error: page.error }
2039
2179
  let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2040
2180
  let finalUrl = page.finalUrl || url
2041
2181
  // Same meta-refresh shell following as read_url: a link hop serves a
2042
2182
  // stub whose real links live at the target. Fails open, hop-bounded.
2043
- const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg, ctx)
2183
+ const followed = await followMetaRefresh(html, finalUrl, signal, cfg, ctx, route)
2184
+ if (signal && signal.aborted) return { error: 'cancelled' }
2044
2185
  html = followed.html
2045
2186
  finalUrl = followed.finalUrl
2046
2187
  let links = extractLinks(html, limit, finalUrl)
@@ -2048,8 +2189,10 @@ function readLinksTool(ctx, cfg) {
2048
2189
  // render it and re-extract when the static result looks empty. Any
2049
2190
  // <script> counts as renderable when there are no links at all — the
2050
2191
  // static shell may be a JS-redirect (same rationale as read_url).
2051
- if (links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))) {
2052
- const rr = await renderPage(finalUrl || url, exec && exec.signal)
2192
+ const needsRender = links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))
2193
+ if (!route.restricted && needsRender) {
2194
+ const rr = await renderPage(finalUrl || url, signal)
2195
+ if (signal && signal.aborted) return { error: 'cancelled' }
2053
2196
  // A challenge interstitial's links are the challenge's own scripts —
2054
2197
  // never an improvement over whatever the static HTML offered.
2055
2198
  if (rr.html && !looksLikeChallenge(rr.html)) {
@@ -2060,7 +2203,8 @@ function readLinksTool(ctx, cfg) {
2060
2203
  }
2061
2204
  }
2062
2205
  }
2063
- return { url: finalUrl, count: links.length, links }
2206
+ const spaHint = [route.hint, route.restricted && needsRender ? DIRECT_SPA_HINT : ''].filter(Boolean).join(';')
2207
+ return { url: finalUrl, count: links.length, links, ...(spaHint ? { spaHint } : {}) }
2064
2208
  },
2065
2209
  }
2066
2210
  }
@@ -2091,7 +2235,8 @@ function renderBatch(value) {
2091
2235
  continue
2092
2236
  }
2093
2237
  const head = p.title || p.url
2094
- lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
2238
+ lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
2239
+ if (p.spaHint) lines.push(`提示: ${p.spaHint}`)
2095
2240
  if (Array.isArray(p.links) && p.links.length) {
2096
2241
  const ls = p.links.map((l) => l.title + ' — ' + l.url)
2097
2242
  lines.push(`links: ${ls.slice(0, 6).join(' | ')}${ls.length > 6 ? ` …共${ls.length}个` : ''}`)
@@ -2181,7 +2326,8 @@ function readUrlBatchTool(ctx, cfg) {
2181
2326
  cached: p.cached === true,
2182
2327
  text: p.text,
2183
2328
  }
2184
- if (Array.isArray(p.links) && p.links.length) out.links = p.links
2329
+ if (Array.isArray(p.links) && p.links.length) out.links = p.links
2330
+ if (p.spaHint) out.spaHint = p.spaHint
2185
2331
  return out
2186
2332
  }),
2187
2333
  }
@@ -2226,7 +2372,8 @@ function isNoiseUrl(url) {
2226
2372
  // failure is recorded and does not abort the crawl. No SPA rendering here —
2227
2373
  // that is read_url's job; crawling favors speed and breadth.
2228
2374
  async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2229
- const { maxPages, maxDepth, includeContent, perMax } = opts
2375
+ const { maxPages, maxDepth, includeContent, perMax } = opts
2376
+ const policy = createRoute(cfg)
2230
2377
  let host = hostOf(entryUrl)
2231
2378
  const visited = new Set()
2232
2379
  const resolved = new Set()
@@ -2240,12 +2387,14 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2240
2387
  // Never overshoot the page budget within a batch round.
2241
2388
  const batch = queue.splice(0, Math.min(2, maxPages - attempted, queue.length))
2242
2389
  const results = await Promise.all(
2243
- batch.map(async ({ url, depth }) => {
2390
+ batch.map(async ({ url, depth, restricted = false }) => {
2244
2391
  const norm = normalizeUrl(url)
2245
2392
  if (!norm || visited.has(norm)) return null
2246
2393
  visited.add(norm)
2247
2394
  attempted++
2248
- const page = await fetchViaWebSeam(ctx, url, externalSignal, cfg) || await fetchPage(url, externalSignal, cfg)
2395
+ const route = { ...policy, restricted }
2396
+ const page = await fetchResource(ctx, url, externalSignal, cfg, route)
2397
+ if (externalSignal && externalSignal.aborted) return null
2249
2398
  if (page.error) return { url, depth, error: page.error }
2250
2399
  let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2251
2400
  let pageUrl = page.finalUrl || url
@@ -2256,7 +2405,8 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2256
2405
  // be crawled AS the page (title-less hop text pollutes results and the
2257
2406
  // real content is never reached). Fails open — fetch problems keep the
2258
2407
  // stub's own HTML.
2259
- const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx)
2408
+ const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx, route)
2409
+ if (externalSignal && externalSignal.aborted) return null
2260
2410
  html = followed.html
2261
2411
  pageUrl = followed.finalUrl
2262
2412
  if (depth === 0) host = hostOf(pageUrl)
@@ -2271,10 +2421,13 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2271
2421
  title: ex.title || '',
2272
2422
  chars: ex.text.length,
2273
2423
  text: includeContent ? smartTruncate(ex.text, perMax).text : undefined,
2274
- links,
2424
+ links,
2425
+ route,
2426
+ ...(route.hint ? { spaHint: route.hint } : {}),
2275
2427
  }
2276
2428
  }),
2277
2429
  )
2430
+ if (externalSignal && externalSignal.aborted) break
2278
2431
  for (const r of results) {
2279
2432
  if (!r) continue
2280
2433
  if (r.error) {
@@ -2285,7 +2438,8 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2285
2438
  if (!finalNorm || resolved.has(finalNorm)) continue
2286
2439
  resolved.add(finalNorm)
2287
2440
  visited.add(finalNorm)
2288
- pages.push(r)
2441
+ const { route, ...publicPage } = r
2442
+ pages.push(publicPage)
2289
2443
  if (r.depth + 1 <= maxDepth) {
2290
2444
  // Queue cap: a huge site (millions of links) would otherwise grow the
2291
2445
  // queue unboundedly while maxPages bounds only what we *process*.
@@ -2293,7 +2447,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2293
2447
  for (const l of r.links) {
2294
2448
  if (!sameHost(l.url, host) || isNoiseUrl(l.url)) continue
2295
2449
  const n = normalizeUrl(l.url)
2296
- if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1 })
2450
+ if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1, restricted: route.restricted })
2297
2451
  }
2298
2452
  }
2299
2453
  }
@@ -2309,8 +2463,9 @@ function renderSite(value) {
2309
2463
  for (const p of v.pages) {
2310
2464
  const indent = ' '.repeat(p.depth)
2311
2465
  const head = p.title || p.url
2312
- lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
2313
- if (p.text) lines.push(`${indent} ${p.text.slice(0, 80)}`)
2466
+ lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
2467
+ if (p.spaHint) lines.push(`${indent}提示: ${p.spaHint}`)
2468
+ if (p.text) lines.push(`${indent} ${p.text}`)
2314
2469
  }
2315
2470
  for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
2316
2471
  return lines.join('\n')
@@ -2364,21 +2519,25 @@ function readUrlSiteTool(ctx, cfg) {
2364
2519
  const url = String((args && args.url) || '').trim()
2365
2520
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
2366
2521
  const maxPages = Math.floor(Math.max(2, Math.min(50, Number(args.maxPages) || 15)))
2522
+ const signal = exec && exec.signal
2523
+ if (signal && signal.aborted) return { error: 'cancelled' }
2367
2524
  const maxDepth = Math.max(1, Math.min(5, Number(args.maxDepth) || 2))
2368
2525
  const includeContent = args.includeContent === true
2369
2526
  const perMax = Math.max(200, Math.min(2000, Number(args.maxCharsPerPage) || 500))
2370
2527
  const { host, pages, failures } = await crawlSite(
2371
- url, cfg, { maxPages, maxDepth, includeContent, perMax }, exec && exec.signal, ctx,
2528
+ url, cfg, { maxPages, maxDepth, includeContent, perMax }, signal, ctx,
2372
2529
  )
2530
+ if (signal && signal.aborted) return { error: 'cancelled' }
2373
2531
  return {
2374
2532
  host,
2375
2533
  total: pages.length + failures.length,
2376
2534
  succeeded: pages.length,
2377
2535
  failed: failures.length,
2378
2536
  pages: pages.map((p) => {
2379
- const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
2380
- if (p.text) o.text = p.text
2381
- return o
2537
+ const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
2538
+ if (p.text) o.text = p.text
2539
+ if (p.spaHint) o.spaHint = p.spaHint
2540
+ return o
2382
2541
  }),
2383
2542
  failures,
2384
2543
  }
@@ -2405,7 +2564,8 @@ export function apply(ctx, config) {
2405
2564
  cfg.paginateMax = clamp(cfg.paginateMax, DEFAULTS.paginateMax, 1, 10)
2406
2565
  if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
2407
2566
  if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
2408
- if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
2567
+ if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
2568
+ cfg.directFetchOrigins = directOrigins(cfg.directFetchOrigins)
2409
2569
 
2410
2570
  // Temporal composability: unload must fully revert side effects.
2411
2571
  ctx.effect(() => () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-url",
3
- "version": "1.8.1",
3
+ "version": "1.9.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",