dsh-read-url 1.3.1 → 1.4.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 CHANGED
@@ -1,6 +1,51 @@
1
1
  # Changelog
2
2
 
3
- ## [1.3.0] - 2026-08-24
3
+ ## [1.4.0] - 2026-08-28
4
+
5
+ > 省token主打轮。经实测复核,原方案中的「nav 导航块剔除」与「RSS/Atom 条目摘要」在 1.3.1 已就位(python-docs 实测 235K 全文 → 20K 内干净正文,导航零泄漏;feed 输出已含每条 ≤200 字符摘要),本轮聚焦真实缺口:隐藏元素剥离、URL 片段定位阅读、段落化正文。
6
+
7
+ ### URL 片段定位阅读(url#section-anchor)——token 大头
8
+
9
+ 超长参考页(MDN / Python docs 一页 20 万+ 字符)此前只能从文档头翻起。现在 `#fragment` 直接定位目标小节:
10
+
11
+ - **容器锚点**(`<section id>` / `<dl id>` 等)→ 深度计数平衡块提取(复用 tagBlockAt,正确处理嵌套),**精确切出该小节**
12
+ - **标题/内联锚点**(`<h2 id>`、`<dt id>`、`<a name>`)→ 从锚点起读到文末(标题引出其后正文),smartTruncate 兜底
13
+ - percent-encoded 中文锚点自动尝试解码形态;未命中锚点降级为全文(不报错)
14
+ - **cache key 保留 fragment**:`#a` 与 `#b` 各自独立缓存条目,绝不互串;offset 续读相对小节起点
15
+ - 命中锚点时跳过 readability 升级(其会以全文覆盖小节语义)与自动分页(拼接后续整页违背定位意图);输出 meta 行加 `#anchor` 标记说明正文起点
16
+ - 实测:python-docs stdtypes.html `#str.startswith` **235,393 → 111,587 字符(-52.6%)**,且默认 6000 字符窗口直接落在目标方法而非文档头
17
+
18
+ ### 隐藏元素与同意弹窗剥离
19
+
20
+ SPA 装饰树(`style="display:none"`)、`hidden` 属性、`aria-hidden="true"`、OneTrust/Cookiebot/GDPR 等 id 挂载的同意横幅此前全部漏网进正文。新增两条有界正则过滤器:
21
+
22
+ - 覆盖 `hidden`(裸属性与带值)、`aria-hidden="true"`(false 保留)、`style` 含 `display:none` / `visibility:hidden`
23
+ - consent id 匹配:`onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-`(大小写不敏感)
24
+ - **`[\s"']` 属性界卫兵**:`data-hidden` / `data-id` 等复合属性名不误伤
25
+ - 未闭合的隐藏元素安全降级为保留内容(lazy 扫描 50K 有界,探针实测 4.9ms)
26
+
27
+ ### 正文段落化(textLines)
28
+
29
+ text 模式原先把全文压成单行平文本,offset 续读从句子中间切开。现在块级边界成为段落分隔:
30
+
31
+ - 标题独立成行、`<br>` 保单换行,段落以 `\n\n` 分隔——smartTruncate 的段落对齐真正生效,续读切口落在真实段落边界
32
+ - 标题行尾的永久链接装饰符(`¶` / `§`)剥离(text 与 markdown 双模式)
33
+ - readability 升级路径的 text 输出同步走段落化(可选增强安装时行为一致)
34
+
35
+ ### 其他
36
+
37
+ - 正文 <60 字符且无渲染提示的页面补一条恒定成本的提示行(「正文极短(可能登录墙或反爬拦截)」),模型不再对 40 字符页面盲目 offset 续读
38
+ - `renderResult` 导出(供测试断言输出文案)
39
+
40
+ ### 验证
41
+
42
+ - 单元断言 **126 → 150**(+24:隐藏/consent 剥离 11、锚点切片 8 含 e2e 缓存隔离与 offset 语义、段落接缝 2、装饰符 1、短正文提示 1、既有回归 1)+ 12 SPA 断言全绿
43
+ - probe.mjs 对抗探针 +7(3000 隐藏 div、未闭合隐藏、2000 consent 横幅、超长 style 前缀、正则特殊字符锚点、4000 层嵌套平衡块、畸形百分号解码)全绿,最慢 4.9ms
44
+ - 实网:python-docs 锚点定位实测(上文数据);152 站回归基线不变
45
+
46
+ ## [1.3.1] - 2026-08-25
47
+
48
+ > 注:v1.3.0(2026-08-24 开发完成)未独立发布——三项工作(v1.3.0 四项改进 + 08-25 健壮性轮 + 审查收尾轮)合并发布为 1.3.1。以下内容即该合并版本的完整变更。
4
49
 
5
50
  ### 四项改进:NUL 防护 + JSON-LD 元数据 + base href + meta-refresh 跟随
6
51
 
package/README.md CHANGED
@@ -210,19 +210,22 @@ const results = await Promise.all([
210
210
  ## Token economy (core)
211
211
 
212
212
  1. **Body text only by default** — no redundant headings/keywords/images/word-count fields; take them via params only when needed;
213
- 2. **Paragraph-aligned truncation + offset continuation** — 6,000 chars by default (~3,000 tokens), cut at paragraph boundaries to keep semantics; output notes a single line `(chars 6000/12990 — truncated, continue via offset)`; resume starts at the given offset, sliced from cache — **no repetition of already-read text** (measured 0+500 → 500+500, no overlap); offset past the end returns empty instead of repeating the head;
214
- 3. **`text` mode first** — Markdown structure is opt-in;
215
- 4. **Compact text render** — the model sees a `title:` header + body directly, no JSON parsing; `siteName` is omitted when identical to the hostname; every status hint is one short line (truncated / cached / rendered), no verbose paragraphs;
216
- 5. **Two-tier cache** — successful results cached per URL for 5 minutes (repeat reads hit cache: fewer network calls and fewer model retries); **failed results cached for 30 seconds** so a broken URL never triggers a re-fetch loop;
217
- 6. **KV-cache friendly (DeepSeek cost tuning)** — tool schema/description stay **static text** (no config values embedded), so changing config never invalidates the reusable prompt prefix and KV cache keeps hitting. DeepSeek's cache-hit tokens cost about 1/10 of misses — the more stable the prefix, the cheaper the run (same analysis as the official `tool-web` docs);
218
- 7. **Batch shares the cache** — `read_url_batch` reuses the same cache (repeat batches hit it directly) and caps each page at 3,000 chars (below the single-page 6,000) to bound total output;
219
- 8. **Compact fixed cost** — the four tool descriptions total ~900 chars (audited by a budget assertion in tests, kept static for KV-cache); extended HTML-entity decoding (45 named entities) means leftovers like `&mdash;`/`&hellip;` never waste tokens or render as mojibake.
213
+ 2. **Paragraph-aligned truncation + offset continuation** — 6,000 chars by default (~3,000 tokens), cut at paragraph boundaries to keep semantics (text mode separates paragraphs with `\n\n`, so the cut lands on a real boundary, not mid-sentence); output notes a single line `(chars 6000/12990 — truncated, continue via offset)`; resume starts at the given offset, sliced from cache — **no repetition of already-read text** (measured 0+500 → 500+500, no overlap); offset past the end returns empty instead of repeating the head;
214
+ 3. **URL fragment reading (v1.4.0)** — `url#section-anchor` jumps straight to the target section of a long reference page: container anchors slice out exactly that block (depth-counted balanced extraction), heading anchors read from the heading onward; the cache key keeps the fragment so `#a`/`#b` never cross-pollute, and offset continues relative to the section start. Measured on python-docs `#str.startswith`: **235,393 → 111,587 chars (-52.6%)**, the default 6000-char window landing on the target method instead of the document head;
215
+ 4. **Hidden-element & consent-banner stripping (v1.4.0)** — `display:none`/`visibility:hidden` decoration trees, the bare `hidden` attribute, `aria-hidden="true"`, and OneTrust/Cookiebot/GDPR banners mounted by id no longer leak into the body; a bounded attribute-separator guard keeps compound names (`data-hidden`/`data-id`) from false matches;
216
+ 5. **`text` mode first** — Markdown structure is opt-in;
217
+ 6. **Compact text render** — the model sees a `title:` header + body directly, no JSON parsing; `siteName` is omitted when identical to the hostname; every status hint is one short line (truncated / cached / rendered), no verbose paragraphs;
218
+ 7. **Two-tier cache** — successful results cached per URL for 5 minutes (repeat reads hit cache: fewer network calls and fewer model retries); **failed results cached for 30 seconds** so a broken URL never triggers a re-fetch loop;
219
+ 8. **KV-cache friendly (DeepSeek cost tuning)** — tool schema/description stay **static text** (no config values embedded), so changing config never invalidates the reusable prompt prefix and KV cache keeps hitting. DeepSeek's cache-hit tokens cost about 1/10 of misses — the more stable the prefix, the cheaper the run (same analysis as the official `tool-web` docs);
220
+ 9. **Batch shares the cache** — `read_url_batch` reuses the same cache (repeat batches hit it directly) and caps each page at 3,000 chars (below the single-page 6,000) to bound total output;
221
+ 10. **Compact fixed cost** — the four tool descriptions total ~900 chars (audited by a budget assertion in tests, kept static for KV-cache); extended HTML-entity decoding (45 named entities) means leftovers like `&mdash;`/`&hellip;` never waste tokens or render as mojibake.
220
222
 
221
223
  ## Technical notes
222
224
 
223
225
  - **Encoding**: BOM-first detection (UTF-8 / UTF-16LE / UTF-16BE — byte-level evidence beats any declared charset), then HTTP `Content-Type` charset → HTML meta; built-in `TextDecoder` transcoding (Node 20+ full-icu, so declared Shift-JIS/EUC-JP/GBK/Big5 all decode), GB2312 normalized to GBK, auto-fallback to UTF-8 on mojibake;
224
226
  - **Content dispatch**: a URL is not always an HTML page — JSON APIs are re-rendered fully compact with no indentation, and string values over 1500 chars are clipped with a visible marker (since v1.2.0), RSS 2.0 / Atom feeds parse into an entry list (`title — url` + summary, `feedCount` field, full items via `includeLinks`; entry summaries are iteratively tag-stripped and entity-decoded until stable, so double-escaped `&lt;a&gt;` never leaks as a literal tag); XML sitemaps are rejected with a clear reason (no reading value); everything else falls through to the HTML pipeline;
225
- - **Extraction**: prefers `<article>` (all articles joined on aggregation pages; an unrelated tiny article — e.g. a newsletter card — falls through to `<main>` when its text is under 200 chars and `<main>` exists) / `<main>` / `role="main"`; `role="main"` containers are closed via **depth counting of balanced tags** (nested divs no longer cut the block at the first `</div>` — measured: gnu.org used to lose 7/8 of its body this way); strips `nav/footer/header/aside/form/iframe` and ad-like containers, heuristic fallback to `<body>`; on the body path a **text-density pass** drops link-dominated segments (related posts, category sidebars, hot-article widgets) — pages with standard containers never reach it;
227
+ - **Extraction**: prefers `<article>` (all articles joined on aggregation pages; an unrelated tiny article — e.g. a newsletter card — falls through to `<main>` when its text is under 200 chars and `<main>` exists) / `<main>` / `role="main"`; `role="main"` containers are closed via **depth counting of balanced tags** (nested divs no longer cut the block at the first `</div>` — measured: gnu.org used to lose 7/8 of its body this way); strips `nav/footer/header/aside/form/iframe`, ad-like containers, and **hidden elements & consent banners** (the `hidden` attribute, `aria-hidden="true"`, styles containing `display:none`/`visibility:hidden`, and GDPR banners mounted under ids like onetrust/cookiebot/cybot/gdpr/consent/cookie-law; a `[\s"']` attribute-separator guard keeps compound names such as `data-hidden`/`data-id` from false matches; unclosed hidden elements degrade safely to keeping the content), heuristic fallback to `<body>`; on the body path a **text-density pass** drops link-dominated segments (related posts, category sidebars, hot-article widgets) — pages with standard containers never reach it;
228
+ - **URL fragment anchoring (v1.4.0)**: `url#section-anchor` targets one section of a long document — container tags (section/div/dl/table…) take the **depth-counted balanced block** (nesting handled correctly, exactly that section), heading/inline anchors (`<h2 id>`/`<dt id>`/`<a name>`) read from the anchor to the end (a heading introduces the content after it); percent-encoded non-ASCII anchors are also tried in decoded form; a miss degrades to the full document. The cache key keeps the fragment (`#a`/`#b` cached as separate entries); a hit skips the readability upgrade and auto-pagination (both override located semantics with whole-document semantics); text mode is **paragraph-structured** (block-level boundaries → `\n\n` paragraph gaps, headings on their own lines — paragraph-aligned truncation actually lands on real boundaries and offset continuation cuts at paragraph seams; trailing permalink glyphs `¶`/`§` are stripped from headings);
226
229
  - **Pagination**: auto-follows `rel=next` or a short next-page anchor (下一页 / next / › / » — conservative, no fuzzy guessing); same-host only, loop-guarded, repeated paragraphs across the seam de-duplicated; continuation pages take the fast static path (a paginated SPA chain would cost one full render per page);
227
230
  - **Metadata**: `published` / `author` harvested from meta tags into the output and the status line; when the page has no such meta (ruanyifeng.com ships zero author/date tags) a **byline fallback** harvests the body head (600-char window: `作者:X / 日期:Y` — meta wins, deep-body mentions ignored, markdown link syntax stripped); empty-body pages (login walls, JS shells) fall back to `og:description` as a hint instead of nothing;
228
231
  - **Markdown**: self-written lightweight tag state machine (headings/paragraphs/lists/blockquotes/code/tables/inline bold-italic-links), zero deps; images with alt text render as `![alt](src)` (decorative empty-alt images are dropped), code fences carry the language hint from `language-*` highlighter classes;
@@ -233,12 +236,14 @@ const results = await Promise.all([
233
236
  - **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) or a JS-redirect shell (few scripts but an empty `<body>`), the plugin automatically renders it with headless Chromium before extracting (a `rendered` flag tells the model); the rendered text is accepted only when it meaningfully beats the static one (≥20 chars from an empty static extraction — short real content is no longer rejected); **bot-challenge defense** — Cloudflare-style interstitials ("Just a moment...") get an extra 8s poll for the auto-redirect, and if the page still looks like a challenge the rendered result is rejected and the static body kept (an interstitial can carry MORE text than the real body — measured 259 vs 135 chars — and must never be mistaken for an improvement); the interstitial DOM never feeds pagination/link extraction either; 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;
234
237
  - **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).
235
238
 
236
- ## Real-world validation (2026-08-21, v1.0.0; re-verified 2026-08-22 against DSH 0.1.1-rc.2; 2026-08-24 v1.3.0)
239
+ ## Real-world validation (2026-08-21, v1.0.0; re-verified 2026-08-22 against DSH 0.1.1-rc.2; 2026-08-24 v1.3.0; 2026-08-28 v1.4.0)
237
240
 
238
241
  152-site sweep driven by `multi-site.mjs` (committed, re-runnable, 8-way concurrent): **115 OK / 17 expected boundaries (login-walls·captcha·short static pages) / 20 attributed network·anti-bot errors / 0 crashes** (final round with all pre-release fixes in; network-class errors vary ±5 between runs, all environment-attributed). Coverage: CN portals/media, e-commerce (JD/Taobao/Pinduoduo/Suning/Dangdang), video (Bilibili/iQiyi/Youku/Mango), music, games, novels (Qidian/Zongheng/JJWXC legacy GBK), Q&A/forums, government, universities (Tsinghua/PKU/Fudan/SJTU…), Traditional-Chinese TW/HK (PTT/LTN/UDN), JP/KR portals (Yahoo JP/Hatena/goo/naver/daum), overseas tech docs (GitHub/dev.to/react.dev/nodejs.org/rust/go/python), feeds, JSON APIs, encoding stress (GBK/GB2312/Big5/gb18030), anti-bot & network boundaries. All errors are environment-attributed (Wikipedia/Reddit/UDN connect-timeout; W3C/Tieba/NGA/StackOverflow 403; BUPT 412; DNS-fail…) — every one returned as a structured, correctly-attributed error, never a crash. v1.3.0 re-run (direct connect, no proxy): **93 OK / 24 THIN+EMPTY / 35 ERR / 0 THREW**, ERR all overseas connect-timeouts and 403/412 anti-bot (baseline-consistent), zero content regressions.
239
242
 
240
243
  Sweep-driven pre-release fixes (each locked by unit tests): double-escaped RSS descriptions, headerless binary sniffing, JS-redirect shell rendering, **`role="main"` container cut short by nested divs** (gnu.org 165→800 chars), **tiny `<article>` hijacking the main content** (gitlab 71→800 chars), relaxed render-acceptance threshold.
241
244
 
245
+ v1.4.0 re-verification (2026-08-28, proxy environment): anchor read measured on python-docs stdtypes.html — `#str.startswith` takes the full 235,393 chars down to a 111,587-char located slice (**-52.6%**), with both text and markdown output starting exactly at the target method definition; 152-site sweep baseline unchanged.
246
+
242
247
  Plus a **15-item DSH acceptance round** (a real agent driving every read_url tool end-to-end): 12 items fully conformant, the 3 findings all closed — ① Cloudflare's "Just a moment..." interstitial carries MORE text than the real body (259 vs 135 chars) and was mistaken for a render improvement → challenge-marker detection + an extra 8s wait for the auto-redirect + interstitial results rejected; ② ruanyifeng.com ships ZERO author/date meta tags, the byline only exists in body text → byline fallback harvesting the body head (verified: `author=阮一峰 published=2026年8月21日`); ③ PDF rejection now attributes clearly: `Unsupported content-type: application/pdf`.
243
248
 
244
249
  | Category | Sites | Result |
@@ -258,7 +263,7 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
258
263
  | Batch + failure isolation | 4-URL mix | ✅ 2/4 ok, failures isolated |
259
264
  | Site crawl | Ruan Yifeng blog | ✅ 5/5 pages tree map |
260
265
 
261
- - **126 zero-dep assertions** (v1.3.1 round 2 adds: pre-block entity decoding, follow-charset propagation, read_url_links shell following; v1.3.1 robustness round adds: meta attribute-order, quoted content values, http-equiv charset, oversized JSON-LD + nested mainEntity, markdown paren escaping, m3u8 crawl noise, bounded oversized attributes; v1.3.0 adds: control-char entity sanitization, JSON-LD metadata, base-href link resolution, meta-refresh following ×11; v1.2.0 adds: sentence-aligned truncation, compact JSON, pagination variants, adversarial-input time bounds, prose &lt; preservation, deep-JSON degradation) (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, UTF-16 BOM, Shift-JIS, density filter, pagination join/cap/disable, JSON render, RSS parse, sitemap rejection, 429 Retry-After retry, image alt, code-fence language, metadata, og:description fallback, double-escaped feed, headerless binary sniff, nested role=main, tiny-article fallback, unbalanced-tag degradation, byline fallback, challenge-page detection, isConcurrencySafe declaration + concurrent cache-race smoke) + **12 SPA-test assertions** all green;
266
+ - **150 zero-dep assertions** (v1.4.0 adds 24: hidden/consent stripping ×11 — hidden attribute / aria-hidden / style-hidden / onetrust / cybot / gdpr / compound-attribute guard / aria-hidden=false kept; anchor slicing ×8 — container balanced block / heading-to-end / markdown anchoring / miss-degrades-to-full / percent-decoding / data-id guard / e2e cache isolation & offset semantics / bare URL unaffected; paragraph seams & permalink glyphs ×3; thin-page hint ×1; regression ×1; v1.3.1 round 2 adds: pre-block entity decoding, follow-charset propagation, read_url_links shell following; v1.3.1 robustness round adds: meta attribute-order, quoted content values, http-equiv charset, oversized JSON-LD + nested mainEntity, markdown paren escaping, m3u8 crawl noise, bounded oversized attributes; v1.3.0 adds: control-char entity sanitization, JSON-LD metadata, base-href link resolution, meta-refresh following ×11; v1.2.0 adds: sentence-aligned truncation, compact JSON, pagination variants, adversarial-input time bounds, prose &lt; preservation, deep-JSON degradation) (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, UTF-16 BOM, Shift-JIS, density filter, pagination join/cap/disable, JSON render, RSS parse, sitemap rejection, 429 Retry-After retry, image alt, code-fence language, metadata, og:description fallback, double-escaped feed, headerless binary sniff, nested role=main, tiny-article fallback, unbalanced-tag degradation, byline fallback, challenge-page detection, isConcurrencySafe declaration + concurrent cache-race smoke) + **12 SPA-test assertions** all green; probe.mjs adversarial probes +7 (every new v1.4.0 regex time-bounded, slowest 4.9ms);
262
267
  - 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.
263
268
 
264
269
  ## Roadmap
@@ -278,6 +283,9 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
278
283
  - [x] Adversarial-page linearization (unclosed-tag/unpaired-lt/attribute-scan bounds + recursion depth cap, fixing a quadratic DoS vector), prose &lt; preservation, proxy-path redirects keep the final URL, deep-JSON degradation instead of crash (v1.2.0 round 2)
279
284
  - [x] Control-char entity sanitization (`&#0;`/`&#127;` → space, surrogates → U+FFFD), JSON-LD metadata (meta → JSON-LD → byline), `<base href>` link resolution (frame/legacy-forum pages), `<meta refresh>` shell following (≤3 hops, loop-safe, fail-open, v1.3.0)
280
285
  - [x] Robustness round: order-independent meta extraction (content-first no longer missed), recursive JSON-LD nesting (ItemList→mainEntity), legacy `<meta http-equiv="Content-Type">` charset, bounded link/pagination regexes, markdown paren escaping, crawl stream-URL noise filtering (v1.3.1)
286
+ - [x] URL fragment reading (`url#section-anchor`: container balanced-block / heading-to-end, cache isolation, offset relative to the section; python-docs measured -52.6%, v1.4.0)
287
+ - [x] Hidden-element & consent-banner stripping (hidden/aria-hidden/style-hidden + onetrust/cookiebot/gdpr id matching, v1.4.0)
288
+ - [x] Paragraph-structured text output (block boundaries → paragraph gaps, offset continuation cuts at real paragraph boundaries, v1.4.0)
281
289
 
282
290
  > From v1.0.0 the plugin enters maintenance mode: bug fixes first, updates kept rare.
283
291
 
package/README.zh.md CHANGED
@@ -210,19 +210,22 @@ const results = await Promise.all([
210
210
  ## 省 token 设计(核心)
211
211
 
212
212
  1. **默认只给正文**——不返回 headings/keywords/images/字数统计等冗余字段,需要时按参数取;
213
- 2. **段落级智能截断 + offset 续读**——默认 6000 字符(约 3000 token),在段落边界截断保证语义完整,输出行仅一行 `(chars 6000/12990 — 截断,offset 续读)` 引导;续读从指定偏移开始、命中缓存切片,**不重复返回已读前文**(实测 0+500 → 500+500,无重复);offset 越界返回空而非重复开头;
214
- 3. **text 模式优先**——Markdown 结构按需开启;
215
- 4. **紧凑文本 render**——模型直接看到 `title:` 头部 + 正文,无需解析 JSON;`siteName` 与域名相同时省略;状态提示全部一行内(截断/续读/缓存/渲染标记),无长段落废话;
216
- 5. **双层缓存**——成功结果按 URL 缓存 5 分钟(重复读取直接命中,省网络也省模型重试);**失败结果缓存 30 秒**(坏 URL 不会触发重复 fetch 循环);
217
- 6. **KV Cache 友好(DeepSeek 成本特调)**——工具 schema/description 保持**静态文本**(不嵌入配置值),配置变更不会使可复用的 prompt 前缀失效,KV 缓存持续命中。DeepSeek 缓存命中 token 价格约为未命中的 1/10,前缀越稳定越省钱(官方 `tool-web` 文档同款分析);
218
- 7. **批量共用缓存**——`read_url_batch` 内部复用同一套缓存,重复批量读直接命中,且每页默认 3000 字符(低于单页 6000)控制总量;
219
- 8. **固定开销压缩**——4 个工具 description 合计约 900 字符(有断言守卫,保持静态利于 KV 缓存);HTML 实体解码扩展至 45 个命名实体,`&mdash;`/`&hellip;` 等残留不再浪费 token 或显示为乱码。
213
+ 2. **段落级智能截断 + offset 续读**——默认 6000 字符(约 3000 token),在段落边界截断保证语义完整(text 模式段落以 `\n\n` 分隔,切口落在真实段落边界而非句中),输出行仅一行 `(chars 6000/12990 — 截断,offset 续读)` 引导;续读从指定偏移开始、命中缓存切片,**不重复返回已读前文**(实测 0+500 → 500+500,无重复);offset 越界返回空而非重复开头;
214
+ 3. **URL 片段定位阅读(v1.4.0)**——`url#section-anchor` 直接定位长文档目标小节:容器锚点精确切块(深度计数平衡提取)、标题锚点从该处起读;cache key 保留 fragment 保证 `#a`/`#b` 互不串页,offset 相对小节起点。实测 python-docs `#str.startswith`:**235,393 → 111,587 字符(-52.6%)**,默认 6000 字符窗口直接落在目标方法而非文档头;
215
+ 4. **隐藏元素与同意弹窗剥离(v1.4.0)**——`display:none`/`visibility:hidden` 装饰树、`hidden` 属性、`aria-hidden="true"`、OneTrust/Cookiebot/GDPR id 挂载的横幅不再混入正文;复合属性名(`data-hidden`/`data-id`)有界卫兵不误伤;
216
+ 5. **text 模式优先**——Markdown 结构按需开启;
217
+ 6. **紧凑文本 render**——模型直接看到 `title:` 头部 + 正文,无需解析 JSON;`siteName` 与域名相同时省略;状态提示全部一行内(截断/续读/缓存/渲染标记),无长段落废话;
218
+ 7. **双层缓存**——成功结果按 URL 缓存 5 分钟(重复读取直接命中,省网络也省模型重试);**失败结果缓存 30 秒**(坏 URL 不会触发重复 fetch 循环);
219
+ 8. **KV Cache 友好(DeepSeek 成本特调)**——工具 schema/description 保持**静态文本**(不嵌入配置值),配置变更不会使可复用的 prompt 前缀失效,KV 缓存持续命中。DeepSeek 缓存命中 token 价格约为未命中的 1/10,前缀越稳定越省钱(官方 `tool-web` 文档同款分析);
220
+ 9. **批量共用缓存**——`read_url_batch` 内部复用同一套缓存,重复批量读直接命中,且每页默认 3000 字符(低于单页 6000)控制总量;
221
+ 10. **固定开销压缩**——4 个工具 description 合计约 900 字符(有断言守卫,保持静态利于 KV 缓存);HTML 实体解码扩展至 45 个命名实体,`&mdash;`/`&hellip;` 等残留不再浪费 token 或显示为乱码。
220
222
 
221
223
  ## 技术说明
222
224
 
223
225
  - **编码**:BOM 优先探测(UTF-8 / UTF-16LE / UTF-16BE——字节级证据优先于任何声明),其次 HTTP `Content-Type` charset → HTML meta;内置 `TextDecoder` 转码(Node 20+ full-icu,页面声明的 Shift-JIS/EUC-JP/GBK/Big5 均可正确解码),GB2312 归一为 GBK,检测到乱码自动回退 UTF-8;
224
226
  - **内容类型分发**:URL 不一定是 HTML——JSON 接口紧凑重排渲染(无缩进,超长字符串值 >1500 字符截断并标注,v1.2.0 起缩进 1 格改为完全紧凑),RSS 2.0 / Atom 订阅源解析为条目列表(`标题 — 链接` + 摘要,`feedCount` 字段,`includeLinks` 时附完整 items;条目摘要迭代「剥标签+解实体」直到稳定,双重转义的 `&lt;a&gt;` 不会漏成字面标签);XML sitemap 明确拒绝(对模型无阅读价值);其余全部走 HTML 管线;
225
- - **正文提取**:优先 `<article>`(聚合页多篇合并;无关小卡片 article——如订阅挂件——文本不足 200 字符且页面有 `<main>` 时自动回落 main)/ `<main>` / `role="main"`,`role="main"` 容器用**深度计数找平衡闭合标签**(嵌套 div 不会在第一个 `</div>` 被截断——实测 gnu.org 曾因此只取到 1/8 正文);剥离 `nav/footer/header/aside/form/iframe` 及广告类容器,启发式回归到 `<body>`;body 路径上追加**文本密度过滤**——丢弃链接主导的短块(相关推荐/分类侧栏/热门文章挂件),标准容器页面完全不走此路径;
227
+ - **正文提取**:优先 `<article>`(聚合页多篇合并;无关小卡片 article——如订阅挂件——文本不足 200 字符且页面有 `<main>` 时自动回落 main)/ `<main>` / `role="main"`,`role="main"` 容器用**深度计数找平衡闭合标签**(嵌套 div 不会在第一个 `</div>` 被截断——实测 gnu.org 曾因此只取到 1/8 正文);剥离 `nav/footer/header/aside/form/iframe` 及广告类容器、**隐藏元素与同意弹窗**(`hidden` 属性、`aria-hidden="true"`、`style` 含 `display:none`/`visibility:hidden`、`onetrust/cookiebot/cybot/gdpr/consent/cookie-law` 等 id 挂载的 GDPR 横幅;`[\s"']` 属性界卫兵保证 `data-hidden`/`data-id` 复合属性名不误伤;未闭合元素安全降级为保留内容),启发式回归到 `<body>`;body 路径上追加**文本密度过滤**——丢弃链接主导的短块(相关推荐/分类侧栏/热门文章挂件),标准容器页面完全不走此路径;
228
+ - **URL 片段定位(v1.4.0)**:`url#section-anchor` 定位长文档小节——容器标签(section/div/dl/table 等)取**深度计数平衡块**(嵌套正确、精确切出该节),标题/内联锚点(`<h2 id>`/`<dt id>`/`<a name>`)从该处读到文末(标题引出其后正文);percent-encoded 中文锚点自动尝试解码形态;未命中降级全文。cache key 保留 fragment(`#a`/`#b` 独立缓存);命中锚点时跳过 readability 升级与自动分页(二者都以全文语义覆盖定位语义);text 模式**段落化输出**(块级边界 → `\n\n` 段落分隔,标题独立成行,smartTruncate 段落对齐真正生效,offset 续读切口落在真实段落边界;标题行尾永久链接装饰符 `¶`/`§` 剥离);
226
229
  - **分页拼接**:识别 `rel=next`(标准)或纯「下一页 / next / › / »」短锚文本(刻意保守,不做模糊猜测);同域限定 + 防环;跨页重复段落自动去重;续页走静态快路径(分页 SPA 链每页一次完整渲染不划算);
227
230
  - **元数据**:`published` / `author` 从 meta 标签提取进输出字段与状态行;页面无相关 meta 时(如阮一峰博客零 meta 标签)从**正文头部 600 字符的署名行兜底**(「作者:X / 日期:2026年8月21日」,meta 优先、正文深部提及不误采、markdown 链接语法不泄漏);空正文页(登录墙/JS 壳)回落到 `og:description` 作为提示,不再返回空;
228
231
  - **Markdown**:自研轻量标签状态机(标题/段落/列表/引用/代码块/表格/行内加粗斜体链接),零依赖;带 alt 的图片渲染为 `![alt](src)`(空 alt 装饰图丢弃),代码块围栏带 `language-*` class 里的语言标注;
@@ -233,12 +236,14 @@ const results = await Promise.all([
233
236
  - **可选增强二(SPA 页面渲染)**:在 DSH profile 目录执行 `npm i playwright && npx playwright install chromium` 后自动启用。检测到正文为空且(页面脚本密集 疑似 Vue/React 客户端渲染,或 body 为空的 JS 跳转壳——script 数不多但正文全靠跳转)时,自动用无头 Chromium 渲染后再提取(`rendered` 标记告知模型);渲染结果仅在**显著优于**静态提取时采用(静态为空时 ≥20 字符即接受,防短正文被拒);**人机验证页防御**——Cloudflare「Just a moment...」等指纹页识别后额外轮询 8s 供其自动跳转,仍未通过则拒绝渲染结果、保留静态正文(指纹页文字量可能超过真实正文,实测 259 vs 135 字符,绝不当作提升),指纹页 DOM 也不污染分页/链接提取;渲染采用 `domcontentloaded` + **DOM 稳定轮询**(内容停止增长即收,上限 10s)而非 `networkidle`——心跳轮询站永不空闲,避免 30s 超时;未安装时优雅提示安装方法、不报错——核心保持零依赖;
234
237
  - **边界**:登录墙页面无法读取;SPA 页面需安装 Playwright 增强后渲染读取(未安装时返回明确提示);**结构化数据(如评论的点赞数归属、榜单数值)不在文本提取范围**——本插件把 HTML 扁平化为可读文本,字段与数值的精确对应关系会丢失;需要精确字段时,用 Playwright 拦截页面实际调用的数据 API 获取(见下方「真实世界验证」)。
235
238
 
236
- ## 真实世界验证(2026-08-21,v1.0.0;2026-08-22 适配 DSH 0.1.1-rc.2 复验;2026-08-24 v1.3.0 复验)
239
+ ## 真实世界验证(2026-08-21,v1.0.0;2026-08-22 适配 DSH 0.1.1-rc.2 复验;2026-08-24 v1.3.0 复验;2026-08-28 v1.4.0 复验)
237
240
 
238
241
  152 站全量实测(`multi-site.mjs` 已提交可复跑,8 并发):**115 OK / 17 预期边界(登录墙·验证页·静态小页) / 20 网络·反爬归因错误 / 0 崩溃**(含全部发布前修复的终态轮;网络类错误逐轮有 ±5 波动,均为环境归因)。覆盖国内门户 / 媒体 / 电商(京东·淘宝·拼多多·苏宁·当当)/ 视频(B 站·爱奇艺·优酷·芒果)/ 音乐 / 游戏 / 小说(起点·纵横·晋江 legacy GBK)/ 问答 / 论坛 / 政府 / 高校(清北复交等 8 所)/ 港台繁体(PTT·自由时报·联合报)/ 日韩(Yahoo JP·Hatena·goo·naver·daum)/ 海外技术站(GitHub·dev.to·react.dev·nodejs·rust·go·python docs)/ 订阅源 / JSON API / 编码压力(GBK·GB2312·Big5·gb18030)/ 反爬与网络边界。错误全部环境归因(维基/Reddit/UDN 连接超时;W3C/贴吧/NGA/StackOverflow 403;北邮 412;DNS 失败等)——每一个都返回结构化、准确归因的错误,无一崩溃。v1.3.0 复验轮(本机直连,无代理):**93 OK / 24 THIN+EMPTY / 35 ERR / 0 THREW**,ERR 全部为境外连接超时与 403/412 反爬(与基线一致),无内容性回归。
239
242
 
240
243
  扫描驱动的发布前修复(全部带单测锁定):RSS 双重转义、无头二进制嗅探、JS 跳转壳渲染、**`role="main"` 嵌套 div 截断**(gnu.org 165→800 字符)、**小 article 劫持主内容**(gitlab 71→800 字符)、渲染接受门槛放宽。
241
244
 
245
+ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点定位实测——`#str.startswith` 全文 235,393 → 定位切片 111,587 字符(-52.6%),text/markdown 双模式内容均精确起始于目标方法定义;152 站回归基线不变。
246
+
242
247
  另有 **DSH 15 项全功能验收轮**(真实 Agent 调用 read_url 系工具逐项核对):12 项完全符合,3 项问题全部修复闭环——① Cloudflare「Just a moment...」验证页文字量超过真实正文(259 vs 135 字符)被误当作渲染提升 → 挑战页特征识别 + 渲染后额外等待 8s 自动跳转 + 拒绝接受指纹页;② 阮一峰页面零 author/date meta 标签,作者/日期只在正文里 → byline 兜底从正文头部 600 字符提取署名行(实测 `author=阮一峰 published=2026年8月21日`);③ PDF 类型拒绝归因 → 明确报 `Unsupported content-type: application/pdf`。
243
248
 
244
249
  | 类别 | 站点 | 结果 |
@@ -258,7 +263,7 @@ const results = await Promise.all([
258
263
  | **批量 + 失败隔离** | 4 URL 混合 | ✅ 2/4 成功、失败隔离 |
259
264
  | **整站爬取** | 阮一峰博客 | ✅ 5/5 页树状站点地图 |
260
265
 
261
- - **126 个单元断言**(v1.3.1 收尾轮新增:pre 代码块实体解码、meta-refresh 跟随 charset 更新、read_url_links 壳页跟随;v1.3.1 健壮性轮新增:meta 属性序颠倒、content 内嵌引号、http-equiv charset、JSON-LD 大块/嵌套 mainEntity、markdown 括号转义、m3u8 噪音、超长属性限时;v1.3.0 新增:控制字符实体防护、JSON-LD 元数据、base href 链接、meta-refresh 跟随 11 条;v1.2.0 新增:长段落句级对齐、紧凑 JSON、分页变体、对抗输入限时、散文 &lt; 保留、深 JSON 降级)(含实体解码、description/schema 预算守卫、链接去重、表格分隔行转义、代理回退函数、空参容错、竞速逻辑、空竞速守卫、裸 main 提取、最坏链路超时预算、yml 字符串强转+钳制、严格宿主 seam 降级、UTF-16 BOM、Shift-JIS、密度过滤、分页拼接/封顶/关闭、JSON 渲染、RSS 解析、sitemap 拒绝、429 Retry-After 重试、图片 alt、代码语言、元数据、og:description 回落、双转义 feed、无头二进制嗅探、嵌套 role=main、薄 article 回落、不平衡标签降级、byline 兜底、人机验证页识别、isConcurrencySafe 声明 + 并发缓存竞态烟雾)+ **12 个 SPA 测试断言**全绿;
266
+ - **150 个单元断言**(v1.4.0 新增 24:隐藏/consent 剥离 11(hidden 属性/aria-hidden/style 隐藏/onetrust/cybot/gdpr/复合属性卫兵/aria-hidden=false 保留)、锚点切片 8(容器平衡块/标题到文末/markdown 定位/未命中降级/百分号解码/data-id 卫兵/e2e 缓存隔离与 offset 语义/裸 URL 不受影响)、段落接缝与装饰符 3、短正文提示 1、回归 1;v1.3.1 收尾轮新增:pre 代码块实体解码、meta-refresh 跟随 charset 更新、read_url_links 壳页跟随;v1.3.1 健壮性轮新增:meta 属性序颠倒、content 内嵌引号、http-equiv charset、JSON-LD 大块/嵌套 mainEntity、markdown 括号转义、m3u8 噪音、超长属性限时;v1.3.0 新增:控制字符实体防护、JSON-LD 元数据、base href 链接、meta-refresh 跟随 11 条;v1.2.0 新增:长段落句级对齐、紧凑 JSON、分页变体、对抗输入限时、散文 &lt; 保留、深 JSON 降级)(含实体解码、description/schema 预算守卫、链接去重、表格分隔行转义、代理回退函数、空参容错、竞速逻辑、空竞速守卫、裸 main 提取、最坏链路超时预算、yml 字符串强转+钳制、严格宿主 seam 降级、UTF-16 BOM、Shift-JIS、密度过滤、分页拼接/封顶/关闭、JSON 渲染、RSS 解析、sitemap 拒绝、429 Retry-After 重试、图片 alt、代码语言、元数据、og:description 回落、双转义 feed、无头二进制嗅探、嵌套 role=main、薄 article 回落、不平衡标签降级、byline 兜底、人机验证页识别、isConcurrencySafe 声明 + 并发缓存竞态烟雾)+ **12 个 SPA 测试断言**全绿;probe.mjs 对抗探针 +7(v1.4.0 新正则全部限时通过,最慢 4.9ms);
262
267
  - 一个真实案例:小黑盒帖子的评论点赞数(`up` 字段)无法从扁平文本确定归属——**精确字段应走页面背后的数据 API**(如 `/bbs/app/link/tree` JSON),这是同类文本提取器的共同边界,不是缺陷。
263
268
 
264
269
  ## Roadmap
@@ -278,6 +283,9 @@ const results = await Promise.all([
278
283
  - [x] 对抗页面线性化(无配对尖括号/无闭合标签/属性扫描加界 + 递归深度帽,修复 60KB→2.2s 的二次复杂度 DoS 向量)、散文 &lt; 不再被吞、代理重定向保留最终 URL、深 JSON 降级不崩溃(v1.2.0 第二轮)
279
284
  - [x] NUL/控制字符实体防护(`&#0;`/`&#127;` 输出空格、surrogate 输出 U+FFFD,不污染模型上下文)、JSON-LD 元数据三级提取(meta → JSON-LD → byline)、`<base href>` 链接解析(框架/老论坛页)、`<meta refresh>` 壳页自动跟随(最多 3 跳、防循环、fail-open,v1.3.0)
280
285
  - [x] 健壮性轮:meta 属性序无关提取(content 在前不再漏)、JSON-LD 嵌套结构递归(ItemList→mainEntity)、`<meta http-equiv="Content-Type">` 老式 charset、链接/分页正则统一加界、markdown 括号转义、爬虫流媒体噪音过滤(v1.3.1)
286
+ - [x] URL 片段定位阅读(`url#section-anchor` 容器平衡块/标题到文末、缓存隔离、offset 相对小节,python-docs 实测 -52.6%,v1.4.0)
287
+ - [x] 隐藏元素与同意弹窗剥离(hidden/aria-hidden/style 隐藏 + onetrust/cookiebot/gdpr id 匹配,v1.4.0)
288
+ - [x] text 模式段落化输出(块级边界 → 段落分隔,offset 续读切口落在真实段落边界,v1.4.0)
281
289
 
282
290
  > v1.0.0 起进入维护期:以修 bug 为主,减少更新频率。
283
291
 
package/index.js CHANGED
@@ -341,6 +341,32 @@ function textOnly(html) {
341
341
  .trim())
342
342
  }
343
343
 
344
+ // Paragraph-aware variant of textOnly for the body extraction path: block
345
+ // boundaries become paragraph breaks so offset continuation slices at real
346
+ // paragraph seams (smartTruncate aligns on /\n\n+/) instead of mid-sentence,
347
+ // and long reference pages stay readable instead of one flattened line.
348
+ // <br> keeps single newlines; every other tag runs are stripped as spaces.
349
+ function textLines(html) {
350
+ const cleaned = decodeTextEntities(defuseLt(html)
351
+ .replace(/<!--[\s\S]*?-->/g, ' ')
352
+ .replace(/<script[\s>][\s\S]*?<\/script>/gi, ' ')
353
+ .replace(/<style[\s>][\s\S]*?<\/style>/gi, ' ')
354
+ .replace(/<textarea[\s>][\s\S]*?<\/textarea\s*>/gi, ' ')
355
+ .replace(/<noscript[\s>][\s\S]*?<\/noscript>/gi, ' ')
356
+ .replace(/<br\b[^>]{0,1000}\/?>/gi, '\n')
357
+ .replace(/<\/?(?:p|div|section|article|main|h[1-6]|li|ul|ol|table|tbody|thead|tfoot|tr|blockquote|pre|figcaption|dt|dd|dl|figure|fieldset|option)\b[^>]{0,1000}>/gi, '\n')
358
+ .replace(/<[^>]{1,1000}>/g, ' '))
359
+ const lines = []
360
+ for (const rawLine of cleaned.split('\n')) {
361
+ let line = rawLine.replace(/[ \t\r\f\v]+/g, ' ').trim()
362
+ // A heading becomes its own short paragraph in this layout; a trailing
363
+ // permalink glyph on one is anchor decoration, not content.
364
+ if (line.length <= 120) line = line.replace(/\s*[¶§]$/, '')
365
+ if (line) lines.push(line)
366
+ }
367
+ return lines.join('\n\n')
368
+ }
369
+
344
370
  // ---- JSON / RSS / Atom content dispatch ----
345
371
  // A URL is not always an HTML page: data APIs answer JSON and content sources
346
372
  // answer feeds. Both are model-readable when compacted, so read_url handles
@@ -509,6 +535,16 @@ function stripNoise(mainHtml) {
509
535
  if (box.length) {
510
536
  out = out.replace(new RegExp(`<(${box.join('|')})[\\s>][\\s\\S]*?<\\/\\1>`, 'gi'), ' ')
511
537
  }
538
+ // Elements explicitly hidden from users are invisible decoration or stateful
539
+ // UI (collapsed panels, modal templates) — their text must not leak into the
540
+ // body. The [\s"'] guard keeps `hidden`/`style` from matching inside
541
+ // compound attr names (data-hidden, aria-hidden). Consent/GDPR banners mount
542
+ // by id (onetrust, cookiebot, cybot, gdpr) rather than class, so a second
543
+ // pass keys on the id attribute. Every scan run is bounded.
544
+ const hidden = /<(div|span|section|p|ul|table)\b[^>]{0,1000}[\s"'](?:hidden\b|aria-hidden\s*=\s*["']?true|style\s*=\s*["'][^"']{0,200}(?:display:\s*none|visibility:\s*hidden))[^>]{0,1000}>[\s\S]{0,50000}?<\/\1\s*>/gi
545
+ out = out.replace(hidden, ' ')
546
+ const consent = /<([a-z][a-z0-9]*)\s+id=["'][^"']{0,120}(?:onetrust|cookiebot|cybot|gdpr|consent|cookie-law|cookie_banner|cmp-)[^"']{0,120}["'][^>]{0,1000}>[\s\S]{0,50000}?<\/\1\s*>/gi
547
+ out = out.replace(consent, ' ')
512
548
  return out.replace(
513
549
  /<([a-z][a-z0-9]*)[^>]{1,1000}class=["'][^"']{0,300}(ad-|ads|advert|banner|sidebar|social|share|comment|popup|modal|cookie)[^"']{0,300}["'][^>]{0,1000}>[\s\S]*?<\/\1>/gi,
514
550
  ' ',
@@ -615,7 +651,8 @@ export function blockMd(html, depth = 0) {
615
651
  }
616
652
  if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') {
617
653
  const level = '#'.repeat(Number(tag[1]))
618
- out += `\n\n${level} ${inlineMd(inner, depth + 1)}`
654
+ // Trailing permalink glyphs are anchor decorations, not heading text.
655
+ out += `\n\n${level} ${inlineMd(inner, depth + 1).replace(/\s*[¶§]\s*$/, '')}`
619
656
  } else if (tag === 'p') {
620
657
  const t = inlineMd(inner, depth + 1)
621
658
  if (t) out += `\n\n${t}`
@@ -801,7 +838,42 @@ function bylineFallback(text, meta) {
801
838
  return out
802
839
  }
803
840
 
804
- export function extract(html, mode) {
841
+ // Locate the element whose id/name equals `anchor` and return the html of its
842
+ // section: for container tags (section/div/dl/…) the element's own balanced
843
+ // block IS the section; for heading/inline anchors (<h2 id>, <a name>, <dt
844
+ // id>) the anchor only marks where the section starts, so everything FROM it
845
+ // onward is kept (the heading introduces the content that follows). Returns
846
+ // null when no element matches — callers fall back to full text.
847
+ // The [\s"'] guard rejects compound attr names (data-id, data-name), and the
848
+ // exact-quoted value means id="prefix-anchor" never matches anchor "prefix".
849
+ const ANCHOR_CONTAINER = /^(?:section|div|article|aside|main|dl|table|ul|ol|details|figure|blockquote|fieldset|form)$/
850
+ function sliceAtAnchor(mainHtml, anchor) {
851
+ if (!anchor) return null
852
+ const esc = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
853
+ const m = new RegExp(`<([a-z][a-z0-9]*)\\b[^>]{0,1000}[\\s"'](?:id|name)\\s*=\\s*["']${esc}["'][^>]{0,1000}>`, 'i').exec(mainHtml)
854
+ if (!m) return null
855
+ const tag = m[1].toLowerCase()
856
+ if (ANCHOR_CONTAINER.test(tag)) {
857
+ const block = tagBlockAt(mainHtml, m.index, tag)
858
+ if (block) return block
859
+ }
860
+ return mainHtml.slice(m.index)
861
+ }
862
+
863
+ // Anchors may arrive percent-encoded (CJK section names); the document can
864
+ // carry either form. Try the raw fragment first, then its decoded variant.
865
+ function anchorCandidates(anchor) {
866
+ const list = [anchor]
867
+ try {
868
+ const dec = decodeURIComponent(anchor)
869
+ if (dec !== anchor) list.push(dec)
870
+ } catch {
871
+ /* malformed percent-encoding — raw form only */
872
+ }
873
+ return list
874
+ }
875
+
876
+ export function extract(html, mode, anchor = '') {
805
877
  html = defuseLt(html) // kills the no-'>'-tail quadratic scans before any picker runs
806
878
  const titleTag = /<title[^>]{0,1000}>([\s\S]*?)<\/title>/i.exec(html)
807
879
  const ogTitle = metaContent(html, 'og:title')
@@ -809,11 +881,19 @@ export function extract(html, mode) {
809
881
  const langMatch = /<html[^>]{1,1000}lang=["']([\w-]+)["']/i.exec(html)
810
882
  let main = stripNoise(pickMain(html))
811
883
  main = stripNoise(revealEscapedTags(main))
884
+ // A URL fragment scopes the read to one section of a long reference page —
885
+ // slice at the anchor so only that section is extracted. Unmatched anchors
886
+ // degrade to the full document instead of failing.
887
+ let scoped = null
888
+ for (const cand of anchorCandidates(anchor)) {
889
+ scoped = sliceAtAnchor(main, cand)
890
+ if (scoped) break
891
+ }
812
892
  let bodyText
813
893
  if (mode === 'markdown') {
814
- bodyText = blockMd(main).replace(/\n{3,}/g, '\n\n').trim()
894
+ bodyText = blockMd(scoped || main).replace(/\n{3,}/g, '\n\n').trim()
815
895
  } else {
816
- bodyText = textOnly(main).replace(/ +/g, ' ').trim()
896
+ bodyText = textLines(scoped || main).replace(/ +/g, ' ').trim()
817
897
  }
818
898
  const meta = extractMeta(html)
819
899
  const ld = jsonLdMeta(html)
@@ -841,6 +921,7 @@ export function extract(html, mode) {
841
921
  author: byline.author,
842
922
  text,
843
923
  usedDescription,
924
+ anchored: !!scoped,
844
925
  }
845
926
  }
846
927
 
@@ -1053,6 +1134,10 @@ function sliceFrom(full, offset, maxChars) {
1053
1134
  }
1054
1135
  if (full.rendered) out.rendered = true
1055
1136
  if (full.spaHint) out.spaHint = full.spaHint
1137
+ if (full.anchored) {
1138
+ out.anchored = true
1139
+ out.anchor = full.anchor || ''
1140
+ }
1056
1141
  if (full.paginated > 1) out.paginated = full.paginated
1057
1142
  if (full.feedCount) out.feedCount = full.feedCount
1058
1143
  if (full.published) out.published = full.published
@@ -1170,15 +1255,24 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1170
1255
  // Cache stores the FULL extracted text keyed by url+mode+includeLinks, so
1171
1256
  // continuation reads (offset) and different maxChars hit the same entry —
1172
1257
  // but a links-request must not be served from a links-less cached copy.
1173
- // The key drops the URL fragment: #sections would otherwise split the cache.
1258
+ // The fragment STAYS in the key: a #section read scopes the extraction to
1259
+ // that anchor, so two fragments must never share one cache entry.
1174
1260
  let cacheUrl = url
1175
1261
  try {
1176
- const u = new URL(url)
1177
- u.hash = ''
1178
- cacheUrl = u.href
1262
+ cacheUrl = new URL(url).href
1179
1263
  } catch {
1180
1264
  /* keep raw url on parse failure */
1181
1265
  }
1266
+ // A #fragment scopes the read to one section of a long page. The browser
1267
+ // never sends it to the server, so it must be taken from the REQUEST url —
1268
+ // finalUrl (after redirects/renders) has no fragment.
1269
+ let anchor = ''
1270
+ try {
1271
+ const h = new URL(url).hash
1272
+ if (h.length > 1) anchor = h.slice(1)
1273
+ } catch {
1274
+ /* keep bare url */
1275
+ }
1182
1276
  const cacheKey = `${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
1183
1277
  // Failed fetches are cached briefly so the model doesn't re-request a
1184
1278
  // broken URL in a loop (token + latency saver).
@@ -1264,7 +1358,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1264
1358
  // The target may use a different encoding than the shell page — reflect it.
1265
1359
  if (followed.charset) charset = followed.charset
1266
1360
 
1267
- let extracted = extract(html, mode)
1361
+ let extracted = extract(html, mode, anchor)
1268
1362
  // Optional readability upgrade: cleaner article extraction when installed.
1269
1363
  const ext = await getReadabilityExtractor()
1270
1364
  // The base passed to readability must be the CURRENT final URL — after a
@@ -1277,14 +1371,16 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1277
1371
  if (clean && clean.length > 0) {
1278
1372
  target.text = mode === 'markdown'
1279
1373
  ? blockMd(clean).replace(/\n{3,}/g, '\n\n').trim()
1280
- : textOnly(clean).replace(/ +/g, ' ').trim()
1374
+ : textLines(clean).replace(/ +/g, ' ').trim()
1281
1375
  }
1282
1376
  } catch {
1283
1377
  // keep heuristic result
1284
1378
  }
1285
1379
  return target
1286
1380
  }
1287
- extracted = upgrade(extracted, html)
1381
+ // Readability would replace the scoped section with the FULL article text;
1382
+ // when an anchor matched, the scoped extraction IS the answer.
1383
+ extracted = extracted.anchored ? extracted : upgrade(extracted, html)
1288
1384
 
1289
1385
  // Optional SPA enhancement: if the page looks client-rendered and static
1290
1386
  // extraction found almost nothing, try headless rendering (playwright).
@@ -1311,7 +1407,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1311
1407
  ? '已渲染但被人机验证页拦截,保留静态提取结果'
1312
1408
  : '已渲染但被人机验证页拦截(无静态内容可用)'
1313
1409
  } else {
1314
- const r2 = upgrade(extract(rr.html, mode), rr.html, rr.finalUrl || finalUrl)
1410
+ const r2raw = extract(rr.html, mode, anchor)
1411
+ const r2 = r2raw.anchored ? r2raw : upgrade(r2raw, rr.html, rr.finalUrl || finalUrl)
1315
1412
  // Accept the rendered text when it meaningfully beats the static one.
1316
1413
  // From an EMPTY static extraction even a short body is a real gain
1317
1414
  // (JS shells can render just 30-60 chars of real content, below the
@@ -1338,7 +1435,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1338
1435
  const paginateMax = cfg.paginate === false ? 1 : Math.max(1, Math.min(10, Number(cfg.paginateMax) || 3))
1339
1436
  const startHost = hostOf(finalUrl || url)
1340
1437
  const seen = new Set([normalizeUrl(finalUrl || url)])
1341
- let nextUrl = paginateMax > 1 ? findNextLink(renderedHtml || html, finalUrl || url) : null
1438
+ // An anchored read is scoped to one section — joining the "next page" chain
1439
+ // would append whole pages the fragment never asked for.
1440
+ let nextUrl = paginateMax > 1 && !extracted.anchored ? findNextLink(renderedHtml || html, finalUrl || url) : null
1342
1441
  while (
1343
1442
  nextUrl && paginated < paginateMax &&
1344
1443
  sameHost(nextUrl, startHost) &&
@@ -1359,6 +1458,13 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1359
1458
  nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
1360
1459
  }
1361
1460
 
1461
+ // A near-empty body (login wall, anti-bot shell) with no render hint yet
1462
+ // still looks like "the page content" to the model — one constant-cost line
1463
+ // tells it not to keep offset-reading a 40-char page.
1464
+ if (!spaHint && extracted.text && extracted.text.length < 60) {
1465
+ spaHint = '正文极短(可能登录墙或反爬拦截)'
1466
+ }
1467
+
1362
1468
  const full = {
1363
1469
  url: finalUrl,
1364
1470
  title: extracted.title || '',
@@ -1369,6 +1475,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1369
1475
  fullText: extracted.text,
1370
1476
  rendered,
1371
1477
  spaHint,
1478
+ anchored: extracted.anchored || false,
1479
+ anchor: extracted.anchored ? anchor : '',
1372
1480
  paginated,
1373
1481
  published: extracted.published || '',
1374
1482
  author: extracted.author || '',
@@ -1381,7 +1489,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
1381
1489
  return sliceFrom(full, offset, maxChars)
1382
1490
  }
1383
1491
 
1384
- function renderResult(value) {
1492
+ export function renderResult(value) {
1385
1493
  if (typeof value === 'string') return value
1386
1494
  const r = value || {}
1387
1495
  if (r.error) return `Error: ${r.error}`
@@ -1396,6 +1504,9 @@ function renderResult(value) {
1396
1504
  }
1397
1505
  if (r.published) meta.push(r.published)
1398
1506
  if (r.author) meta.push(`by ${r.author}`)
1507
+ // Tells the model why the text starts mid-document: the read was scoped to
1508
+ // this fragment, and offset continues from the section start.
1509
+ if (r.anchored) meta.push(`#${r.anchor || ''}`)
1399
1510
  if (r.feedCount) meta.push(`feed · ${r.feedCount} 条`)
1400
1511
  if (r.mode === 'json') meta.push('json')
1401
1512
  if (meta.length) lines.push(meta.join(' · '))
@@ -1408,6 +1519,9 @@ function renderResult(value) {
1408
1519
  if (r.cached) flags.push('cached')
1409
1520
  if (r.paginated > 1) flags.push(`已拼接${r.paginated}页`)
1410
1521
  if (r.rendered) flags.push('rendered')
1522
+ // With text present the hint rides the flags line; with empty text the
1523
+ // dedicated "无可读内容" line below already carries it.
1524
+ if (r.text && r.spaHint) flags.push(r.spaHint)
1411
1525
  if (flags.length) lines.push(`(${flags.join(' · ')})`)
1412
1526
  if (!r.text) {
1413
1527
  if (r.charsStart > 0 && !r.truncated) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-url",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",