dsh-read-url 1.8.0 → 1.9.0

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0] - 2026-09-19
4
+
5
+ ### 新增
6
+
7
+ - 新增仅 profile 可配置的 `directFetchOrigins`,默认空列表。按协议、域名和端口精确匹配,命中后直接读取,显式跳过宿主 provider 及其 SSRF 检查;未命中的初始 URL 维持原有行为,不因任意 provider 错误自动直连。
8
+ - 例外链的 HTTP 重定向、meta-refresh、分页及爬取子页面均受完整列表约束;HTTP 跳转逐跳校验,每次尝试最多 5 跳并检测循环。例外不进入 curl 代理竞速或 SPA 渲染,失败边界/静态读取提示保留到工具输出。
9
+ - 成功和失败缓存按规范化后的完整配置隔离;非法配置明确报错,工具参数不允许扩大直连范围。双语 README 增补配置方法及信任边界。
10
+
11
+ ### 验证
12
+
13
+ - 322 个零依赖单元断言、15 个真实 Chromium SPA 断言、53 项对抗探针通过;新增 22 条直连策略检查与 3 条 SPA 限制检查。
14
+ - 受控 HTTP 服务与 provider 桩覆盖默认拒绝、显式直连、重定向、分页、四工具、缓存隔离、取消、超时及正文上限。
15
+ - 152 站实网回归:105 OK / 23 THIN+EMPTY / 24 ERR / 0 THREW,OK 站零噪声。Vue、React、酷狗三个已知 NOISE 站同输入 v1.8.1 对照的 text/Markdown 全文与 800 字符窗口逐字一致;ERR 均为网络、反爬、无效地址或预期不支持的类型。
16
+ - 四工具 description 共 1,159 字符,参数 schema 共 1,876 字符;零运行时依赖,新增正则有界。
17
+
18
+ ## [1.8.1] - 2026-09-19
19
+
20
+ ### 修复
21
+
22
+ - SPA 导航取消及时关闭当前页面;启动期间取消不再创建页面,卸载等待浏览器关闭,并发清理共享等待,断开连接后可重新启动;旧启动失败不会清空新浏览器引用。
23
+ - 浏览器启动异常保留简短真实原因,避免把执行权限错误误判为缺少 Playwright;DOM 轮询按连续失败次数判断停止。
24
+ - 有效成功缓存优先于并发失败,成功写入或命中清理同键失败记录。
25
+ - 站点爬取按 URL 路径边界过滤,保留 feedback、admin-guide 及查询参数中包含登录地址的正常页面。
26
+ - 编码声明支持等号空格和无引号属性,排除 data-charset 伪属性;Markdown 链接与图片按真实属性读取,正确处理实体、括号和图片说明文本。
27
+
28
+ ### 验证
29
+
30
+ - 297 个零依赖单元断言、15 个真实 Chromium SPA 断言、53 项对抗探针通过;新增 21 条单元断言和 3 条 SPA 断言。
31
+ - 152 站实网回归:104 OK / 23 THIN+EMPTY / 25 ERR / 0 THREW,OK 站零噪声;Vue、React、酷狗三站已知 NOISE 在同输入 v1.8.0 A/B 中逐字一致。
32
+ - 修复前 v1.7.0 / v1.8.0 同输入均复现相关缺陷,属于既有问题;核心零运行时依赖,新增或修改的正则保持有界。
33
+ - 四工具 description 共 1,159 字符,参数 schema 共 1,876 字符;预算仍为 1,250 / 2,000。
34
+
3
35
  ## [1.8.0] - 2026-09-07
4
36
 
5
37
  ### 内容与准确性
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
@@ -274,10 +295,26 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
274
295
  | Batch + failure isolation | 4-URL mix | ✅ 2/4 ok, failures isolated |
275
296
  | Site crawl | Ruan Yifeng blog | ✅ 5/5 pages tree map |
276
297
 
298
+ ### Released v1.8.0 validation snapshot
299
+
277
300
  - **276 zero-dependency assertions + 12 SPA assertions** pass. `node test.mjs` covers provider contracts, cancellation/retry, extraction/metadata, RSS/Atom, Markdown, continuation, batching and crawl boundaries. `node probe.mjs` checks pathological tags, nesting, long whitespace and bounded scanning.
278
301
  - Local maintenance validation (2026-09-07): **102 OK / 27 THIN+EMPTY / 23 ERR / 0 THREW** across 152 sites, with no noise among OK results; 53 adversarial probes pass. ERR results are network/anti-bot failures, invalid addresses or intentionally unsupported binary types. Known NOISE flags were checked against the previous code using identical inputs.
279
302
  - Real case: on a Xiaoheihe post, comment like-counts (`up` field) could not be attributed from flattened text — **precise fields should come from the page's underlying data API** (e.g. `/bbs/app/link/tree` JSON). This is a shared boundary of text extractors, not a defect.
280
303
 
304
+ ### v1.8.1 maintenance validation (2026-09-19)
305
+
306
+ - **297 zero-dependency assertions + 15 real Chromium SPA assertions + 53 adversarial probes** pass. `test-lifecycle.mjs`, included in `test.mjs`, uses a local browser fixture for cancellation, launch failures, concurrent cleanup and restart checks.
307
+ - The 152-site run returned **104 OK / 23 THIN+EMPTY / 25 ERR / 0 THREW**, with no noise among OK results. The three known NOISE sites have identical decoded content and extracted output against v1.8.0 using the same input. Network errors and intentionally unsupported types remain separate from extraction results.
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.
309
+ - Fixed model input cost remains **1,159 description characters / 1,876 schema characters**, within the 1,250 / 2,000 budgets.
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
+
281
318
  ## Roadmap
282
319
 
283
320
  - [x] Single-page continuation (`offset` parameter)
@@ -306,7 +343,7 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
306
343
 
307
344
  ```bash
308
345
  node test.mjs # zero-dependency self-tests (charset/extract/markdown/truncate)
309
- node test-spa.mjs # SPA rendering tests (12 assertions; SKIPs if playwright absent)
346
+ node test-spa.mjs # SPA rendering tests (15 assertions; SKIPs if playwright absent)
310
347
  node multi-site.mjs # 152-site real-world sweep (needs network, CONC=8 tunable): portals/SPA/login-walls/static/feeds/JSON/anti-bot/net-boundaries
311
348
 
312
349
  # End-to-end (requires DSH CLI)
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
@@ -274,10 +295,26 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
274
295
  | **批量 + 失败隔离** | 4 URL 混合 | ✅ 2/4 成功、失败隔离 |
275
296
  | **整站爬取** | 阮一峰博客 | ✅ 5/5 页树状站点地图 |
276
297
 
298
+ ### 已发布 v1.8.0 验证快照
299
+
277
300
  - **276 个零依赖单元断言 + 12 个 SPA 断言**通过;`node test.mjs` 包含网络契约、取消重试、正文/元数据、RSS/Atom、Markdown、续读、批量与站点边界测试。`node probe.mjs` 验证极端标签、嵌套、长空白和有界扫描性能。
278
301
  - 本机维护验证(2026-09-07):152 站为 **102 OK / 27 THIN+EMPTY / 23 ERR / 0 THREW**,OK 站零噪声;53 项对抗探针通过。ERR 为网络、反爬、无效地址或预期不支持的二进制类型;已知 NOISE 标记经同输入新旧 A/B 核查。
279
302
  - 一个真实案例:小黑盒帖子的评论点赞数(`up` 字段)无法从扁平文本确定归属——**精确字段应走页面背后的数据 API**(如 `/bbs/app/link/tree` JSON),这是同类文本提取器的共同边界,不是缺陷。
280
303
 
304
+ ### v1.8.1 维护验证(2026-09-19)
305
+
306
+ - **297 个零依赖单元断言 + 15 个真实 Chromium SPA 断言 + 53 项对抗探针**通过。`test.mjs` 已包含 `test-lifecycle.mjs`,通过本地浏览器桩验证取消、启动失败、并发清理和重启。
307
+ - 152 站实网结果为 **104 OK / 23 THIN+EMPTY / 25 ERR / 0 THREW**,OK 站零噪声。三个已知 NOISE 站在同输入 v1.8.0 对照中,解码内容与提取结果逐字一致;网络错误与预期不支持的类型单独归类。
308
+ - SPA 取消关闭当前页面,插件卸载等待浏览器关闭;有效成功缓存优先于并发失败;爬取过滤遵循路径边界;编码与 Markdown 属性使用有界解析。
309
+ - 模型固定输入成本仍为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
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
+
281
318
  ## Roadmap
282
319
 
283
320
  - [x] 单页多段续读(`offset` 参数)
@@ -308,7 +345,7 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
308
345
  node test.mjs # 单元自测(转码/提取/Markdown/截断/批量/站点爬取/缓存隔离/配置钳制/控制字符/JSON-LD/base href/meta-refresh)
309
346
 
310
347
  # SPA 渲染真实测试(需 playwright 已安装,未装自动 SKIP)
311
- node test-spa.mjs # 12 断言:JS 正文/渲染后链接/工具不崩溃/缓存隔离/JS 跳转壳
348
+ node test-spa.mjs # 15 断言:JS 正文/渲染后链接/缓存隔离/导航取消/浏览器重启
312
349
 
313
350
  # 152 站真实世界验证(需联网,CONC=8 可调并发)
314
351
  node multi-site.mjs # 门户/SPA/登录墙/静态/订阅源/JSON/反爬/网络边界,输出分级结果
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
 
@@ -46,11 +48,57 @@ function cacheFail(key, error) {
46
48
  if (failCache.size >= FAIL_CACHE_MAX) failCache.delete(failCache.keys().next().value)
47
49
  failCache.set(key, { time: Date.now(), error })
48
50
  }
49
- function cacheStore(key, full, cacheMax) {
50
- if (cache.size >= cacheMax) cache.delete(cache.keys().next().value)
51
- cache.set(key, { time: Date.now(), full })
51
+ function cacheStore(key, full, cacheMax) {
52
+ if (cache.size >= cacheMax) cache.delete(cache.keys().next().value)
53
+ cache.set(key, { time: Date.now(), full })
54
+ failCache.delete(key)
52
55
  }
53
- 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 = '已配置直连仅支持静态读取,未启用浏览器渲染'
54
102
 
55
103
  function getDecoder(enc) {
56
104
  let d = decoders.get(enc)
@@ -65,21 +113,29 @@ function getDecoder(enc) {
65
113
  return d
66
114
  }
67
115
 
68
- function sniffCharset(buffer, contentType) {
116
+ function declaredCharset(contentType) {
117
+ const m = /(?:^|;)[ \t]{0,1000}charset[ \t]{0,1000}=[ \t]{0,1000}(?:"([\w.:-]{1,80})"|'([\w.:-]{1,80})'|([\w.:-]{1,80}))(?=[ \t;]|$)/i.exec(contentType || '')
118
+ return m ? m[1] || m[2] || m[3] : null
119
+ }
120
+
121
+ function sniffCharset(buffer, contentType) {
69
122
  // BOM first — byte-level evidence beats any declared charset (a UTF-16 body
70
123
  // with a latin1-read <meta> probe garbles the meta match anyway).
71
124
  if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) return 'utf-8'
72
125
  if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le'
73
126
  if (buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be'
74
- const m = /charset=["']?([\w-]+)/i.exec(contentType || '')
75
- if (m) return m[1]
76
- const head = buffer.subarray(0, 2048).toString('latin1').toLowerCase()
77
- const meta = /<meta[^>]{0,1000}charset=["']?([\w-]+)/i.exec(head)
78
- if (meta) return meta[1]
79
- // Legacy form: <meta http-equiv="Content-Type" content="text/html; charset=gb2312">
80
- // (no charset= attribute anywhere) — common on old GBK pages with no header charset.
81
- const metaEquiv = /<meta[^>]{0,1000}(?:http-equiv)=["']content-type["'][^>]{0,1000}content=["'][^"']*charset=([\w-]+)/i.exec(head)
82
- if (metaEquiv) return metaEquiv[1]
127
+ const header = declaredCharset(contentType)
128
+ if (header) return header
129
+ const head = buffer.subarray(0, 2048).toString('latin1')
130
+ for (const meta of head.matchAll(/<meta(?=[\s/>])[^>]{0,1000}>/gi)) {
131
+ const attrs = htmlAttributes(meta[0])
132
+ const charset = attrs.charset?.trim()
133
+ if (charset && /^[\w.:-]{1,80}$/.test(charset)) return charset
134
+ if (attrs['http-equiv']?.toLowerCase() === 'content-type') {
135
+ const legacy = declaredCharset(attrs.content)
136
+ if (legacy) return legacy
137
+ }
138
+ }
83
139
  return null
84
140
  }
85
141
 
@@ -115,13 +171,31 @@ function retryAfterMs(res) {
115
171
  }
116
172
 
117
173
  // Single fetch attempt. Never throws; maps abort/timeout/network to { error }.
118
- async function directFetchOnce(url, signal, cfg) {
119
- try {
120
- const res = await fetch(url, {
121
- redirect: 'follow',
122
- signal,
123
- headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
124
- })
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
+ }
125
199
  // Early-return paths must cancel the body stream, or the connection is
126
200
  // held open undrained and cannot return to the keep-alive pool.
127
201
  const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
@@ -141,7 +215,7 @@ async function directFetchOnce(url, signal, cfg) {
141
215
  discardBody()
142
216
  return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
143
217
  }
144
- 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 }
145
219
  const chunks = []
146
220
  let size = 0
147
221
  for await (const chunk of res.body) {
@@ -154,7 +228,7 @@ async function directFetchOnce(url, signal, cfg) {
154
228
  if (!contentType && looksBinary(buffer)) {
155
229
  return { error: 'Unsupported content-type (no header, binary body)' }
156
230
  }
157
- return { buffer, contentType, finalUrl: res.url }
231
+ return { buffer, contentType, finalUrl: res.url || currentUrl }
158
232
  } catch (e) {
159
233
  if (e.name === 'AbortError' || e.name === 'TimeoutError') {
160
234
  return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
@@ -171,15 +245,15 @@ async function directFetchOnce(url, signal, cfg) {
171
245
  // Exported for tests: the double-throttle path must always produce an { error }
172
246
  // shape — a second `{ retryAfterMs }` would otherwise leak past the race
173
247
  // filter into decodeBuffer as a bufferless "success" (bare TypeError there).
174
- export async function directFetch(url, signal, cfg) {
175
- const first = await directFetchOnce(url, signal, cfg)
248
+ export async function directFetch(url, signal, cfg, origins) {
249
+ const first = await directFetchOnce(url, signal, cfg, origins)
176
250
  if (first && first.retryAfterMs !== undefined && !(signal && signal.aborted)) {
177
251
  await new Promise((resolve) => {
178
252
  const t = setTimeout(resolve, first.retryAfterMs)
179
253
  if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
180
254
  })
181
255
  if (signal && signal.aborted) return { error: 'cancelled' }
182
- const second = await directFetchOnce(url, signal, cfg)
256
+ const second = await directFetchOnce(url, signal, cfg, origins)
183
257
  // The retry budget is spent — report the rate limit instead of returning
184
258
  // a shapeless { retryAfterMs } (no buffer, no error) to the caller.
185
259
  if (second && second.retryAfterMs !== undefined) {
@@ -300,6 +374,27 @@ async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
300
374
  return { error: `Web provider failed: ${String((e && e.message) || e).slice(0, 160)}` }
301
375
  }
302
376
  }
377
+
378
+ // Every fetch in a tool call passes here. Once a chain enters an explicit
379
+ // direct origin it stays restricted, including meta refresh and pagination.
380
+ // Never send an exception through curl -L or a browser: both may make further
381
+ // requests outside this origin list. Unlisted initial URLs retain host policy.
382
+ async function fetchResource(ctx, url, externalSignal, cfg, route) {
383
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
384
+ if (route.restricted || matchesDirectOrigin(url, route.origins)) {
385
+ route.restricted = true
386
+ if (!allowedDirectUrl(url, route.origins)) {
387
+ route.hint = DIRECT_BOUNDARY_ERROR
388
+ return { error: DIRECT_BOUNDARY_ERROR }
389
+ }
390
+ const timeout = AbortSignal.timeout(cfg.timeoutMs)
391
+ const signal = externalSignal ? AbortSignal.any([externalSignal, timeout]) : timeout
392
+ const page = await directFetch(url, signal, cfg, route.origins)
393
+ if (page.error) route.hint = page.error
394
+ return page
395
+ }
396
+ return await fetchViaWebSeam(ctx, url, externalSignal, cfg) || await fetchPage(url, externalSignal, cfg)
397
+ }
303
398
 
304
399
  // Decode common HTML entities in already-stripped text. Runs AFTER tag
305
400
  // stripping so escaped tags stay visible to the stripper and only remaining
@@ -708,9 +803,15 @@ function stripNoise(mainHtml) {
708
803
  }
709
804
 
710
805
  // ---- HTML -> Markdown (lightweight, tag-state machine) ----
711
- function escInline(s) {
712
- return s.replace(/([\\`*_[\]])/g, '\\$1')
713
- }
806
+ function escInline(s) {
807
+ return s.replace(/([\\`*_[\]])/g, '\\$1')
808
+ }
809
+
810
+ function markdownTarget(value) {
811
+ return decodeUrlAttribute(value).trim()
812
+ .replace(/\(/g, '%28').replace(/\)/g, '%29')
813
+ .replace(/[<>\s\\]/g, encodeURIComponent)
814
+ }
714
815
 
715
816
  // <img> is a void element (never matches the paired-tag regex in either md
716
817
  // walker): convert descriptive images to markdown BEFORE the tag walk, via a
@@ -729,25 +830,30 @@ function imgsToMarkdown(html) {
729
830
  // in <picture><source srcset>. When the <img> fallback carries no usable
730
831
  // src (lazy placeholder), the first source URL is injected into it so the
731
832
  // normal img pass below still emits the image.
732
- html = html.replace(/<picture\b[^>]*>([\s\S]{0,50000}?)<\/picture\s*>/gi, (block) => {
733
- const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
734
- const imgSrc = imgTag && /(?:^|\s)src=["']([^"']+)["']/i.exec(imgTag[0])
735
- if (imgTag && imgSrc && !/^data:/i.test(imgSrc[1])) return block
736
- const source = /<source\b[^>]{0,1000}\bsrcset=["']([^"'\s>]+)/i.exec(block)
737
- if (!source || !imgTag) return block
738
- return block.replace(/<img\b[^>]{0,1000}>/i, (img0) => img0.replace(/<img/i, `<img src="${source[1]}"`))
739
- })
740
- const out = html.replace(/<(?:amp-)?img\b[^>]{0,1000}>/gi, (tag) => {
741
- const alt = /alt=["']([^"']*)["']/i.exec(tag)
742
- let src = /src=["']([^"']+)["']/i.exec(tag)
743
- let srcVal = src && src[1]
833
+ html = html.replace(/<picture\b[^>]{0,1000}>([\s\S]{0,50000}?)<\/picture\s{0,1000}>/gi, (block) => {
834
+ const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
835
+ const imgSrc = imgTag && htmlAttributes(imgTag[0]).src
836
+ if (!imgTag || (imgSrc && !/^data:/i.test(imgSrc))) return block
837
+ let source = ''
838
+ for (const tag of block.matchAll(/<source\b[^>]{0,1000}>/gi)) {
839
+ source = htmlAttributes(tag[0]).srcset?.trim().split(/[\s,]{1,1000}/, 1)[0] || ''
840
+ if (source) break
841
+ }
842
+ if (!source) return block
843
+ const escaped = decodeUrlAttribute(source).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
844
+ return block.replace(imgTag[0], () => `<img src="${escaped}"${imgTag[0].slice(4)}`)
845
+ })
846
+ const out = html.replace(/<(?:amp-)?img\b[^>]{0,1000}>/gi, (tag) => {
847
+ const attrs = htmlAttributes(tag)
848
+ const alt = decodeTextEntities(attrs.alt || '').replace(/\s{1,1000}/g, ' ').trim()
849
+ let srcVal = attrs.src?.trim()
744
850
  // Lazy-load wrappers keep the real URL in data-* attributes; a data: URI
745
851
  // src is an inline placeholder spacer, not a usable image reference.
746
852
  if (!srcVal || /^data:/i.test(srcVal)) {
747
- const lazy = /data-(?:src|original|lazy-src)=["']([^"']+)["']/i.exec(tag)
748
- if (lazy) srcVal = lazy[1]
749
- }
750
- return protect(srcVal && alt && alt[1].trim() ? `![${alt[1].trim()}](${srcVal})` : '')
853
+ const lazy = attrs['data-src'] || attrs['data-original'] || attrs['data-lazy-src']
854
+ if (lazy) srcVal = lazy.trim()
855
+ }
856
+ return protect(srcVal && alt ? `![${escInline(alt)}](${markdownTarget(srcVal)})` : '')
751
857
  })
752
858
  return {
753
859
  html: out,
@@ -826,13 +932,13 @@ export function inlineMd(html, depth = 0) {
826
932
  out += img.protect(inlineCode(m[3]))
827
933
  continue
828
934
  }
829
- const inner = inlineMd(m[3], depth + 1)
830
- if (tag === 'a') {
831
- const href = /href=["']([^"']+)["']/i.exec(m[2])
935
+ const inner = inlineMd(m[3], depth + 1)
936
+ if (tag === 'a') {
937
+ const href = htmlAttributes(`<a${m[2]}>`).href
832
938
  // A raw ')' in the target would terminate the markdown link early
833
939
  // (common on wiki/framework URLs) — percent-encode parens to keep the
834
940
  // link parseable by the model.
835
- out += href ? `[${inner}](${href[1].replace(/\)/g, '%29').replace(/\(/g, '%28')})` : inner
941
+ out += href?.trim() ? `[${inner}](${markdownTarget(href)})` : inner
836
942
  } else if (tag === 'strong' || tag === 'b') out += `**${inner}**`
837
943
  else if (tag === 'em' || tag === 'i') out += `*${inner}*`
838
944
  else out += inner
@@ -1547,7 +1653,7 @@ export function metaRefreshTarget(html, baseUrl) {
1547
1653
  // cache key is the ORIGINAL url, and a shell re-request should re-follow
1548
1654
  // rather than serve a foreign page.
1549
1655
  const MAX_META_REFRESH_HOPS = 3
1550
- async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
1656
+ async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx, route) {
1551
1657
  let cur = html
1552
1658
  let curUrl = finalUrl
1553
1659
  let curCharset = '' // set only when a hop actually happened
@@ -1566,7 +1672,7 @@ async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
1566
1672
  const norm = normalizeUrl(target)
1567
1673
  if (!norm || seen.has(norm)) break
1568
1674
  seen.add(norm)
1569
- const page = await fetchViaWebSeam(ctx, target, hopSignal, cfg) || await fetchPage(target, hopSignal, cfg)
1675
+ const page = await fetchResource(ctx, target, hopSignal, cfg, route)
1570
1676
  if (page.error) break
1571
1677
  const ct = page.kind === 'html' ? 'text/html' : (page.contentType || '').split(';')[0].toLowerCase().trim()
1572
1678
  if (!/html|xhtml/.test(ct)) break
@@ -1583,6 +1689,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1583
1689
  const url = String((args && args.url) || '').trim()
1584
1690
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
1585
1691
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1692
+ let route
1693
+ try { route = createRoute(cfg) } catch (e) { return { error: e.message } }
1586
1694
  const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
1587
1695
  const mode = args.mode === 'markdown' ? 'markdown' : 'text'
1588
1696
  const offset = Math.max(0, Number(args.offset) || 0)
@@ -1612,8 +1720,20 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1612
1720
  } catch {
1613
1721
  /* keep bare url */
1614
1722
  }
1615
- const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1616
- // Failed fetches are cached briefly so the model doesn't re-request a
1723
+ // Include the complete policy: an unlisted entry may reach a listed origin
1724
+ // through refresh/pagination, and must not reuse content after revocation.
1725
+ const cacheKey = `${route.key}|${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1726
+ const hit = cache.get(cacheKey)
1727
+ if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
1728
+ // Valid content takes precedence over a concurrent fetch failure.
1729
+ failCache.delete(cacheKey)
1730
+ // LRU touch keeps hot pages available throughout their TTL.
1731
+ cache.delete(cacheKey)
1732
+ cache.set(cacheKey, hit)
1733
+ const sliced = sliceFrom(hit.full, offset, maxChars)
1734
+ return { ...sliced, cached: true }
1735
+ }
1736
+ // Failed fetches are cached briefly so the model doesn't re-request a
1617
1737
  // broken URL in a loop (token + latency saver).
1618
1738
  const fh = failCache.get(cacheKey)
1619
1739
  if (fh) {
@@ -1626,15 +1746,6 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1626
1746
  }
1627
1747
  failCache.delete(cacheKey)
1628
1748
  }
1629
- const hit = cache.get(cacheKey)
1630
- if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
1631
- // LRU touch: hot pages survive eviction even when cached long ago — the
1632
- // Map re-insertion moves the entry to the newest slot.
1633
- cache.delete(cacheKey)
1634
- cache.set(cacheKey, hit)
1635
- const sliced = sliceFrom(hit.full, offset, maxChars)
1636
- return { ...sliced, cached: true }
1637
- }
1638
1749
 
1639
1750
  const failWith = (error) => {
1640
1751
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
@@ -1679,9 +1790,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1679
1790
  }
1680
1791
  }
1681
1792
  const dispatch = async () => {
1682
- const viaSeam = await fetchViaWebSeam(ctx, url, externalSignal, cfg)
1683
- const page = viaSeam || await fetchPage(url, externalSignal, cfg)
1793
+ const page = await fetchResource(ctx, url, externalSignal, cfg, route)
1684
1794
  if (page.error) return { error: page.error }
1795
+ const viaSeam = typeof page.html === 'string'
1685
1796
  const ct = viaSeam ? (page.kind === 'text' ? 'text/plain' : 'text/html') : (page.contentType || '').split(';')[0].toLowerCase().trim()
1686
1797
  const decoded = viaSeam ? { text: page.html, charset: 'provider-decoded' } : decodeBuffer(page.buffer, page.contentType)
1687
1798
  const meta = { finalUrl: page.finalUrl || url, sourceTruncated: page.sourceTruncated === true }
@@ -1761,7 +1872,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1761
1872
  // follow immediately, BEFORE extraction and SPA rendering — a shell that
1762
1873
  // redirects needs no headless round-trip, and extracting the stub is wasted
1763
1874
  // work. Fails open on any fetch problem; the follow is hop-bounded.
1764
- const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx)
1875
+ const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx, route)
1765
1876
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
1766
1877
  html = followed.html
1767
1878
  finalUrl = followed.finalUrl
@@ -1805,7 +1916,10 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1805
1916
  let renderedHtml = null
1806
1917
  const needsRender = !extracted.text || extracted.text.length < 200
1807
1918
  const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
1808
- if (cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
1919
+ if (route.restricted && cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
1920
+ spaHint = [spaHint, DIRECT_SPA_HINT].filter(Boolean).join(';')
1921
+ }
1922
+ if (!route.restricted && cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
1809
1923
  const rr = await renderPage(finalUrl || url, externalSignal)
1810
1924
  if (rr.html) {
1811
1925
  renderedHtml = rr.html
@@ -1861,7 +1975,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1861
1975
  const nu = normalizeUrl(nextUrl)
1862
1976
  if (!nu || seen.has(nu)) break
1863
1977
  seen.add(nu)
1864
- const page = await fetchViaWebSeam(ctx, nextUrl, externalSignal, cfg) || await fetchPage(nextUrl, externalSignal, cfg)
1978
+ const page = await fetchResource(ctx, nextUrl, externalSignal, cfg, route)
1865
1979
  if (page.error) break
1866
1980
  const pageFinal = normalizeUrl(page.finalUrl || nextUrl)
1867
1981
  if (!pageFinal || !sameHost(pageFinal, startHost) || (pageFinal !== nu && seen.has(pageFinal))) break
@@ -1877,7 +1991,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1877
1991
  nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1878
1992
  }
1879
1993
 
1880
- // A near-empty body (login wall, anti-bot shell) with no render hint yet
1994
+ if (route.hint) spaHint = [spaHint, route.hint].filter(Boolean).join(';')
1995
+
1996
+ // A near-empty body (login wall, anti-bot shell) with no render hint yet
1881
1997
  // still looks like "the page content" to the model — one constant-cost line
1882
1998
  // tells it not to keep offset-reading a 40-char page.
1883
1999
  if (!spaHint && extracted.text && extracted.text.length < 60) {
@@ -1971,8 +2087,9 @@ function renderLinks(value) {
1971
2087
  if (typeof value === 'string') return value
1972
2088
  const r = value || {}
1973
2089
  if (r.error) return `Error: ${r.error}`
1974
- if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}`
1975
- const lines = [`${r.count} link(s) on ${r.url}:`, UNTRUSTED_NOTICE]
2090
+ if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}${r.spaHint ? `\n提示: ${r.spaHint}` : ''}`
2091
+ const lines = [`${r.count} link(s) on ${r.url}:`, UNTRUSTED_NOTICE]
2092
+ if (r.spaHint) lines.push(`提示: ${r.spaHint}`)
1976
2093
  for (const l of r.links) lines.push(`- ${l.title || l.url} — ${l.url}`)
1977
2094
  return lines.join('\n')
1978
2095
  }
@@ -2012,14 +2129,15 @@ function readLinksTool(ctx, cfg) {
2012
2129
  async execute(args, exec) {
2013
2130
  const url = String((args && args.url) || '').trim()
2014
2131
  if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
2015
- const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
2016
- const page = await fetchViaWebSeam(ctx, url, exec && exec.signal, cfg) || await fetchPage(url, exec && exec.signal, cfg)
2132
+ const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
2133
+ const route = createRoute(cfg)
2134
+ const page = await fetchResource(ctx, url, exec && exec.signal, cfg, route)
2017
2135
  if (page.error) return { error: page.error }
2018
2136
  let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2019
2137
  let finalUrl = page.finalUrl || url
2020
2138
  // Same meta-refresh shell following as read_url: a link hop serves a
2021
2139
  // stub whose real links live at the target. Fails open, hop-bounded.
2022
- const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg, ctx)
2140
+ const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg, ctx, route)
2023
2141
  html = followed.html
2024
2142
  finalUrl = followed.finalUrl
2025
2143
  let links = extractLinks(html, limit, finalUrl)
@@ -2027,7 +2145,8 @@ function readLinksTool(ctx, cfg) {
2027
2145
  // render it and re-extract when the static result looks empty. Any
2028
2146
  // <script> counts as renderable when there are no links at all — the
2029
2147
  // static shell may be a JS-redirect (same rationale as read_url).
2030
- if (links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))) {
2148
+ const needsRender = links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))
2149
+ if (!route.restricted && needsRender) {
2031
2150
  const rr = await renderPage(finalUrl || url, exec && exec.signal)
2032
2151
  // A challenge interstitial's links are the challenge's own scripts —
2033
2152
  // never an improvement over whatever the static HTML offered.
@@ -2039,7 +2158,8 @@ function readLinksTool(ctx, cfg) {
2039
2158
  }
2040
2159
  }
2041
2160
  }
2042
- return { url: finalUrl, count: links.length, links }
2161
+ const spaHint = [route.hint, route.restricted && needsRender ? DIRECT_SPA_HINT : ''].filter(Boolean).join(';')
2162
+ return { url: finalUrl, count: links.length, links, ...(spaHint ? { spaHint } : {}) }
2043
2163
  },
2044
2164
  }
2045
2165
  }
@@ -2070,7 +2190,8 @@ function renderBatch(value) {
2070
2190
  continue
2071
2191
  }
2072
2192
  const head = p.title || p.url
2073
- lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
2193
+ lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
2194
+ if (p.spaHint) lines.push(`提示: ${p.spaHint}`)
2074
2195
  if (Array.isArray(p.links) && p.links.length) {
2075
2196
  const ls = p.links.map((l) => l.title + ' — ' + l.url)
2076
2197
  lines.push(`links: ${ls.slice(0, 6).join(' | ')}${ls.length > 6 ? ` …共${ls.length}个` : ''}`)
@@ -2160,7 +2281,8 @@ function readUrlBatchTool(ctx, cfg) {
2160
2281
  cached: p.cached === true,
2161
2282
  text: p.text,
2162
2283
  }
2163
- if (Array.isArray(p.links) && p.links.length) out.links = p.links
2284
+ if (Array.isArray(p.links) && p.links.length) out.links = p.links
2285
+ if (p.spaHint) out.spaHint = p.spaHint
2164
2286
  return out
2165
2287
  }),
2166
2288
  }
@@ -2189,18 +2311,24 @@ function sameHost(a, b) {
2189
2311
  }
2190
2312
 
2191
2313
  // URLs not worth crawling: static assets, login/auth paths, feeds, sitemaps.
2192
- const NOISE_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|css|js|json|xml|pdf|zip|gz|tar|7z|mp3|m3u8?|mpd|flv|ts|mp4|avi|mov|webm|woff2?|ttf|eot|map)(\?|#|$)/i
2193
- const NOISE_PATH = /(\/login|\/signin|\/register|\/logout|\/signup|\/api\/|\/admin|\/wp-admin|\/wp-login|\/feed|\/rss|\/sitemap|\/robots\.txt|\/cdn-cgi)/i
2194
- function isNoiseUrl(url) {
2195
- return NOISE_EXT.test(url) || NOISE_PATH.test(url)
2196
- }
2314
+ const NOISE_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|css|js|json|xml|pdf|zip|gz|tar|7z|mp3|m3u8?|mpd|flv|ts|mp4|avi|mov|webm|woff2?|ttf|eot|map)$/i
2315
+ const NOISE_PATH = /\/(?:login|signin|register|logout|signup|api|admin|wp-admin|wp-login|feed|rss|sitemap|robots\.txt|cdn-cgi)(?:\/|[.;]|$)/i
2316
+ function isNoiseUrl(url) {
2317
+ try {
2318
+ const path = new URL(url).pathname
2319
+ return NOISE_EXT.test(path) || NOISE_PATH.test(path)
2320
+ } catch {
2321
+ return true
2322
+ }
2323
+ }
2197
2324
 
2198
2325
  // BFS crawl of a single site. Only same-host http(s) pages are followed;
2199
2326
  // static assets / auth paths are skipped; visited URLs are deduped; a per-page
2200
2327
  // failure is recorded and does not abort the crawl. No SPA rendering here —
2201
2328
  // that is read_url's job; crawling favors speed and breadth.
2202
2329
  async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2203
- const { maxPages, maxDepth, includeContent, perMax } = opts
2330
+ const { maxPages, maxDepth, includeContent, perMax } = opts
2331
+ const policy = createRoute(cfg)
2204
2332
  let host = hostOf(entryUrl)
2205
2333
  const visited = new Set()
2206
2334
  const resolved = new Set()
@@ -2214,12 +2342,13 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2214
2342
  // Never overshoot the page budget within a batch round.
2215
2343
  const batch = queue.splice(0, Math.min(2, maxPages - attempted, queue.length))
2216
2344
  const results = await Promise.all(
2217
- batch.map(async ({ url, depth }) => {
2345
+ batch.map(async ({ url, depth, restricted = false }) => {
2218
2346
  const norm = normalizeUrl(url)
2219
2347
  if (!norm || visited.has(norm)) return null
2220
2348
  visited.add(norm)
2221
2349
  attempted++
2222
- const page = await fetchViaWebSeam(ctx, url, externalSignal, cfg) || await fetchPage(url, externalSignal, cfg)
2350
+ const route = { ...policy, restricted }
2351
+ const page = await fetchResource(ctx, url, externalSignal, cfg, route)
2223
2352
  if (page.error) return { url, depth, error: page.error }
2224
2353
  let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
2225
2354
  let pageUrl = page.finalUrl || url
@@ -2230,7 +2359,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2230
2359
  // be crawled AS the page (title-less hop text pollutes results and the
2231
2360
  // real content is never reached). Fails open — fetch problems keep the
2232
2361
  // stub's own HTML.
2233
- const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx)
2362
+ const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx, route)
2234
2363
  html = followed.html
2235
2364
  pageUrl = followed.finalUrl
2236
2365
  if (depth === 0) host = hostOf(pageUrl)
@@ -2245,7 +2374,9 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2245
2374
  title: ex.title || '',
2246
2375
  chars: ex.text.length,
2247
2376
  text: includeContent ? smartTruncate(ex.text, perMax).text : undefined,
2248
- links,
2377
+ links,
2378
+ route,
2379
+ ...(route.hint ? { spaHint: route.hint } : {}),
2249
2380
  }
2250
2381
  }),
2251
2382
  )
@@ -2259,7 +2390,8 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2259
2390
  if (!finalNorm || resolved.has(finalNorm)) continue
2260
2391
  resolved.add(finalNorm)
2261
2392
  visited.add(finalNorm)
2262
- pages.push(r)
2393
+ const { route, ...publicPage } = r
2394
+ pages.push(publicPage)
2263
2395
  if (r.depth + 1 <= maxDepth) {
2264
2396
  // Queue cap: a huge site (millions of links) would otherwise grow the
2265
2397
  // queue unboundedly while maxPages bounds only what we *process*.
@@ -2267,7 +2399,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
2267
2399
  for (const l of r.links) {
2268
2400
  if (!sameHost(l.url, host) || isNoiseUrl(l.url)) continue
2269
2401
  const n = normalizeUrl(l.url)
2270
- if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1 })
2402
+ if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1, restricted: route.restricted })
2271
2403
  }
2272
2404
  }
2273
2405
  }
@@ -2283,7 +2415,8 @@ function renderSite(value) {
2283
2415
  for (const p of v.pages) {
2284
2416
  const indent = ' '.repeat(p.depth)
2285
2417
  const head = p.title || p.url
2286
- lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
2418
+ lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
2419
+ if (p.spaHint) lines.push(`${indent}提示: ${p.spaHint}`)
2287
2420
  if (p.text) lines.push(`${indent} ${p.text.slice(0, 80)}`)
2288
2421
  }
2289
2422
  for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
@@ -2350,9 +2483,10 @@ function readUrlSiteTool(ctx, cfg) {
2350
2483
  succeeded: pages.length,
2351
2484
  failed: failures.length,
2352
2485
  pages: pages.map((p) => {
2353
- const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
2354
- if (p.text) o.text = p.text
2355
- return o
2486
+ const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
2487
+ if (p.text) o.text = p.text
2488
+ if (p.spaHint) o.spaHint = p.spaHint
2489
+ return o
2356
2490
  }),
2357
2491
  failures,
2358
2492
  }
@@ -2379,14 +2513,15 @@ export function apply(ctx, config) {
2379
2513
  cfg.paginateMax = clamp(cfg.paginateMax, DEFAULTS.paginateMax, 1, 10)
2380
2514
  if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
2381
2515
  if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
2382
- if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
2516
+ if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
2517
+ cfg.directFetchOrigins = directOrigins(cfg.directFetchOrigins)
2383
2518
 
2384
2519
  // Temporal composability: unload must fully revert side effects.
2385
- ctx.effect(() => () => {
2386
- cache.clear()
2387
- failCache.clear()
2388
- closeBrowser().catch(() => {})
2389
- })
2520
+ ctx.effect(() => () => {
2521
+ cache.clear()
2522
+ failCache.clear()
2523
+ return closeBrowser()
2524
+ })
2390
2525
 
2391
2526
  console.log('[dsh-read-url] plugin loaded; tools read_url, read_url_batch, read_url_links, read_url_site registered')
2392
2527
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-url",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
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
@@ -4,6 +4,11 @@
4
4
  // When absent, reads fall back to static extraction with a clear hint.
5
5
 
6
6
  let browserPromise = null
7
+ const closingBrowsers = new Set()
8
+
9
+ function renderError(error) {
10
+ return `Render failed: ${String(error?.message || error).split('\n', 1)[0].slice(0, 500)}`
11
+ }
7
12
 
8
13
  // Heuristic: a page whose HTML carries many <script> tags is likely a
9
14
  // client-rendered SPA (Vue/React) whose body lives only after JS execution.
@@ -33,12 +38,19 @@ async function getBrowser() {
33
38
  if (!browserPromise) {
34
39
  // A failed launch (playwright later installed / chromium download finished)
35
40
  // must not stay cached as a rejected promise — reset so the next call retries.
36
- browserPromise = import('playwright')
41
+ const pending = import('playwright')
37
42
  .then(({ chromium }) => chromium.launch({ headless: true }))
43
+ .then((browser) => {
44
+ browser.once('disconnected', () => {
45
+ if (browserPromise === pending) browserPromise = null
46
+ })
47
+ return browser
48
+ })
38
49
  .catch((e) => {
39
- browserPromise = null
50
+ if (browserPromise === pending) browserPromise = null
40
51
  throw e
41
52
  })
53
+ browserPromise = pending
42
54
  }
43
55
  return browserPromise
44
56
  }
@@ -49,12 +61,23 @@ export async function renderPage(url, externalSignal) {
49
61
  let browser
50
62
  try {
51
63
  browser = await getBrowser()
52
- } catch {
53
- return { error: 'SPA 渲染需 playwright(npm i playwright && npx playwright install chromium)' }
64
+ } catch (error) {
65
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
66
+ return { error: error?.code === 'ERR_MODULE_NOT_FOUND'
67
+ ? 'SPA 渲染需 playwright(npm i playwright && npx playwright install chromium)'
68
+ : renderError(error) }
54
69
  }
70
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
55
71
  let page
72
+ let pageClosing
73
+ const closePage = () => {
74
+ if (page && !pageClosing) pageClosing = page.close().catch(() => {})
75
+ return pageClosing
76
+ }
56
77
  try {
57
78
  page = await browser.newPage()
79
+ externalSignal?.addEventListener('abort', closePage, { once: true })
80
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
58
81
  // 'domcontentloaded' instead of 'networkidle': heartbeat-polling sites
59
82
  // (qq-news, juejin) never go idle and would time out at 30s. Instead wait
60
83
  // for the DOM to stabilize (content stops growing) up to 10s — SPA paint
@@ -72,7 +95,8 @@ export async function renderPage(url, externalSignal) {
72
95
  .evaluate(() => (document.body ? document.body.innerHTML.length : 0))
73
96
  .catch(() => -1)
74
97
  if (Date.now() - t0 > 10000) break
75
- if (len === -1 && ++evalFails >= 2) break
98
+ evalFails = len === -1 ? evalFails + 1 : 0
99
+ if (evalFails >= 2) break
76
100
  // stop once two consecutive reads agree — including empty bodies (a
77
101
  // blank page should not burn the full 10s poll)
78
102
  if (len === prevLen && prevLen >= 0) break
@@ -93,25 +117,33 @@ export async function renderPage(url, externalSignal) {
93
117
  if (!looksLikeChallenge(html)) break
94
118
  }
95
119
  }
120
+ if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
96
121
  const finalUrl = page.url()
97
122
  return { html, finalUrl }
98
123
  } catch (e) {
99
124
  if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
100
- return { error: `Render failed: ${e.message}` }
125
+ return { error: renderError(e) }
101
126
  } finally {
102
- if (page) await page.close().catch(() => {})
127
+ externalSignal?.removeEventListener('abort', closePage)
128
+ await closePage()
103
129
  }
104
130
  }
105
131
 
106
132
  // Called on plugin unload (temporal composability): release the browser.
107
133
  export async function closeBrowser() {
108
- if (!browserPromise) return
109
134
  const p = browserPromise
110
135
  browserPromise = null
111
- try {
112
- const b = await p
113
- await b.close().catch(() => {})
114
- } catch {
115
- // launch never completed — nothing to close
136
+ if (p) {
137
+ const closing = (async () => {
138
+ try {
139
+ const b = await p
140
+ await b.close()
141
+ } catch {
142
+ // A failed launch or an already closed browser needs no further cleanup.
143
+ }
144
+ })()
145
+ closingBrowsers.add(closing)
146
+ closing.finally(() => closingBrowsers.delete(closing))
116
147
  }
148
+ await Promise.all(closingBrowsers)
117
149
  }