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 +32 -0
- package/README.md +39 -2
- package/README.zh.md +39 -2
- package/cordis.patch.yml +1 -0
- package/index.js +236 -101
- package/package.json +1 -1
- package/spa.js +45 -13
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 (
|
|
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 #
|
|
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
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
|
|
|
@@ -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
|
|
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
|
|
75
|
-
if (
|
|
76
|
-
const head = buffer.subarray(0, 2048).toString('latin1')
|
|
77
|
-
const meta
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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[^>]
|
|
733
|
-
const imgTag = /<img\b[^>]{0,1000}>/i.exec(block)
|
|
734
|
-
const imgSrc = imgTag &&
|
|
735
|
-
if (imgTag
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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, '&').replace(/"/g, '"')
|
|
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 =
|
|
748
|
-
if (lazy) srcVal = lazy
|
|
749
|
-
}
|
|
750
|
-
return protect(srcVal && alt
|
|
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 ? `})` : '')
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
1616
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
2193
|
-
const NOISE_PATH =
|
|
2194
|
-
function isNoiseUrl(url) {
|
|
2195
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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.
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
125
|
+
return { error: renderError(e) }
|
|
101
126
|
} finally {
|
|
102
|
-
|
|
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
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
}
|