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 +33 -0
- package/README.md +37 -1
- package/README.zh.md +37 -1
- package/cordis.patch.yml +1 -0
- package/index.js +247 -87
- package/package.json +1 -1
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
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),
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
188
|
-
|
|
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).
|
|
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
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
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(
|
|
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
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
const r =
|
|
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
|
-
|
|
1443
|
-
|
|
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(
|
|
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
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
2052
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 },
|
|
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
|
-
|
|
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.
|
|
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",
|