dsh-scrape 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +168 -2
- package/cordis.patch.yml +22 -0
- package/dsh-plugin.naming.json +28 -0
- package/lib/cli.js +251 -0
- package/lib/entities.mjs +65 -0
- package/lib/errors.mjs +137 -0
- package/lib/extract.mjs +192 -0
- package/lib/format.mjs +137 -0
- package/lib/html.mjs +822 -0
- package/lib/index.mjs +15 -0
- package/lib/limit.mjs +120 -0
- package/lib/markdown.mjs +161 -0
- package/lib/net.mjs +283 -0
- package/lib/robots.mjs +171 -0
- package/lib/scrape.mjs +560 -0
- package/package.json +60 -4
- package/plugin.mjs +197 -0
- package/scripts/check.mjs +170 -0
- package/scripts/verify-install.mjs +143 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 he fangsheng (https://github.com/dongsheng123132)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-scrape · 把网页变成干净、可引用、带行号的 Markdown
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
dsh-scrape 是一个 DSH(DeepSeek Harness)插件,用于把任意网页抓成**干净、可引用、带源行号锚点**的 Markdown:它遵守 robots.txt、同域限速、跟随重定向、自动认字符集(GBK/Big5 不乱码)、按元素去噪(脚本/样式/导航/页脚/cookie 条/分享按钮),再用密度法挑出正文并给出 0–1 的置信度。安装:`dsh plugin --profile web add dsh-scrape`。
|
|
4
|
+
|
|
5
|
+
> **English**: dsh-scrape is a DSH plugin that turns any web page into clean, citable Markdown with per-line provenance anchors — robots-aware, host-throttled, redirect-following, charset-aware, boilerplate-stripped, with a density-based main-content extractor and a 0–1 confidence score. It never executes JavaScript, and says so loudly when a page looks client-rendered.
|
|
6
|
+
|
|
7
|
+
包 `dsh-scrape@0.1.0` · 已实测宿主 DSH 0.2.2-rc.1、Node 23.11.0 · 许可 MIT · 作者主页 https://github.com/dongsheng123132 · 仓库 https://github.com/dongsheng123132/dsh-scrape(发布时创建)
|
|
8
|
+
|
|
9
|
+
## dsh-scrape 是什么?
|
|
10
|
+
|
|
11
|
+
它是一个**挂在 DSH 里的工具**(模型可以直接调 `dsh_scrape`),也是一个**零运行时依赖的 CLI**(人和 CI 可以跑 `node lib/cli.js`)。输入是一个 URL、一批 URL,或一段本地 HTML;输出是 Markdown 正文 + 抓取事实(`provenance`)+ 去了哪些噪音(`removed`)+ 警告清单(`warnings`)。
|
|
12
|
+
|
|
13
|
+
它和「随手 fetch 一下」只差一件事,但这件事决定了 agent 敢不敢引用:**每一段内容都带着它在原页里的行号**。Markdown 里每块前面有一个 HTML 注释锚点:
|
|
14
|
+
|
|
15
|
+
```markdown
|
|
16
|
+
<!-- L42-45 -->
|
|
17
|
+
它解决的是「抓到了但不敢引用」的问题:官方 web-fetch-http 只给原始 HTML。
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
渲染时看不见 `<!-- L42-45 -->`,但它能被 grep、能被下游模型读到——于是「这句话来自原页第 42–45 行」是可验证的,不是凭印象说的。
|
|
21
|
+
|
|
22
|
+
## 它和官方抓取、Readability、Jina Reader 有什么区别?
|
|
23
|
+
|
|
24
|
+
| 方案 | 它给你什么 | 缺什么 / 代价 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| 官方 `web-fetch-http` | 原始 HTML 或裸文本 | 正文要自己抽;没有去噪、没有行号、不判 robots |
|
|
27
|
+
| Readability(Mozilla) | 抽好的正文 | 只给一段文本,不给行号;要 DOM 解析(多一层依赖),DSH 里不能直接当工具调 |
|
|
28
|
+
| Jina Reader(`r.jina.ai`) | 干净的 Markdown | 内容要发给第三方;自己网站的正文会流出去;离线不可用 |
|
|
29
|
+
| dsh-scrape(本包) | 干净 Markdown + `<!-- L42 -->` 行号 + provenance + 去噪分类计数 + confidence | 不执行 JS(SPA 会警告而不是渲染);正文抽取是启发式,不是浏览器渲染 |
|
|
30
|
+
|
|
31
|
+
## 怎么装、怎么用?
|
|
32
|
+
|
|
33
|
+
装插件(装完重启 dsh 才生效):
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
dsh plugin --profile web add dsh-scrape
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
装好后在 DSH 会话里直接说需求,模型会调 `dsh_scrape`:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
把 https://example.com/post 抓成 Markdown,我要引用里面的数字
|
|
43
|
+
抓这几页,每页给我正文和它的行号:https://a.com/1 https://b.com/2
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
不装插件也能用 CLI(零依赖,只要 Node ≥22):
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
node lib/cli.js --url https://example.com/post # 抓网页,打印摘要 + Markdown
|
|
50
|
+
node lib/cli.js --url https://example.com/post --json # 机器可读(含 provenance / removed / warnings)
|
|
51
|
+
node lib/cli.js --file saved.html # 解析本地 HTML,不联网
|
|
52
|
+
node lib/cli.js --urls https://a.com,https://b.com --json # 批量抓(同域自动串行限速)
|
|
53
|
+
node lib/cli.js --url https://x.com --robots false # 不遵守 robots(会记一条警告)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
退出码固定三档,便于脚本判断:`0` 抓取完成(含「被判 robots 禁止」这种合法结论)、`1` 参数或文件错误、`2` 网络或 HTTP 错误。错误只写 stderr,正常结果只写 stdout。
|
|
57
|
+
|
|
58
|
+
## 一次调用的输出长什么样?
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"markdown": "<!-- L16 -->\n# 样例文章\n\n<!-- L17 -->\n这是一段正文……",
|
|
63
|
+
"title": "样例文章 · 把网页变成可引用的 Markdown",
|
|
64
|
+
"provenance": {
|
|
65
|
+
"finalUrl": "https://example.com/post",
|
|
66
|
+
"fetchedAt": "2026-11-11T00:12:33.512Z",
|
|
67
|
+
"httpStatus": 200,
|
|
68
|
+
"bytes": 4213,
|
|
69
|
+
"charset": "utf-8",
|
|
70
|
+
"redirects": [{ "from": "https://example.com/old", "to": "https://example.com/post", "status": 301 }]
|
|
71
|
+
},
|
|
72
|
+
"removed": { "script": 25, "style": 17, "nav": 6, "footer": 18, "consent": 15, "share": 8, "total": 203 },
|
|
73
|
+
"content": { "chars": 171, "blocks": 7, "confidence": 0.9, "method": "main-element", "spaSuspected": false },
|
|
74
|
+
"warnings": []
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`removed` 里每个数字是**被去掉的字数**(不是元素个数),固定键齐全、缺省为 0,调用方不用判空;元素个数在 `removed.elements` 里。
|
|
79
|
+
|
|
80
|
+
## confidence 是怎么算出来的?
|
|
81
|
+
|
|
82
|
+
不是玄学分数,是几条可解释的理由加权。基础分看抽取路径,再加/减信号:
|
|
83
|
+
|
|
84
|
+
| 信号 | 分值 | 为什么 |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| 用了 `<main>` / `<article>` 且里面有够多文字 | 基础 0.85 | 站点自己声明的正文,最可信 |
|
|
87
|
+
| 密度法找到的连续正文段 | 基础 0.55 | 没有 main 时靠「最长连续正文段」猜 |
|
|
88
|
+
| 只能退回「去掉样板后的全部文字」 | 基础 0.25 | 没找到可靠正文段,只能将就 |
|
|
89
|
+
| 保留块 ≥ 3 段 | +0.10 | 像正文,不像碎片 |
|
|
90
|
+
| 正文 ≥ 800 字 | +0.08 | 内容量够,不是列表页 |
|
|
91
|
+
| 有标题层级 / 有表格或代码块 | 各 +0.05 | 这些结构通常只出现在正文里 |
|
|
92
|
+
| 正文 < 200 字 / < 50 字 | −0.15 / −0.15 | 太短,多半抽错了 |
|
|
93
|
+
| 疑似 SPA(不执行 JS) | −0.10 | 内容天然不全 |
|
|
94
|
+
|
|
95
|
+
同一份内容,`spa: true` 一定比 `spa: false` 的置信度低——这条有测试守着(`test/extract.test.mjs`)。
|
|
96
|
+
|
|
97
|
+
## 去噪到底去掉什么?
|
|
98
|
+
|
|
99
|
+
两层判定,都可解释:
|
|
100
|
+
|
|
101
|
+
1. **按元素**:`script` `style` `noscript` `template` `svg` `canvas` `iframe` `nav` `footer` `aside` `form` `dialog` `button` `select` 等整块不要;
|
|
102
|
+
2. **按属性特征**:`class` / `id` / `role` / `aria-label` 命中 `cookie|consent|gdpr|onetrust|didomi`、`share|social|addthis`、`breadcrumb|pagination|navbar|sidebar`、`advert|adsense|sponsor` 等就整块丢掉——cookie 条、分享按钮、面包屑几乎都有可辨认的 class。
|
|
103
|
+
|
|
104
|
+
再往上还有一层「样板块」判定(正文抽取阶段):纯链接块、整块只有菜单词(首页/登录/联系我们)、版权行、极短碎片会被标成 boilerplate 丢掉,字数额度记进 `removed.boilerplate`。
|
|
105
|
+
|
|
106
|
+
## 疑似 SPA 的判定规则是什么?
|
|
107
|
+
|
|
108
|
+
**不执行 JS** 是硬约束,所以遇到客户端渲染必须说清楚,而不是返回一页空白。判定条件是「正文很少」**且**「看到 SPA 骨架」:
|
|
109
|
+
|
|
110
|
+
- 正文 < 200 字,且页面出现 `__NEXT_DATA__`(Next.js)、`__NUXT__`(Nuxt)、`__INITIAL_STATE__` / `__APOLLO_STATE__`、空的 `<div id="root">` / `id="app">`、或 `<noscript>` 里写着「需要启用 JavaScript」;
|
|
111
|
+
- 或者正文 < 120 字、页面有 ≥5 个 `<script>`、且没有 `<main>` / `<article>`。
|
|
112
|
+
|
|
113
|
+
命中就返回 `warnings[].code = "spa_suspected"`,并在 Markdown 里写一段**非空**的说明(不是空字符串):内容可能缺,建议换预渲染版本或静态副本。
|
|
114
|
+
|
|
115
|
+
## 抓取纪律
|
|
116
|
+
|
|
117
|
+
- **robots.txt**:默认遵守。先取 `origin/robots.txt`,按 RFC 9309 判定(精确 UA 组优先、最长匹配胜出、同长度 Allow 胜);禁止就不发页面请求,返回 `blocked: "robots"` 与命中规则的行号。`robots:false` 可以关,但一定会记 `robots_disabled` 警告。
|
|
118
|
+
- **限速**:同域串行 + 最小间隔(默认 500ms,`Crawl-delay` 会把它抬高,只能升不能降),不同域互不牵连。并发猛打是被封的最快方式。
|
|
119
|
+
- **重定向**:手动跟随(最多 5 跳),每一跳都记进 `provenance.redirects`;跨站跳转会额外给 `cross_origin_redirect` 提示。
|
|
120
|
+
- **字符集**:先看 `Content-Type` 的 charset,再看 `<meta charset>`,然后自己解码字节——中文老站(GBK/GB18030/Big5)不会乱码。
|
|
121
|
+
- **体量**:默认 2 MB 字节上限,边读边数,超了截断并给 `truncated` 警告,不把上下文炸掉。
|
|
122
|
+
|
|
123
|
+
## 三个常见问题
|
|
124
|
+
|
|
125
|
+
### 它能抓带登录的页面吗?
|
|
126
|
+
|
|
127
|
+
不能,也不打算做。它不带 Cookie、不带浏览器指纹,需要登录的页面会拿到登录页(并如实反映在正文里)。要抓登录后的内容,请用你已有的凭据在本地导出 HTML,再走 `--file`。
|
|
128
|
+
|
|
129
|
+
### 为什么不执行 JavaScript,却敢说「可引用」?
|
|
130
|
+
|
|
131
|
+
因为**只引用它实际抓到的东西**才是可验证的。执行 JS 会把「页面渲染后的样子」变成黑盒,还会引入浏览器依赖;本包选择「抓到什么说什么」——SPA 就明说 SPA,置信度也相应降低。要渲染后的内容,请抓预渲染地址。
|
|
132
|
+
|
|
133
|
+
### 表格、代码块、图片会被保留吗?
|
|
134
|
+
|
|
135
|
+
会。`<table>` 转成 Markdown 管道表(管道符转义、列数不齐自动补齐),`<pre><code>` 转成带语言的代码围栏(内容里有 ``` 时自动升级成四个反引号),`<a>` 转成 `[文字](绝对地址)`,`<img>` 保留 alt 与地址。这些都是有测试守着的(`test/html.test.mjs`、`test/markdown.test.mjs`)。
|
|
136
|
+
|
|
137
|
+
## 事实
|
|
138
|
+
|
|
139
|
+
- 源码 **3111 行**(`plugin.mjs` + `lib/` 共 13 个文件),**零运行时依赖**,没有编译步骤(直接 `.js` / `.mjs`)
|
|
140
|
+
- 测试 **131 条**,11 个测试文件,覆盖正常页 / 重定向 / 404 / 超时 / robots 禁 / SPA 空壳警告 / 表格保留 / 中文 GBK 编码 / 限速 / CLI 退出码 / 插件挂载
|
|
141
|
+
- `npm pack --dry-run` 实测:发布 **20 个文件**、解包 **151 KB**、tarball **53 KB**
|
|
142
|
+
- 去噪分类 **17 类**(`script` `style` `nav` `footer` `aside` `consent` `share` 等),每类都记字数
|
|
143
|
+
- 已实测:宿主 DSH 0.2.2-rc.1、Node 23.11.0、macOS
|
|
144
|
+
- 工具名 `dsh_scrape`;命名清单见 `dsh-plugin.naming.json`(`npm run check` 会校验四处同名)
|
|
145
|
+
- 默认参数:超时 15000ms、字节上限 2000000、重定向 5 跳、同域间隔 500ms、正文门槛 200 字
|
|
146
|
+
|
|
147
|
+
## 已知限制
|
|
148
|
+
|
|
149
|
+
- **不执行 JavaScript**:客户端渲染的页面只能拿到服务端 HTML,内容会不全(会警告,不会假装成功)。
|
|
150
|
+
- 正文抽取是「结构化密度」启发式:不认 CSS 隐藏、不认付费墙;误判时用 `--no-line-refs` 看原文,或直接读 `--json` 的 `removed` 与 `content.reasons`。
|
|
151
|
+
- 只处理 HTML / 纯文本 / JSON / XML;PDF、图片、Office 文档里的文字不解析(那属于文档解析工具)。
|
|
152
|
+
- 不携带登录态与 Cookie,需要登录的页面会拿到登录页。
|
|
153
|
+
- `robots.txt` 只按 `dsh-scrape` 与 `*` 两组判定(长匹配优先、Allow 同长胜出),不实现非标准扩展。
|
|
154
|
+
- 不做 SSRF 白名单:它是本机工具,你可以抓内网地址;要对外暴露请自己加一层。
|
|
155
|
+
|
|
156
|
+
## 开发与自检
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
node --test test/ # 131 条测试(也可 node --test test/*.test.mjs)
|
|
160
|
+
npm run check # 包结构 + 语法 + 命名四处同源 + 假 ctx 真加载 + 宿主 schema 校验 + 零依赖红线
|
|
161
|
+
npm run verify:install # 用宿主自己的 cordis + ToolsService 把 npm pack 的产物真装一遍(DSH_HOME 隔离)
|
|
162
|
+
node lib/cli.js --file test/fixtures/sample.html # 离线跑一遍,看输出长什么样
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
装验为什么不用 `dsh plugin add`:本机 Node 23 下 dsh CLI 会静默空转(缺 `import.meta.main`),走 CLI 只会得到「什么都没发生的成功」。所以 `verify:install` 直接走宿主的安装代码路径:`npm pack` → 解包 → 真 `Context` + 真 `SystemPrompt` + 真 `ToolsService` → `apply(ctx, patch.config)` → 断言注册表里有 `dsh_scrape`、schema 过宿主校验、execute 真抓到 200 与行号锚点。
|
|
166
|
+
|
|
167
|
+
## 许可
|
|
168
|
+
|
|
169
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# dsh-scrape:往宿主挂一个 host 侧插件,注册模型可调用工具 dsh_scrape。
|
|
2
|
+
# 行 id 与包名同名(三个名字同源:包名 / patch 行 id / 代码里的 name),避免两套命名空间重复注册。
|
|
3
|
+
- insert:
|
|
4
|
+
- id: dsh-scrape
|
|
5
|
+
name: dsh-scrape
|
|
6
|
+
config:
|
|
7
|
+
# 单跳网络超时(毫秒)
|
|
8
|
+
timeoutMs: 15000
|
|
9
|
+
# 响应体字节上限(超过截断并警告)
|
|
10
|
+
maxBytes: 2000000
|
|
11
|
+
# 一次批量最多几个 URL
|
|
12
|
+
maxUrls: 20
|
|
13
|
+
# 同域两次请求的最小间隔(毫秒):礼貌抓取的底线
|
|
14
|
+
minIntervalMs: 500
|
|
15
|
+
# 返回的 Markdown 字符上限
|
|
16
|
+
maxChars: 200000
|
|
17
|
+
# 「可信正文」字数门槛(低于它会降 confidence)
|
|
18
|
+
minChars: 200
|
|
19
|
+
# 是否遵守 robots.txt(false 会抓但仍记警告)
|
|
20
|
+
robots: true
|
|
21
|
+
# 是否在 Markdown 里加 <!-- L42 --> 行号锚点
|
|
22
|
+
lineRefs: true
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"policy": "dsh-plugin-naming/v1",
|
|
4
|
+
"plugin": {
|
|
5
|
+
"namespace": "hfshfg",
|
|
6
|
+
"name": "scrape",
|
|
7
|
+
"coordinate": "hfshfg/scrape",
|
|
8
|
+
"packageName": "dsh-scrape"
|
|
9
|
+
},
|
|
10
|
+
"names": {
|
|
11
|
+
"pluginNames": [
|
|
12
|
+
"dsh-scrape"
|
|
13
|
+
],
|
|
14
|
+
"loaderIds": [
|
|
15
|
+
"dsh-scrape"
|
|
16
|
+
],
|
|
17
|
+
"services": [],
|
|
18
|
+
"tools": [
|
|
19
|
+
"dsh_scrape"
|
|
20
|
+
],
|
|
21
|
+
"commands": [],
|
|
22
|
+
"skills": [],
|
|
23
|
+
"skillProviders": [],
|
|
24
|
+
"events": [],
|
|
25
|
+
"settingsNamespaces": [],
|
|
26
|
+
"routes": []
|
|
27
|
+
}
|
|
28
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh-scrape` 命令行:同一套引擎的零依赖入口,给人和 CI 用。
|
|
4
|
+
*
|
|
5
|
+
* 为「被 AI 调用」而定的约定:
|
|
6
|
+
* - 正常结果写 stdout(文本模式=摘要+Markdown,`--json` 模式=一个 JSON 对象);错误只写 stderr;
|
|
7
|
+
* - 退出码三档:0 抓取完成(含「被判 robots 禁止」这种合法结论)、1 参数/文件错误、2 网络或 HTTP 错误;
|
|
8
|
+
* - 不执行 JS、不读你的浏览器 Cookie、不改任何配置。
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* node lib/cli.js --url https://example.com
|
|
12
|
+
* node lib/cli.js --urls https://a.com,https://b.com --json
|
|
13
|
+
* node lib/cli.js --file saved.html --min-chars 100
|
|
14
|
+
* node lib/cli.js --url https://example.com --robots false --brief
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import { scrape, scrapeMany, VERSION, DEFAULTS } from './scrape.mjs'
|
|
22
|
+
import { describeError } from './errors.mjs'
|
|
23
|
+
import { renderText, renderJson } from './format.mjs'
|
|
24
|
+
|
|
25
|
+
const USAGE = `dsh-scrape ${VERSION} —— 网页 → 干净、可引用、带行号的 Markdown
|
|
26
|
+
|
|
27
|
+
用法:
|
|
28
|
+
node lib/cli.js --url <URL> 抓一个网页
|
|
29
|
+
node lib/cli.js --urls <URL,URL,...> 批量抓(同域自动串行限速)
|
|
30
|
+
node lib/cli.js --file <路径> 解析本地 HTML / 文本(不联网)
|
|
31
|
+
node lib/cli.js --stdin 从标准输入读 HTML
|
|
32
|
+
|
|
33
|
+
选项:
|
|
34
|
+
--json 输出 JSON(stdout 只有这一个对象,含 markdown 与 provenance)
|
|
35
|
+
--brief 文本模式只给摘要,不打印 Markdown 全文
|
|
36
|
+
--no-line-refs Markdown 里不加 '<!-- L42 -->' 行号锚点
|
|
37
|
+
--robots <true|false> 是否遵守 robots.txt,默认 true;设 false 会记一条警告
|
|
38
|
+
--max-bytes <n> 响应体字节上限,默认 ${DEFAULTS.maxBytes}
|
|
39
|
+
--timeout <ms> 单跳超时,默认 ${DEFAULTS.timeoutMs}
|
|
40
|
+
--max-redirects <n> 最多跟随几跳重定向,默认 ${DEFAULTS.maxRedirects}
|
|
41
|
+
--max-chars <n> Markdown 字符上限,默认 ${DEFAULTS.maxChars}
|
|
42
|
+
--min-chars <n> 「可信正文」字数门槛,默认 ${DEFAULTS.minChars}
|
|
43
|
+
--min-interval <ms> 同域最小请求间隔,默认 ${DEFAULTS.minIntervalMs}
|
|
44
|
+
--help, -h 显示本帮助
|
|
45
|
+
--version, -v 只打印版本
|
|
46
|
+
|
|
47
|
+
退出码:0 抓取完成(分数/禁止与否都不影响)|1 参数或文件错误|2 网络或 HTTP 错误`
|
|
48
|
+
|
|
49
|
+
/** 参数错误(退出码 1)。 */
|
|
50
|
+
class UsageError extends Error {}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 解析正整数。
|
|
54
|
+
* @param {string} raw 原始值
|
|
55
|
+
* @param {string} name 选项名
|
|
56
|
+
* @returns {number} 正整数
|
|
57
|
+
*/
|
|
58
|
+
function toPositiveInt(raw, name) {
|
|
59
|
+
const value = Number(raw)
|
|
60
|
+
if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) {
|
|
61
|
+
throw new UsageError(`${name} 需要正整数,收到 ${raw}`)
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 解析布尔开关(true/false/1/0/yes/no)。
|
|
68
|
+
* @param {string} raw 原始值
|
|
69
|
+
* @param {string} name 选项名
|
|
70
|
+
* @returns {boolean} 布尔值
|
|
71
|
+
*/
|
|
72
|
+
function toBoolean(raw, name) {
|
|
73
|
+
const value = String(raw).toLowerCase()
|
|
74
|
+
if (['true', '1', 'yes', 'on'].includes(value)) return true
|
|
75
|
+
if (['false', '0', 'no', 'off'].includes(value)) return false
|
|
76
|
+
throw new UsageError(`${name} 需要 true/false,收到 ${raw}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 解析 argv(手写,理由同其它零依赖 CLI:不引 commander)。
|
|
81
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
82
|
+
* @returns {object} flags
|
|
83
|
+
*/
|
|
84
|
+
export function parseArgs(argv) {
|
|
85
|
+
const flags = {}
|
|
86
|
+
for (let i = 0; i < argv.length; i++) {
|
|
87
|
+
const arg = argv[i]
|
|
88
|
+
const takeValue = (name) => {
|
|
89
|
+
const next = argv[++i]
|
|
90
|
+
if (next === undefined) throw new UsageError(`${name} 需要一个值`)
|
|
91
|
+
return next
|
|
92
|
+
}
|
|
93
|
+
switch (arg) {
|
|
94
|
+
case '--help': case '-h': flags.help = true; break
|
|
95
|
+
case '--version': case '-v': flags.version = true; break
|
|
96
|
+
case '--json': flags.json = true; break
|
|
97
|
+
case '--brief': flags.brief = true; break
|
|
98
|
+
case '--stdin': flags.stdin = true; break
|
|
99
|
+
case '--no-line-refs': flags.lineRefs = false; break
|
|
100
|
+
case '--line-refs': flags.lineRefs = true; break
|
|
101
|
+
case '--robots': flags.robots = toBoolean(takeValue('--robots'), '--robots'); break
|
|
102
|
+
case '--no-robots': flags.robots = false; break
|
|
103
|
+
case '--url': flags.url = takeValue('--url'); break
|
|
104
|
+
case '--urls': flags.urls = takeValue('--urls').split(',').map((item) => item.trim()).filter(Boolean); break
|
|
105
|
+
case '--file': flags.file = takeValue('--file'); break
|
|
106
|
+
case '--max-bytes': flags.maxBytes = toPositiveInt(takeValue('--max-bytes'), '--max-bytes'); break
|
|
107
|
+
case '--timeout': flags.timeoutMs = toPositiveInt(takeValue('--timeout'), '--timeout'); break
|
|
108
|
+
case '--max-redirects': flags.maxRedirects = toPositiveInt(takeValue('--max-redirects'), '--max-redirects'); break
|
|
109
|
+
case '--max-chars': flags.maxChars = toPositiveInt(takeValue('--max-chars'), '--max-chars'); break
|
|
110
|
+
case '--min-chars': flags.minChars = toPositiveInt(takeValue('--min-chars'), '--min-chars'); break
|
|
111
|
+
case '--min-interval': flags.minIntervalMs = toPositiveInt(takeValue('--min-interval'), '--min-interval'); break
|
|
112
|
+
default:
|
|
113
|
+
if (arg.startsWith('-')) throw new UsageError(`未知参数 ${arg}`)
|
|
114
|
+
throw new UsageError(`多余的参数 ${arg}(本工具只接受 --url / --urls / --file / --stdin)`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return flags
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 读 stdin。
|
|
122
|
+
* @returns {string} 全部输入
|
|
123
|
+
*/
|
|
124
|
+
function readStdin() {
|
|
125
|
+
try {
|
|
126
|
+
return readFileSync(0, 'utf8')
|
|
127
|
+
} catch {
|
|
128
|
+
return ''
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 判本地文件是 HTML 还是纯文本。
|
|
134
|
+
* @param {string} path 文件路径
|
|
135
|
+
* @param {string} content 内容
|
|
136
|
+
* @returns {boolean} 是否按 HTML 解析
|
|
137
|
+
*/
|
|
138
|
+
function looksLikeHtml(path, content) {
|
|
139
|
+
if (/\.(html?|xhtml)$/i.test(path)) return true
|
|
140
|
+
if (/\.(txt|md|markdown|json|xml)$/i.test(path)) return false
|
|
141
|
+
return /<\s*(?:!doctype|html|head|body|div|article|main|p\b)/i.test(content.slice(0, 2048))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* CLI 主流程。
|
|
146
|
+
* @param {string[]} argv 参数
|
|
147
|
+
* @returns {Promise<number>} 退出码
|
|
148
|
+
*/
|
|
149
|
+
export async function main(argv) {
|
|
150
|
+
let flags
|
|
151
|
+
try {
|
|
152
|
+
flags = parseArgs(argv)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
process.stderr.write(`${error.message}\n\n${USAGE}\n`)
|
|
155
|
+
return 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (flags.help) { process.stdout.write(`${USAGE}\n`); return 0 }
|
|
159
|
+
if (flags.version) { process.stdout.write(`${VERSION}\n`); return 0 }
|
|
160
|
+
|
|
161
|
+
const options = {
|
|
162
|
+
robots: flags.robots ?? DEFAULTS.robots,
|
|
163
|
+
lineRefs: flags.lineRefs ?? DEFAULTS.lineRefs,
|
|
164
|
+
maxBytes: flags.maxBytes,
|
|
165
|
+
timeoutMs: flags.timeoutMs,
|
|
166
|
+
maxRedirects: flags.maxRedirects,
|
|
167
|
+
maxChars: flags.maxChars,
|
|
168
|
+
minChars: flags.minChars,
|
|
169
|
+
minIntervalMs: flags.minIntervalMs,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
let result
|
|
174
|
+
if (flags.file) {
|
|
175
|
+
const path = resolve(flags.file)
|
|
176
|
+
let content
|
|
177
|
+
try {
|
|
178
|
+
content = readFileSync(path, 'utf8')
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new UsageError(`读不了文件 ${flags.file}:${error instanceof Error ? error.message : String(error)}`)
|
|
181
|
+
}
|
|
182
|
+
const size = statSync(path).size
|
|
183
|
+
result = looksLikeHtml(path, content)
|
|
184
|
+
? await scrape({ html: content, baseUrl: `file://${path}` }, options)
|
|
185
|
+
: await scrape({ html: `<pre>${escapeHtml(content)}</pre>` }, options)
|
|
186
|
+
result.notes = [...(result.notes ?? []), `本地文件 ${path}(${size} 字节)`]
|
|
187
|
+
} else if (flags.stdin) {
|
|
188
|
+
const content = readStdin()
|
|
189
|
+
result = await scrape({ html: content }, options)
|
|
190
|
+
} else if (flags.urls && flags.urls.length > 0) {
|
|
191
|
+
result = await scrapeMany(flags.urls, options)
|
|
192
|
+
} else if (flags.url) {
|
|
193
|
+
result = await scrape({ url: flags.url }, options)
|
|
194
|
+
} else {
|
|
195
|
+
throw new UsageError('缺少输入:--url / --urls / --file / --stdin 至少一个')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (flags.json) {
|
|
199
|
+
process.stdout.write(`${renderJson(result)}\n`)
|
|
200
|
+
} else {
|
|
201
|
+
process.stdout.write(`${renderText(result, { detail: flags.brief ? 'brief' : 'full', withMarkdown: !flags.brief })}\n`)
|
|
202
|
+
}
|
|
203
|
+
return 0
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof UsageError) {
|
|
206
|
+
process.stderr.write(`✗ ${error.message}\n\n${USAGE}\n`)
|
|
207
|
+
return 1
|
|
208
|
+
}
|
|
209
|
+
const info = describeError(error)
|
|
210
|
+
const isNetwork = ['timeout', 'dns', 'http_status', 'tls', 'connection_refused', 'connection_reset',
|
|
211
|
+
'too_many_redirects', 'network', 'aborted', 'bad_redirect'].includes(info.code)
|
|
212
|
+
if (flags.json) {
|
|
213
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: info }, null, 2)}\n`)
|
|
214
|
+
} else {
|
|
215
|
+
process.stderr.write(`✗ ${info.message}\n`)
|
|
216
|
+
if (info.hint) process.stderr.write(` → ${info.hint}\n`)
|
|
217
|
+
}
|
|
218
|
+
return isNetwork ? 2 : 1
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 把纯文本包进 `<pre>` 时转义尖括号。
|
|
224
|
+
* @param {string} text 文本
|
|
225
|
+
* @returns {string} 转义后的文本
|
|
226
|
+
*/
|
|
227
|
+
function escapeHtml(text) {
|
|
228
|
+
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 是否被直接执行(被 import 时不跑主流程,兼容 npm bin 的符号链接安装)。
|
|
233
|
+
* @returns {boolean} 是否直接执行
|
|
234
|
+
*/
|
|
235
|
+
function isInvokedDirectly() {
|
|
236
|
+
const entry = process.argv[1]
|
|
237
|
+
if (!entry) return false
|
|
238
|
+
const self = fileURLToPath(import.meta.url)
|
|
239
|
+
try {
|
|
240
|
+
return realpathSync(entry) === realpathSync(self)
|
|
241
|
+
} catch {
|
|
242
|
+
return resolve(entry) === self
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (isInvokedDirectly()) {
|
|
247
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code }).catch((error) => {
|
|
248
|
+
process.stderr.write(`✗ 未捕获异常:${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
|
|
249
|
+
process.exitCode = 1
|
|
250
|
+
})
|
|
251
|
+
}
|
package/lib/entities.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML 实体解码:命名实体 + 十进制 + 十六进制。
|
|
3
|
+
*
|
|
4
|
+
* 为什么自己写而不引第三方表:这里只需要「读得懂英文/中文页面的常见符号」,
|
|
5
|
+
* 一个几百字节的映射就够,而多一个依赖就多一个装不上的理由。
|
|
6
|
+
* 单遍替换——所以 `&lt;` 会正确变成 `<` 而不是 `<` 再变 `<`。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 常见命名实体(够覆盖正文里的标点、货币、箭头、数学符号)。 */
|
|
10
|
+
export const NAMED_ENTITIES = {
|
|
11
|
+
// 基础
|
|
12
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ',
|
|
13
|
+
shy: '\u00ad', zwj: '\u200d', zwnj: '\u200c',
|
|
14
|
+
// 标点
|
|
15
|
+
mdash: '—', ndash: '–', horbar: '―', hellip: '…', middot: '·', bull: '•',
|
|
16
|
+
lsquo: '\u2018', rsquo: '\u2019', sbquo: '\u201a', ldquo: '\u201c', rdquo: '\u201d', bdquo: '\u201e',
|
|
17
|
+
laquo: '«', raquo: '»', lsaquo: '‹', rsaquo: '›', prime: '′', Prime: '″',
|
|
18
|
+
dagger: '†', Dagger: '‡', permil: '‰', sect: '§', para: '¶', deg: '°',
|
|
19
|
+
// 货币与符号
|
|
20
|
+
copy: '©', reg: '®', trade: '™', euro: '€', pound: '£', yen: '¥', cent: '¢', curren: '¤',
|
|
21
|
+
times: '×', divide: '÷', plusmn: '±', minus: '−', frac12: '½', frac14: '¼', frac34: '¾',
|
|
22
|
+
sup1: '¹', sup2: '²', sup3: '³', micro: 'µ', iexcl: '¡', iquest: '¿',
|
|
23
|
+
// 箭头
|
|
24
|
+
larr: '←', rarr: '→', uarr: '↑', darr: '↓', harr: '↔', lArr: '⇐', rArr: '⇒', hArr: '⇔',
|
|
25
|
+
// 数学
|
|
26
|
+
ne: '≠', le: '≤', ge: '≥', asymp: '≈', equiv: '≡', infin: '∞', sum: '∑', prod: '∏',
|
|
27
|
+
radic: '√', part: '∂', nabla: '∇', isin: '∈', notin: '∉', cap: '∩', cup: '∪',
|
|
28
|
+
// 希腊字母(论文/技术页常见)
|
|
29
|
+
alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η',
|
|
30
|
+
theta: 'θ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
|
|
31
|
+
rho: 'ρ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
|
|
32
|
+
Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
|
|
33
|
+
// 结构符号
|
|
34
|
+
check: '✓', cross: '✗', star: '★', starf: '★', hearts: '♥', spades: '♠', clubs: '♣', diams: '♦',
|
|
35
|
+
loz: '◊', square: '□', blk12: '▒', blk14: '░',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 解码 HTML 实体(命名 + 十进制 + 十六进制)。
|
|
40
|
+
* @param {string} text 含实体的文本
|
|
41
|
+
* @returns {string} 解码后的文本
|
|
42
|
+
*/
|
|
43
|
+
export function decodeEntities(text) {
|
|
44
|
+
return String(text ?? '').replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body) => {
|
|
45
|
+
if (body[0] === '#') {
|
|
46
|
+
const isHex = body[1] === 'x' || body[1] === 'X'
|
|
47
|
+
const code = Number.parseInt(isHex ? body.slice(2) : body.slice(1), isHex ? 16 : 10)
|
|
48
|
+
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return whole
|
|
49
|
+
// 代理区码点不是合法字符
|
|
50
|
+
if (code >= 0xd800 && code <= 0xdfff) return whole
|
|
51
|
+
try { return String.fromCodePoint(code) } catch { return whole }
|
|
52
|
+
}
|
|
53
|
+
const named = NAMED_ENTITIES[body] ?? NAMED_ENTITIES[body.toLowerCase()]
|
|
54
|
+
return named === undefined ? whole : named
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 判断文本里是否还有未解码的实体(自检与 warning 用)。
|
|
60
|
+
* @param {string} text 文本
|
|
61
|
+
* @returns {boolean} 是否还有实体
|
|
62
|
+
*/
|
|
63
|
+
export function hasEntities(text) {
|
|
64
|
+
return /&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]{1,31});/.test(String(text ?? ''))
|
|
65
|
+
}
|