dsh-read-url 0.4.7 → 0.4.9
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 +39 -0
- package/README.md +52 -6
- package/README.zh.md +54 -8
- package/index.js +56 -13
- package/package.json +2 -2
- package/proxy-fallback.js +10 -2
- package/spa.js +15 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.9] - 2026-08-20
|
|
4
|
+
|
|
5
|
+
### 稳定性:配置健壮性 + 严格宿主兼容 + 资源泄漏
|
|
6
|
+
|
|
7
|
+
**修复(真 bug)**
|
|
8
|
+
- `cordis.patch.yml` 数字加引号(`timeoutMs: "15000"`)时按字符串拼接进超时预算(`"1500020000"`)→ 加载时统一强转数字并钳制到合理范围(timeoutMs 500–120000 等),非法值回退默认
|
|
9
|
+
- 严格 cordis 宿主上 `ctx.get('web')` 对未注入服务直接抛错(同 `settings` 踩过的坑)→ 移入 try,降级为全局 fetch
|
|
10
|
+
- 超时预算公式低估最坏链路:SPA 渲染实际是 goto 30s 上限 + DOM 轮询 10s = 40s,原公式只留 20s → 修正为 `timeoutMs + 45s`,且随配置缩放(60s 配置得 105s 预算,不再钳制)
|
|
11
|
+
- `read_url_site` 预算 120s 覆盖不了 50 页 × 长超时的爬取波次 → 改为随配置缩放(`timeoutMs × 25` 波)
|
|
12
|
+
- `crawlSite` 在协作超时触发后不退出循环(对已中止的 fetch 空转清空队列)→ while 增加 signal aborted 检查
|
|
13
|
+
- 无 body 响应(204/304)导致 `res.body` 迭代抛 TypeError → 空响应防护返回空 buffer
|
|
14
|
+
- offset 续读到文末时误报"登录墙 / SPA 页" → 识别文末,提示"offset 已到文末"
|
|
15
|
+
- playwright 启动失败被永久缓存为 rejected promise(装好后也不重试)→ 失败自愈重置;`closeBrowser` 对未完成的 launch 健壮化
|
|
16
|
+
|
|
17
|
+
**测试**:39 → **42 断言**(+最坏链路预算随配置缩放、yml 字符串强转+钳制、严格宿主 seam 降级)+ test-spa 10 全绿
|
|
18
|
+
|
|
19
|
+
**明确不做**:Web UI 设置卡片——宿主侧注册成功但官方 UI 只渲染自带浏览器半边的插件("A served namespace no card claims renders nothing"),为一个 6 字段低频配置维护 React 构建链不划算;配置走 `cordis.patch.yml` 已足够
|
|
20
|
+
|
|
21
|
+
## [0.4.8] - 2026-08-19
|
|
22
|
+
|
|
23
|
+
### 41 站多类型实测 + 提取修复
|
|
24
|
+
|
|
25
|
+
**新增 12 类站点测试**(29 → 41):问答(知乎专栏/百度知道)、论坛(虎扑/贴吧)、百科(百度百科)、小说(起点)、政府(gov.cn)、教育(北邮)、视频(B 站视频页)、技术媒体(36 氪/中关村在线 GBK)、技术文档(Vue 文档)
|
|
26
|
+
- 结果:**23 OK / 8 预期边界 / 10 网络·反爬边界 / 0 崩溃**;新增站 gov.cn/知乎专栏/虎扑/百度知道/B 站视频页/中关村在线(GBK) 全部干净提取
|
|
27
|
+
|
|
28
|
+
**修复(真 bug)**
|
|
29
|
+
- `pickMain` 只匹配 `role="main"` 属性形式,**裸 `<main>` 标签漏选**——VitePress 系文档站(vuejs.org)退化为整页 body,顶部导航(VPLocalNav "菜单/本页目录/赞助位")混入正文 → 新增裸 `<main>` 匹配分支,文档站正文聚焦、噪音清除(实测 vuejs.org 修复)
|
|
30
|
+
|
|
31
|
+
**修复(防御性)**
|
|
32
|
+
- `readWindowsSystemProxy` 失败时**永久缓存空代理**——若 PowerShell 子进程被 EDR/策略临时拦截,整个会话都失去代理 → 失败缓存 60s 后允许重新探测
|
|
33
|
+
|
|
34
|
+
**修复(测试脚本)**
|
|
35
|
+
- `noiseCheck` 的 css 对象正则误报 JS 代码块(`{ data ()`)→ 收紧为 CSS 属性名白名单
|
|
36
|
+
|
|
37
|
+
**验证**:38 → **39 断言**(+裸 main 优先于 body)+ test-spa 10 全绿;41 站 0 THREW
|
|
38
|
+
|
|
39
|
+
### README 加真实案例
|
|
40
|
+
- 中英「真实案例」章节:省 token(新浪 800/12398 截断)、竞速(BBC 633ms)、offset 续读(800+800 cached)、批量失败隔离——全部实测数据
|
|
41
|
+
|
|
3
42
|
## [0.4.7] - 2026-08-19
|
|
4
43
|
|
|
5
44
|
### 省 token:参数 schema 描述精简(固定开销 -9%)
|
package/README.md
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-read-url)
|
|
8
|
+
[](https://github.com/2672243194/dsh-read-url/blob/main/LICENSE)
|
|
9
|
+
|
|
7
10
|
URL reader plugin for DeepSeek Harness: fetch any webpage, **auto-detect encoding (GBK/GB2312/UTF-8/Big5)**, extract the clean main content, and return **token-efficient compact text or structured Markdown**.
|
|
8
11
|
|
|
9
|
-
Zero dependencies (Node 20+ built-ins), no API key, no server side — install and use.
|
|
12
|
+
Zero runtime dependencies (Node 20+ built-ins handle fetch/decode/extract), no API key, no server side — install and use.
|
|
10
13
|
|
|
11
14
|
## Why
|
|
12
15
|
|
|
@@ -59,6 +62,45 @@ Read https://example.com/article and summarize the key points
|
|
|
59
62
|
Read https://docs.example.org/guide in markdown mode
|
|
60
63
|
```
|
|
61
64
|
|
|
65
|
+
### Real examples (measured)
|
|
66
|
+
|
|
67
|
+
**1. Token economy — return only what the model needs**
|
|
68
|
+
|
|
69
|
+
`read_url` on a portal returns cleaned body capped at `maxChars` (default 6,000) — not the raw page with nav/ads/footers. Repeat reads hit the 5-min cache (`(cached)`), so the agent never re-fetches:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
title: 新闻中心首页_新浪网
|
|
73
|
+
charset utf-8
|
|
74
|
+
(chars 800/12398 — 截断,offset 续读)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**2. Overseas sites — direct + proxy race**
|
|
78
|
+
|
|
79
|
+
When a proxy is configured, the direct fetch and the proxy `curl` start together and the first to complete wins. A blocked overseas site that used to cost ~11s (direct-connect timeout + fallback) now reads in under a second:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
BBC 中文: OK (633ms) — clean 4,000+ chars of headline news
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**3. Long-article continuation (`offset`)**
|
|
86
|
+
|
|
87
|
+
A 12,000-char article is read in slices; `offset` resumes from cache without repeating earlier text — the model keeps exactly what it needs in context:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
chars 800+800/12398 · cached
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**4. Batch research across pages**
|
|
94
|
+
|
|
95
|
+
`read_url_batch` reads up to 10 pages in parallel (concurrency 4), each cleaned individually, failures isolated:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
读取 2/4 页成功,2 页失败
|
|
99
|
+
--- 阮一峰的网络日志 (491 字符) ---
|
|
100
|
+
--- Example Domain (127 字符) ---
|
|
101
|
+
[失败] https://zh.wikipedia.org/... — Fetch failed: HTTP 403 ...
|
|
102
|
+
```
|
|
103
|
+
|
|
62
104
|
### Tools
|
|
63
105
|
|
|
64
106
|
**`read_url(url, maxChars?, offset?, mode?, includeLinks?)`** — fetch and extract clean body
|
|
@@ -107,19 +149,23 @@ Read https://docs.example.org/guide in markdown mode
|
|
|
107
149
|
|
|
108
150
|
### Configuration (optional)
|
|
109
151
|
|
|
110
|
-
|
|
152
|
+
Override via the profile's `cordis.patch.yml` (defaults in the plugin's own `cordis.patch.yml`):
|
|
111
153
|
|
|
112
154
|
```yaml
|
|
113
155
|
- id: dsh-read-url
|
|
114
156
|
config:
|
|
115
|
-
timeoutMs: 15000 # per-request timeout
|
|
157
|
+
timeoutMs: 15000 # per-request timeout (500-120000, clamped)
|
|
116
158
|
maxBytes: 3145728 # response body cap (bytes)
|
|
117
159
|
maxChars: 6000 # default body truncation
|
|
118
160
|
maxLinks: 20 # read_url_links default count
|
|
119
161
|
spaRender: true # SPA rendering enhancement (needs playwright installed; degrades with a hint otherwise)
|
|
120
162
|
userAgent: '...' # request UA
|
|
163
|
+
cacheTtlMs: 300000 # success-cache TTL
|
|
164
|
+
cacheMax: 32 # cache entry cap
|
|
121
165
|
```
|
|
122
166
|
|
|
167
|
+
Values are coerced and clamped to sane ranges at load — quoted numbers in YAML work, garbage falls back to defaults.
|
|
168
|
+
|
|
123
169
|
### Output (compact)
|
|
124
170
|
|
|
125
171
|
```json
|
|
@@ -172,9 +218,9 @@ const results = await Promise.all([
|
|
|
172
218
|
- **Optional enhancement 2 (SPA page rendering)**: run `npm i playwright && npx playwright install chromium` in the DSH profile directory to auto-enable it. When the extracted body is empty and the page is script-heavy (likely Vue/React client-rendered), the plugin automatically renders it with headless Chromium before extracting (a `rendered` flag tells the model); rendering waits for the DOM to stabilize (content stops growing) instead of `networkidle` — heartbeat-polling sites never idle, so this avoids 30s timeouts; when not installed it degrades with a clear install hint, never errors — the core stays zero-dependency;
|
|
173
219
|
- **Boundaries**: login-walled pages are not readable; SPA pages need the Playwright enhancement; **structured data (e.g. which like-count belongs to which comment) is out of text-extraction scope** — this plugin flattens HTML into readable text, so exact field↔value associations are lost; for precise fields, intercept the page's actual data API (see "Real-world validation" below).
|
|
174
220
|
|
|
175
|
-
## Real-world validation (2026-08-19, v0.4.
|
|
221
|
+
## Real-world validation (2026-08-19, v0.4.8)
|
|
176
222
|
|
|
177
|
-
|
|
223
|
+
41-site sweep driven by `multi-site.mjs` (committed, re-runnable): **23 OK / 8 expected boundaries / 10 network·anti-bot boundaries / 0 crashes** — overseas sites (BBC/V2EX) served in ~1s via the direct+proxy race; doc sites (vuejs.org) fixed via bare-`<main>` picking; wikipedia/httpbin remain clear attributed network boundaries.
|
|
178
224
|
|
|
179
225
|
| Category | Sites | Result |
|
|
180
226
|
|---|---|---|
|
|
@@ -189,7 +235,7 @@ const results = await Promise.all([
|
|
|
189
235
|
| Batch + failure isolation | 4-URL mix | ✅ 2/4 ok, failures isolated |
|
|
190
236
|
| Site crawl | Ruan Yifeng blog | ✅ 5/5 pages tree map |
|
|
191
237
|
|
|
192
|
-
- **
|
|
238
|
+
- **42 zero-dep assertions** (incl. entity decoding, description-budget guard, link dedupe, table-separator escaping, proxy-fallback function, missing-args tolerance, race logic, empty-race guard, schema budget, bare-main pick, worst-case timeout budgets, yml string coercion + clamping, strict-host seam degradation) + **10 SPA-test assertions** all green;
|
|
193
239
|
- 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.
|
|
194
240
|
|
|
195
241
|
## Roadmap
|
package/README.zh.md
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-read-url)
|
|
8
|
+
[](https://github.com/2672243194/dsh-read-url/blob/main/LICENSE)
|
|
9
|
+
|
|
7
10
|
DeepSeek Harness 的 URL 阅读插件:抓取任意网页,**自动识别编码(GBK/GB2312/UTF-8/Big5)**,提取干净正文,输出**省 token 的紧凑文本或结构化 Markdown**。
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
核心零运行时依赖(Node 20+ 内置能力完成抓取/转码/提取),免 API key,免服务端,装完即用。
|
|
10
13
|
|
|
11
14
|
## 为什么做它
|
|
12
15
|
|
|
@@ -24,7 +27,7 @@ DSH 的 Agent 能搜索(返回链接和片段),但缺"把 URL 读成干净
|
|
|
24
27
|
| `ctx.effect` 卸载清理 | ✅ | ❌ | ❌ | ✅ |
|
|
25
28
|
| 协作式超时(不暴露给模型) | ✅ | ⚠️ 自管 | ⚠️ 自管 | ✅ `timeoutMs` + `exec.signal` |
|
|
26
29
|
| 模型视角输出 | 整页 Markdown | 紧凑文本 | 15 字段 JSON | **紧凑文本(无需解析 JSON)** |
|
|
27
|
-
| 依赖 | 官方 | TS 需构建 | 零依赖 |
|
|
30
|
+
| 依赖 | 官方 | TS 需构建 | 零依赖 | 核心零运行时依赖(JS ESM 即装即用) |
|
|
28
31
|
| 反爬/降级响应(UA 与 TLS 指纹) | ⚠️ Node 默认 UA,实测 https 被中间设备按 TLS 指纹拦截、百度返回无热搜的降级版 | ❓ 未披露 | ❓ 未披露 | ✅ 完整浏览器 UA,实测获取完整版页面(百度热搜正常) |
|
|
29
32
|
|
|
30
33
|
> 2026-08-16 实测(本机环境):停用本插件后用官方 `web_fetch` 读 `https://www.baidu.com`——TLS 握手被中间设备按程序指纹拦截(退回 http 才成功),且百度对 Node UA 返回**服务端降级版**(热搜词条改由 JS 异步加载,静态 HTML 不含);换回 `dsh-read-url` 后 https 正常、热搜完整可读。差异根因:请求的 UA 与 TLS 特征决定网站/中间设备是否按 bot 处理。
|
|
@@ -60,6 +63,45 @@ npx @deepseek-ai/dsh plugin --profile web add ./dsh-read-url
|
|
|
60
63
|
同时读一下这几个网址,对比它们的观点:<url1> <url2> <url3>
|
|
61
64
|
```
|
|
62
65
|
|
|
66
|
+
### 真实案例(实测数据)
|
|
67
|
+
|
|
68
|
+
**1. 省 token——只返回模型真正需要的内容**
|
|
69
|
+
|
|
70
|
+
门户页 `read_url` 返回按 `maxChars`(默认 6000)截断的干净正文——不是带导航/广告/页脚的原始页面。重复读取命中 5 分钟会话缓存(`(cached)`),模型不会重复抓取:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
title: 新闻中心首页_新浪网
|
|
74
|
+
charset utf-8
|
|
75
|
+
(chars 800/12398 — 截断,offset 续读)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**2. 海外站——直连 + 代理并发竞速**
|
|
79
|
+
|
|
80
|
+
检测到代理时,直连与代理 `curl` 同时发起、先完成者胜。原本被墙的海外站(直连超时 + 回退约 11s)现在 1 秒内读到:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
BBC 中文: OK (633ms) — 4000+ 字符干净头条新闻
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**3. 长文续读(`offset`)**
|
|
87
|
+
|
|
88
|
+
1.2 万字符的文章分片读取,`offset` 从缓存续读且不重复前文——模型上下文只保留需要的部分:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
chars 800+800/12398 · cached
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**4. 跨页批量研究**
|
|
95
|
+
|
|
96
|
+
`read_url_batch` 并行读最多 10 页(并发 4),逐页净化、失败隔离:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
读取 2/4 页成功,2 页失败
|
|
100
|
+
--- 阮一峰的网络日志 (491 字符) ---
|
|
101
|
+
--- Example Domain (127 字符) ---
|
|
102
|
+
[失败] https://zh.wikipedia.org/... — Fetch failed: HTTP 403 ...
|
|
103
|
+
```
|
|
104
|
+
|
|
63
105
|
### 工具
|
|
64
106
|
|
|
65
107
|
**`read_url(url, maxChars?, offset?, mode?, includeLinks?)`** — 抓取并提取干净正文
|
|
@@ -108,19 +150,23 @@ npx @deepseek-ai/dsh plugin --profile web add ./dsh-read-url
|
|
|
108
150
|
|
|
109
151
|
### 配置(可选)
|
|
110
152
|
|
|
111
|
-
|
|
153
|
+
通过 profile 的 `cordis.patch.yml` 覆盖(默认值见插件自带 `cordis.patch.yml`):
|
|
112
154
|
|
|
113
155
|
```yaml
|
|
114
156
|
- id: dsh-read-url
|
|
115
157
|
config:
|
|
116
|
-
timeoutMs: 15000 #
|
|
158
|
+
timeoutMs: 15000 # 单请求超时(500-120000,自动钳制)
|
|
117
159
|
maxBytes: 3145728 # 响应体上限(字节)
|
|
118
160
|
maxChars: 6000 # 默认正文截断
|
|
119
161
|
maxLinks: 20 # read_url_links 默认条数
|
|
120
162
|
spaRender: true # SPA 渲染增强(需 playwright 已安装,未装自动降级提示)
|
|
121
163
|
userAgent: '...' # 请求 UA
|
|
164
|
+
cacheTtlMs: 300000 # 成功缓存 TTL
|
|
165
|
+
cacheMax: 32 # 缓存条目上限
|
|
122
166
|
```
|
|
123
167
|
|
|
168
|
+
配置在加载时统一强转数字并钳制到合理范围——YAML 里加引号的数字也能用,非法值回退默认。
|
|
169
|
+
|
|
124
170
|
### 输出结构(紧凑)
|
|
125
171
|
|
|
126
172
|
```json
|
|
@@ -173,9 +219,9 @@ const results = await Promise.all([
|
|
|
173
219
|
- **可选增强二(SPA 页面渲染)**:在 DSH profile 目录执行 `npm i playwright && npx playwright install chromium` 后自动启用。检测到正文为空且页面脚本密集(疑似 Vue/React 客户端渲染)时,自动用无头 Chromium 渲染后再提取(`rendered` 标记告知模型);渲染采用 `domcontentloaded` + **DOM 稳定轮询**(内容停止增长即收,上限 10s)而非 `networkidle`——心跳轮询站永不空闲,避免 30s 超时;未安装时优雅提示安装方法、不报错——核心保持零依赖;
|
|
174
220
|
- **边界**:登录墙页面无法读取;SPA 页面需安装 Playwright 增强后渲染读取(未安装时返回明确提示);**结构化数据(如评论的点赞数归属、榜单数值)不在文本提取范围**——本插件把 HTML 扁平化为可读文本,字段与数值的精确对应关系会丢失;需要精确字段时,用 Playwright 拦截页面实际调用的数据 API 获取(见下方「真实世界验证」)。
|
|
175
221
|
|
|
176
|
-
## 真实世界验证(2026-08-19,v0.4.
|
|
222
|
+
## 真实世界验证(2026-08-19,v0.4.8)
|
|
177
223
|
|
|
178
|
-
|
|
224
|
+
41 站全量实测(`multi-site.mjs` 已提交可复跑):**23 OK / 8 预期边界 / 10 网络·反爬边界 / 0 崩溃**——海外站(BBC/V2EX)经竞速通道 ~1s 读回;文档站(vuejs.org)经裸 `<main>` 修复后正文聚焦;wikipedia/httpbin 保持清晰的网络边界归因。
|
|
179
225
|
|
|
180
226
|
| 类别 | 站点 | 结果 |
|
|
181
227
|
|---|---|---|
|
|
@@ -190,7 +236,7 @@ const results = await Promise.all([
|
|
|
190
236
|
| **批量 + 失败隔离** | 4 URL 混合 | ✅ 2/4 成功、失败隔离 |
|
|
191
237
|
| **整站爬取** | 阮一峰博客 | ✅ 5/5 页树状站点地图 |
|
|
192
238
|
|
|
193
|
-
- **
|
|
239
|
+
- **42 个单元断言**(含实体解码、description/schema 预算守卫、链接去重、表格分隔行转义、代理回退函数、空参容错、竞速逻辑、空竞速守卫、裸 main 提取、最坏链路超时预算、yml 字符串强转+钳制、严格宿主 seam 降级)+ **10 个 SPA 测试断言**全绿;
|
|
194
240
|
- 一个真实案例:小黑盒帖子的评论点赞数(`up` 字段)无法从扁平文本确定归属——**精确字段应走页面背后的数据 API**(如 `/bbs/app/link/tree` JSON),这是同类文本提取器的共同边界,不是缺陷。
|
|
195
241
|
|
|
196
242
|
## Roadmap
|
|
@@ -203,7 +249,7 @@ const results = await Promise.all([
|
|
|
203
249
|
## 开发
|
|
204
250
|
|
|
205
251
|
```bash
|
|
206
|
-
node test.mjs #
|
|
252
|
+
node test.mjs # 单元自测(转码/提取/Markdown/截断/批量/站点爬取/缓存隔离/配置钳制)
|
|
207
253
|
|
|
208
254
|
# SPA 渲染真实测试(需 playwright 已安装,未装自动 SKIP)
|
|
209
255
|
node test-spa.mjs # 10 断言:JS 正文/渲染后链接/工具不崩溃/缓存隔离
|
package/index.js
CHANGED
|
@@ -32,6 +32,8 @@ const DEFAULTS = {
|
|
|
32
32
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Session-level cache: url -> { time, full } on success, { time, error } on
|
|
36
|
+
// failure (short TTL so a broken URL is not re-fetched in a loop).
|
|
35
37
|
const cache = new Map()
|
|
36
38
|
const decoders = new Map()
|
|
37
39
|
|
|
@@ -85,16 +87,21 @@ async function directFetch(url, signal, cfg) {
|
|
|
85
87
|
signal,
|
|
86
88
|
headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
|
|
87
89
|
})
|
|
88
|
-
|
|
90
|
+
// Early-return paths must cancel the body stream, or the connection is
|
|
91
|
+
// held open undrained and cannot return to the keep-alive pool.
|
|
92
|
+
const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
|
|
93
|
+
if (!res.ok) { discardBody(); return { error: `HTTP ${res.status} ${res.statusText}` } }
|
|
89
94
|
const contentType = res.headers.get('content-type') || ''
|
|
90
95
|
if (contentType && !/text\/html|application\/xhtml|text\/plain/i.test(contentType)) {
|
|
96
|
+
discardBody()
|
|
91
97
|
return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
|
|
92
98
|
}
|
|
99
|
+
if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url }
|
|
93
100
|
const chunks = []
|
|
94
101
|
let size = 0
|
|
95
102
|
for await (const chunk of res.body) {
|
|
96
103
|
size += chunk.length
|
|
97
|
-
if (size > cfg.maxBytes) return { error: `Page exceeds ${cfg.maxBytes} bytes` }
|
|
104
|
+
if (size > cfg.maxBytes) { discardBody(); return { error: `Page exceeds ${cfg.maxBytes} bytes` } }
|
|
98
105
|
chunks.push(chunk)
|
|
99
106
|
}
|
|
100
107
|
return { buffer: Buffer.concat(chunks), contentType, finalUrl: res.url }
|
|
@@ -178,9 +185,11 @@ async function fetchPage(url, externalSignal, cfg) {
|
|
|
178
185
|
// Seam calls get the same cooperative timeout as fetchPage (a hanging
|
|
179
186
|
// provider should not block the tool call forever).
|
|
180
187
|
async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
|
|
181
|
-
const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
|
|
182
|
-
if (!web || typeof web.fetch !== 'function') return null
|
|
183
188
|
try {
|
|
189
|
+
// ctx.get for a non-injected service throws on strict cordis hosts (seen
|
|
190
|
+
// with 'settings') — treat that as "seam absent" and fall through.
|
|
191
|
+
const web = ctx && typeof ctx.get === 'function' ? ctx.get('web') : undefined
|
|
192
|
+
if (!web || typeof web.fetch !== 'function') return null
|
|
184
193
|
const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs)
|
|
185
194
|
const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal
|
|
186
195
|
const res = await web.fetch(url, { signal })
|
|
@@ -241,7 +250,13 @@ export function pickMain(html) {
|
|
|
241
250
|
// collect all of them instead of only the first.
|
|
242
251
|
const articles = [...html.matchAll(/<article[\s>][\s\S]*?<\/article\s*>/gi)]
|
|
243
252
|
if (articles.length) return articles.map((a) => a[0]).join('\n')
|
|
244
|
-
const mainTag =
|
|
253
|
+
const mainTag =
|
|
254
|
+
/<(main|div)[^>]*role=["']main["'][\s>][\s\S]*?<\/\1\s*>/i.exec(html) ||
|
|
255
|
+
// Bare <main> without role="main": doc generators (VitePress/VuePress,
|
|
256
|
+
// MDN, MDX sites) mark the body with a plain <main> tag. Falling back to
|
|
257
|
+
// the whole <body> would drag in the top/local nav (menu, outline, skip
|
|
258
|
+
// link) — the extraction quality loss we saw on vuejs.org.
|
|
259
|
+
/<main[\s>][\s\S]*?<\/main\s*>/i.exec(html)
|
|
245
260
|
if (mainTag) return mainTag[0]
|
|
246
261
|
const body = /<body[\s>]/i.exec(html)
|
|
247
262
|
if (body) {
|
|
@@ -649,9 +664,13 @@ function renderResult(value) {
|
|
|
649
664
|
if (r.cached) lines.push('(cached)')
|
|
650
665
|
if (r.rendered) lines.push('(rendered — JS 执行后内容)')
|
|
651
666
|
if (!r.text) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
667
|
+
if (r.charsStart > 0 && !r.truncated) {
|
|
668
|
+
lines.push('(offset 已到文末,无更多内容)')
|
|
669
|
+
} else {
|
|
670
|
+
lines.push(r.spaHint
|
|
671
|
+
? `(无可读内容 — ${r.spaHint})`
|
|
672
|
+
: '(无可读内容 — 登录墙 / SPA 页 / 空页面;SPA 需安装 playwright)')
|
|
673
|
+
}
|
|
655
674
|
}
|
|
656
675
|
lines.push('', r.text || '')
|
|
657
676
|
if (Array.isArray(r.links) && r.links.length) {
|
|
@@ -686,7 +705,8 @@ function readLinksTool(ctx, cfg) {
|
|
|
686
705
|
},
|
|
687
706
|
required: ['url'],
|
|
688
707
|
},
|
|
689
|
-
|
|
708
|
+
// Covers SPA-render fallback: full fetch timeout + playwright render cap.
|
|
709
|
+
timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
|
|
690
710
|
output: {
|
|
691
711
|
schema: {
|
|
692
712
|
type: 'object',
|
|
@@ -785,7 +805,8 @@ function readUrlBatchTool(ctx, cfg) {
|
|
|
785
805
|
},
|
|
786
806
|
required: ['urls'],
|
|
787
807
|
},
|
|
788
|
-
|
|
808
|
+
// Worst case: ceil(10/4)=3 concurrency waves, each (fetch + SPA render).
|
|
809
|
+
timeoutMs: Math.max(60000, cfg.timeoutMs * 3 + 135000),
|
|
789
810
|
output: {
|
|
790
811
|
schema: {
|
|
791
812
|
type: 'object',
|
|
@@ -871,7 +892,9 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
|
|
|
871
892
|
const pages = []
|
|
872
893
|
const failures = []
|
|
873
894
|
const queue = [{ url: entryUrl, depth: 0 }]
|
|
874
|
-
|
|
895
|
+
// Stop once the host's cooperative signal fires (tool budget hit): without
|
|
896
|
+
// this the loop keeps draining the queue on instantly-aborted fetches.
|
|
897
|
+
while (queue.length && pages.length < maxPages && !(externalSignal && externalSignal.aborted)) {
|
|
875
898
|
// Never overshoot the page budget within a batch round.
|
|
876
899
|
const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
|
|
877
900
|
const results = await Promise.all(
|
|
@@ -951,7 +974,9 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
951
974
|
},
|
|
952
975
|
required: ['url'],
|
|
953
976
|
},
|
|
954
|
-
|
|
977
|
+
// Worst case: ceil(maxPages/2)=25 waves of paired fetches (no SPA render
|
|
978
|
+
// here); the cooperative signal stops the loop early once the budget is hit.
|
|
979
|
+
timeoutMs: Math.max(120000, cfg.timeoutMs * 25),
|
|
955
980
|
output: {
|
|
956
981
|
schema: {
|
|
957
982
|
type: 'object',
|
|
@@ -996,7 +1021,22 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
996
1021
|
|
|
997
1022
|
export function apply(ctx, config) {
|
|
998
1023
|
// Plugin-level config from cordis.patch.yml (config row), merged over defaults.
|
|
1024
|
+
// YAML is free-form: numbers can arrive quoted as strings, which would poison
|
|
1025
|
+
// arithmetic and AbortSignal timeouts downstream — coerce to numbers once
|
|
1026
|
+
// here, clamp to sane ranges, and fall back to defaults on garbage.
|
|
999
1027
|
const cfg = { ...DEFAULTS, ...(config || {}) }
|
|
1028
|
+
const clamp = (v, d, min, max) => {
|
|
1029
|
+
const n = Number(v)
|
|
1030
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d
|
|
1031
|
+
}
|
|
1032
|
+
cfg.timeoutMs = clamp(cfg.timeoutMs, DEFAULTS.timeoutMs, 500, 120000)
|
|
1033
|
+
cfg.maxBytes = clamp(cfg.maxBytes, DEFAULTS.maxBytes, 65536, 16 * 1024 * 1024)
|
|
1034
|
+
cfg.maxChars = clamp(cfg.maxChars, DEFAULTS.maxChars, 500, 20000)
|
|
1035
|
+
cfg.maxLinks = clamp(cfg.maxLinks, DEFAULTS.maxLinks, 1, 50)
|
|
1036
|
+
cfg.cacheTtlMs = clamp(cfg.cacheTtlMs, DEFAULTS.cacheTtlMs, 0, 3600000)
|
|
1037
|
+
cfg.cacheMax = clamp(cfg.cacheMax, DEFAULTS.cacheMax, 1, 256)
|
|
1038
|
+
if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
|
|
1039
|
+
if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
|
|
1000
1040
|
|
|
1001
1041
|
// Temporal composability: unload must fully revert side effects.
|
|
1002
1042
|
ctx.effect(() => () => {
|
|
@@ -1025,7 +1065,10 @@ export function apply(ctx, config) {
|
|
|
1025
1065
|
},
|
|
1026
1066
|
required: ['url'],
|
|
1027
1067
|
},
|
|
1028
|
-
|
|
1068
|
+
// Pipeline budget must cover the real worst case: full fetch timeout
|
|
1069
|
+
// (cfg.timeoutMs) + SPA render (playwright goto 30s cap + DOM-stability
|
|
1070
|
+
// poll 10s) + margin. Derived from load-time cfg — scale with it, never clamp it.
|
|
1071
|
+
timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
|
|
1029
1072
|
output: {
|
|
1030
1073
|
schema: {
|
|
1031
1074
|
type: 'object',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "DeepSeek Harness URL reader: fetch any webpage, auto-detect encoding (GBK/GB2312/UTF-8/Big5), extract clean main content, output compact text/Markdown to save tokens. Zero runtime dependencies.",
|
|
3
|
+
"version": "0.4.9",
|
|
4
|
+
"description": "DeepSeek Harness URL reader: fetch any webpage, auto-detect encoding (GBK/GB2312/UTF-8/Big5), extract clean main content, output compact text/Markdown to save tokens. Zero runtime dependencies, no API key, drop-in.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
package/proxy-fallback.js
CHANGED
|
@@ -16,14 +16,21 @@
|
|
|
16
16
|
import { execFile, execFileSync } from 'node:child_process'
|
|
17
17
|
|
|
18
18
|
let sysProxyCache = null // '' = checked & absent; string = proxy URL
|
|
19
|
+
let sysProxyCheckedAt = 0 // cache timestamp; a failed read is re-tried after 60s
|
|
20
|
+
const SYS_PROXY_RETRY_MS = 60 * 1000
|
|
19
21
|
|
|
20
22
|
// Windows system proxy from the registry (HKCU Internet Settings). PowerShell
|
|
21
23
|
// is used because `reg.exe` is frequently blocked by security policy; the
|
|
22
24
|
// read is synchronous and cached after the first call (it only runs after a
|
|
23
25
|
// direct-connect failure, so the ~1s cost is invisible on the happy path).
|
|
26
|
+
// A FAILED read is cached as '' but re-tried after 60s — an environment that
|
|
27
|
+
// transiently blocks the PowerShell child (EDR, policy) should not starve
|
|
28
|
+
// the whole session of the proxy.
|
|
24
29
|
function readWindowsSystemProxy() {
|
|
25
|
-
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
if (sysProxyCache !== null && now - sysProxyCheckedAt < SYS_PROXY_RETRY_MS) return sysProxyCache
|
|
26
32
|
sysProxyCache = ''
|
|
33
|
+
sysProxyCheckedAt = now
|
|
27
34
|
try {
|
|
28
35
|
const out = execFileSync(
|
|
29
36
|
'powershell',
|
|
@@ -38,10 +45,11 @@ function readWindowsSystemProxy() {
|
|
|
38
45
|
// ProxyServer may be "host:port" or "http=...;https=host:port"
|
|
39
46
|
const httpsPart = server.split(';').map((s) => s.trim()).find((s) => /^https=/i.test(s))
|
|
40
47
|
sysProxyCache = (httpsPart ? httpsPart.replace(/^https=/i, '') : server) || ''
|
|
48
|
+
return sysProxyCache
|
|
41
49
|
} catch {
|
|
42
50
|
sysProxyCache = ''
|
|
51
|
+
return sysProxyCache
|
|
43
52
|
}
|
|
44
|
-
return sysProxyCache
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
// The user's proxy, in priority order: env vars, then Windows system proxy.
|
package/spa.js
CHANGED
|
@@ -20,8 +20,14 @@ export function looksLikeSpa(html) {
|
|
|
20
20
|
|
|
21
21
|
async function getBrowser() {
|
|
22
22
|
if (!browserPromise) {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// A failed launch (playwright later installed / chromium download finished)
|
|
24
|
+
// must not stay cached as a rejected promise — reset so the next call retries.
|
|
25
|
+
browserPromise = import('playwright')
|
|
26
|
+
.then(({ chromium }) => chromium.launch({ headless: true }))
|
|
27
|
+
.catch((e) => {
|
|
28
|
+
browserPromise = null
|
|
29
|
+
throw e
|
|
30
|
+
})
|
|
25
31
|
}
|
|
26
32
|
return browserPromise
|
|
27
33
|
}
|
|
@@ -73,9 +79,13 @@ export async function renderPage(url, externalSignal) {
|
|
|
73
79
|
|
|
74
80
|
// Called on plugin unload (temporal composability): release the browser.
|
|
75
81
|
export async function closeBrowser() {
|
|
76
|
-
if (browserPromise)
|
|
77
|
-
|
|
82
|
+
if (!browserPromise) return
|
|
83
|
+
const p = browserPromise
|
|
84
|
+
browserPromise = null
|
|
85
|
+
try {
|
|
86
|
+
const b = await p
|
|
78
87
|
await b.close().catch(() => {})
|
|
79
|
-
|
|
88
|
+
} catch {
|
|
89
|
+
// launch never completed — nothing to close
|
|
80
90
|
}
|
|
81
91
|
}
|