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 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
- Plugin-level config is overridable via the profile's `cordis.patch.yml` (defaults in the plugin's own `cordis.patch.yml`):
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
- - **39 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) + **10 SPA-test assertions** all green;
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
- 零依赖(Node 20+ 内置能力),免 API key,免服务端,装完即用。
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 需构建 | 零依赖 | 零依赖(JS ESM 即装即用) |
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
- 插件级配置通过 profile 的 `cordis.patch.yml` 覆盖(默认值见插件自带 `cordis.patch.yml`):
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
- - **39 个零依赖断言**(含实体解码、description 预算守卫、链接去重、表格分隔行转义、代理回退函数、空参容错、竞速逻辑、空竞速守卫、schema 预算、裸 main 提取)+ **10 个 SPA 测试断言**全绿;
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 # 零依赖自测(转码/提取/Markdown/截断/批量/站点爬取/缓存隔离)
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
- if (!res.ok) return { error: `HTTP ${res.status} ${res.statusText}` }
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
- lines.push(r.spaHint
659
- ? `(无可读内容 — ${r.spaHint})`
660
- : '(无可读内容 — 登录墙 / SPA 页 / 空页面;SPA 需安装 playwright)')
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
- timeoutMs: 20000,
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
- timeoutMs: Math.max(30000, cfg.timeoutMs + 15000),
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
- while (queue.length && pages.length < maxPages) {
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
- timeoutMs: 120000,
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
- timeoutMs: Math.max(5000, cfg.timeoutMs + 5000),
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.8",
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
- const { chromium } = await import('playwright')
24
- browserPromise = chromium.launch({ headless: true })
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
- const b = await browserPromise
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
- browserPromise = null
88
+ } catch {
89
+ // launch never completed — nothing to close
80
90
  }
81
91
  }