dsh-read-url 0.4.8 → 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 +18 -0
- package/README.md +8 -4
- package/README.zh.md +10 -6
- package/index.js +49 -12
- package/package.json +2 -2
- package/spa.js +15 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
3
21
|
## [0.4.8] - 2026-08-19
|
|
4
22
|
|
|
5
23
|
### 41 站多类型实测 + 提取修复
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
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**.
|
|
11
11
|
|
|
12
|
-
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.
|
|
13
13
|
|
|
14
14
|
## Why
|
|
15
15
|
|
|
@@ -149,19 +149,23 @@ chars 800+800/12398 · cached
|
|
|
149
149
|
|
|
150
150
|
### Configuration (optional)
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
Override via the profile's `cordis.patch.yml` (defaults in the plugin's own `cordis.patch.yml`):
|
|
153
153
|
|
|
154
154
|
```yaml
|
|
155
155
|
- id: dsh-read-url
|
|
156
156
|
config:
|
|
157
|
-
timeoutMs: 15000 # per-request timeout
|
|
157
|
+
timeoutMs: 15000 # per-request timeout (500-120000, clamped)
|
|
158
158
|
maxBytes: 3145728 # response body cap (bytes)
|
|
159
159
|
maxChars: 6000 # default body truncation
|
|
160
160
|
maxLinks: 20 # read_url_links default count
|
|
161
161
|
spaRender: true # SPA rendering enhancement (needs playwright installed; degrades with a hint otherwise)
|
|
162
162
|
userAgent: '...' # request UA
|
|
163
|
+
cacheTtlMs: 300000 # success-cache TTL
|
|
164
|
+
cacheMax: 32 # cache entry cap
|
|
163
165
|
```
|
|
164
166
|
|
|
167
|
+
Values are coerced and clamped to sane ranges at load — quoted numbers in YAML work, garbage falls back to defaults.
|
|
168
|
+
|
|
165
169
|
### Output (compact)
|
|
166
170
|
|
|
167
171
|
```json
|
|
@@ -231,7 +235,7 @@ const results = await Promise.all([
|
|
|
231
235
|
| Batch + failure isolation | 4-URL mix | ✅ 2/4 ok, failures isolated |
|
|
232
236
|
| Site crawl | Ruan Yifeng blog | ✅ 5/5 pages tree map |
|
|
233
237
|
|
|
234
|
-
- **
|
|
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;
|
|
235
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.
|
|
236
240
|
|
|
237
241
|
## Roadmap
|
package/README.zh.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
DeepSeek Harness 的 URL 阅读插件:抓取任意网页,**自动识别编码(GBK/GB2312/UTF-8/Big5)**,提取干净正文,输出**省 token 的紧凑文本或结构化 Markdown**。
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
核心零运行时依赖(Node 20+ 内置能力完成抓取/转码/提取),免 API key,免服务端,装完即用。
|
|
13
13
|
|
|
14
14
|
## 为什么做它
|
|
15
15
|
|
|
@@ -27,7 +27,7 @@ DSH 的 Agent 能搜索(返回链接和片段),但缺"把 URL 读成干净
|
|
|
27
27
|
| `ctx.effect` 卸载清理 | ✅ | ❌ | ❌ | ✅ |
|
|
28
28
|
| 协作式超时(不暴露给模型) | ✅ | ⚠️ 自管 | ⚠️ 自管 | ✅ `timeoutMs` + `exec.signal` |
|
|
29
29
|
| 模型视角输出 | 整页 Markdown | 紧凑文本 | 15 字段 JSON | **紧凑文本(无需解析 JSON)** |
|
|
30
|
-
| 依赖 | 官方 | TS 需构建 | 零依赖 |
|
|
30
|
+
| 依赖 | 官方 | TS 需构建 | 零依赖 | 核心零运行时依赖(JS ESM 即装即用) |
|
|
31
31
|
| 反爬/降级响应(UA 与 TLS 指纹) | ⚠️ Node 默认 UA,实测 https 被中间设备按 TLS 指纹拦截、百度返回无热搜的降级版 | ❓ 未披露 | ❓ 未披露 | ✅ 完整浏览器 UA,实测获取完整版页面(百度热搜正常) |
|
|
32
32
|
|
|
33
33
|
> 2026-08-16 实测(本机环境):停用本插件后用官方 `web_fetch` 读 `https://www.baidu.com`——TLS 握手被中间设备按程序指纹拦截(退回 http 才成功),且百度对 Node UA 返回**服务端降级版**(热搜词条改由 JS 异步加载,静态 HTML 不含);换回 `dsh-read-url` 后 https 正常、热搜完整可读。差异根因:请求的 UA 与 TLS 特征决定网站/中间设备是否按 bot 处理。
|
|
@@ -150,19 +150,23 @@ chars 800+800/12398 · cached
|
|
|
150
150
|
|
|
151
151
|
### 配置(可选)
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
通过 profile 的 `cordis.patch.yml` 覆盖(默认值见插件自带 `cordis.patch.yml`):
|
|
154
154
|
|
|
155
155
|
```yaml
|
|
156
156
|
- id: dsh-read-url
|
|
157
157
|
config:
|
|
158
|
-
timeoutMs: 15000 #
|
|
158
|
+
timeoutMs: 15000 # 单请求超时(500-120000,自动钳制)
|
|
159
159
|
maxBytes: 3145728 # 响应体上限(字节)
|
|
160
160
|
maxChars: 6000 # 默认正文截断
|
|
161
161
|
maxLinks: 20 # read_url_links 默认条数
|
|
162
162
|
spaRender: true # SPA 渲染增强(需 playwright 已安装,未装自动降级提示)
|
|
163
163
|
userAgent: '...' # 请求 UA
|
|
164
|
+
cacheTtlMs: 300000 # 成功缓存 TTL
|
|
165
|
+
cacheMax: 32 # 缓存条目上限
|
|
164
166
|
```
|
|
165
167
|
|
|
168
|
+
配置在加载时统一强转数字并钳制到合理范围——YAML 里加引号的数字也能用,非法值回退默认。
|
|
169
|
+
|
|
166
170
|
### 输出结构(紧凑)
|
|
167
171
|
|
|
168
172
|
```json
|
|
@@ -232,7 +236,7 @@ const results = await Promise.all([
|
|
|
232
236
|
| **批量 + 失败隔离** | 4 URL 混合 | ✅ 2/4 成功、失败隔离 |
|
|
233
237
|
| **整站爬取** | 阮一峰博客 | ✅ 5/5 页树状站点地图 |
|
|
234
238
|
|
|
235
|
-
- **
|
|
239
|
+
- **42 个单元断言**(含实体解码、description/schema 预算守卫、链接去重、表格分隔行转义、代理回退函数、空参容错、竞速逻辑、空竞速守卫、裸 main 提取、最坏链路超时预算、yml 字符串强转+钳制、严格宿主 seam 降级)+ **10 个 SPA 测试断言**全绿;
|
|
236
240
|
- 一个真实案例:小黑盒帖子的评论点赞数(`up` 字段)无法从扁平文本确定归属——**精确字段应走页面背后的数据 API**(如 `/bbs/app/link/tree` JSON),这是同类文本提取器的共同边界,不是缺陷。
|
|
237
241
|
|
|
238
242
|
## Roadmap
|
|
@@ -245,7 +249,7 @@ const results = await Promise.all([
|
|
|
245
249
|
## 开发
|
|
246
250
|
|
|
247
251
|
```bash
|
|
248
|
-
node test.mjs #
|
|
252
|
+
node test.mjs # 单元自测(转码/提取/Markdown/截断/批量/站点爬取/缓存隔离/配置钳制)
|
|
249
253
|
|
|
250
254
|
# SPA 渲染真实测试(需 playwright 已安装,未装自动 SKIP)
|
|
251
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 })
|
|
@@ -655,9 +664,13 @@ function renderResult(value) {
|
|
|
655
664
|
if (r.cached) lines.push('(cached)')
|
|
656
665
|
if (r.rendered) lines.push('(rendered — JS 执行后内容)')
|
|
657
666
|
if (!r.text) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
+
}
|
|
661
674
|
}
|
|
662
675
|
lines.push('', r.text || '')
|
|
663
676
|
if (Array.isArray(r.links) && r.links.length) {
|
|
@@ -692,7 +705,8 @@ function readLinksTool(ctx, cfg) {
|
|
|
692
705
|
},
|
|
693
706
|
required: ['url'],
|
|
694
707
|
},
|
|
695
|
-
|
|
708
|
+
// Covers SPA-render fallback: full fetch timeout + playwright render cap.
|
|
709
|
+
timeoutMs: Math.max(45000, cfg.timeoutMs + 45000),
|
|
696
710
|
output: {
|
|
697
711
|
schema: {
|
|
698
712
|
type: 'object',
|
|
@@ -791,7 +805,8 @@ function readUrlBatchTool(ctx, cfg) {
|
|
|
791
805
|
},
|
|
792
806
|
required: ['urls'],
|
|
793
807
|
},
|
|
794
|
-
|
|
808
|
+
// Worst case: ceil(10/4)=3 concurrency waves, each (fetch + SPA render).
|
|
809
|
+
timeoutMs: Math.max(60000, cfg.timeoutMs * 3 + 135000),
|
|
795
810
|
output: {
|
|
796
811
|
schema: {
|
|
797
812
|
type: 'object',
|
|
@@ -877,7 +892,9 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal) {
|
|
|
877
892
|
const pages = []
|
|
878
893
|
const failures = []
|
|
879
894
|
const queue = [{ url: entryUrl, depth: 0 }]
|
|
880
|
-
|
|
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)) {
|
|
881
898
|
// Never overshoot the page budget within a batch round.
|
|
882
899
|
const batch = queue.splice(0, Math.min(2, maxPages - pages.length, queue.length))
|
|
883
900
|
const results = await Promise.all(
|
|
@@ -957,7 +974,9 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
957
974
|
},
|
|
958
975
|
required: ['url'],
|
|
959
976
|
},
|
|
960
|
-
|
|
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),
|
|
961
980
|
output: {
|
|
962
981
|
schema: {
|
|
963
982
|
type: 'object',
|
|
@@ -1002,7 +1021,22 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
1002
1021
|
|
|
1003
1022
|
export function apply(ctx, config) {
|
|
1004
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.
|
|
1005
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
|
|
1006
1040
|
|
|
1007
1041
|
// Temporal composability: unload must fully revert side effects.
|
|
1008
1042
|
ctx.effect(() => () => {
|
|
@@ -1031,7 +1065,10 @@ export function apply(ctx, config) {
|
|
|
1031
1065
|
},
|
|
1032
1066
|
required: ['url'],
|
|
1033
1067
|
},
|
|
1034
|
-
|
|
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),
|
|
1035
1072
|
output: {
|
|
1036
1073
|
schema: {
|
|
1037
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/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
|
}
|