@tiny-codes/web-clip-extractor 0.2.0-alpha.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/README.md +124 -0
- package/package.json +38 -0
- package/src/cli.mjs +54 -0
- package/src/extractor.mjs +425 -0
- package/src/network-policy.mjs +429 -0
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# web-clip-extractor
|
|
2
|
+
|
|
3
|
+
The Node.js content-extraction engine for the web-to-obsidian Hermes plugin. It
|
|
4
|
+
fetches a public web page over a hardened network policy and returns normalized
|
|
5
|
+
article metadata plus Markdown.
|
|
6
|
+
|
|
7
|
+
See `../CHANGELOG.md` for version history and `../plugin/README.md` for how the
|
|
8
|
+
plugin drives this extractor.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Node.js 18+ (package `engines.node: >=18`)
|
|
13
|
+
- Locked dependencies from `package-lock.json` (`npm ci --ignore-scripts`)
|
|
14
|
+
- Playwright Chromium for the browser fallback (`npx playwright install chromium`
|
|
15
|
+
after `npm ci` — `--ignore-scripts` intentionally skips the download that
|
|
16
|
+
Playwright's postinstall normally performs).
|
|
17
|
+
|
|
18
|
+
## Dependencies
|
|
19
|
+
|
|
20
|
+
| Package | Purpose |
|
|
21
|
+
| ------------ | ----------------------------------------- |
|
|
22
|
+
| `defuddle` | Primary static article extraction |
|
|
23
|
+
| `linkedom` | Lightweight DOM parsing (no browser) |
|
|
24
|
+
| `playwright` | Isolated Chromium fallback for weak pages |
|
|
25
|
+
|
|
26
|
+
## CLI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node src/cli.mjs <url> [--no-browser]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `<url>` — exactly one `http://` or `https://` URL.
|
|
33
|
+
- `--no-browser` — skip the Chromium fallback; fail with `QUALITY_GATE` if the
|
|
34
|
+
static pass yields no substantial article.
|
|
35
|
+
|
|
36
|
+
Output is a single JSON line on stdout. On success:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{ "ok": true, "title": "...", "author": "...", "published": "...",
|
|
40
|
+
"description": "...", "site": "...", "canonicalUrl": "...",
|
|
41
|
+
"keywords": [...], "markdown": "...", "wordCount": 123, "method": "static" }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`method` is `static` (Defuddle over the pinned fetch) or `playwright`
|
|
45
|
+
(Chromium fallback). On failure, exit code is `1` and stdout carries:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "ok": false, "error": "<user-facing message>", "code": "<CODE>" }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Error codes
|
|
52
|
+
|
|
53
|
+
| Code | Meaning |
|
|
54
|
+
| -------------------------- | ----------------------------------------------- |
|
|
55
|
+
| `INVALID_URL` | The URL is malformed. |
|
|
56
|
+
| `UNSUPPORTED_SCHEME` | Only HTTP and HTTPS are allowed. |
|
|
57
|
+
| `URL_CREDENTIALS` | URLs containing credentials are rejected. |
|
|
58
|
+
| `NON_DEFAULT_PORT` | Non-default ports are rejected. |
|
|
59
|
+
| `DNS_FAILED` | The hostname could not be resolved. |
|
|
60
|
+
| `BLOCKED_ADDRESS` | Destination blocked by network policy. |
|
|
61
|
+
| `TOO_MANY_REDIRECTS` | The page redirected too many times. |
|
|
62
|
+
| `INVALID_REDIRECT` | The page returned an invalid redirect. |
|
|
63
|
+
| `TIMEOUT` | Extraction timed out. |
|
|
64
|
+
| `NETWORK_ERROR` | The page request failed. |
|
|
65
|
+
| `HTTP_STATUS` | The server returned an unsuccessful status. |
|
|
66
|
+
| `UNSUPPORTED_CONTENT_TYPE` | The response is not HTML. |
|
|
67
|
+
| `UNSUPPORTED_ENCODING` | The response uses an unsupported encoding. |
|
|
68
|
+
| `BODY_TOO_LARGE` | The response body is too large. |
|
|
69
|
+
| `INVALID_RESPONSE` | The server returned an invalid response. |
|
|
70
|
+
| `EXTRACTION_FAILED` | Article extraction failed. |
|
|
71
|
+
| `QUALITY_GATE` | The page did not contain a substantial article. |
|
|
72
|
+
| `BROWSER_FAILED` | Browser extraction failed. |
|
|
73
|
+
| `USAGE` | Bad command-line invocation. |
|
|
74
|
+
|
|
75
|
+
## Extraction pipeline
|
|
76
|
+
|
|
77
|
+
1. **Static pass** — `extractHtml()` normalizes links (`absolutizeLinks`),
|
|
78
|
+
canonicalizes the URL, then runs Defuddle to produce Markdown. No page
|
|
79
|
+
scripts execute and Defuddle network fallbacks are disabled.
|
|
80
|
+
2. **Quality gate** — a result passes only if it has a meaningful non-generic
|
|
81
|
+
title and ≥ 200 Markdown characters (`MIN_MARKDOWN_CHARS`).
|
|
82
|
+
3. **Playwright fallback** (unless `--no-browser`) — if the static pass fails
|
|
83
|
+
the quality gate, an isolated headless Chromium re-renders the page. All
|
|
84
|
+
browser HTTP(S) is deferred to the pinned Node fetch layer via
|
|
85
|
+
`buildSecureRouteHandler()` (see [Network safety](#network-safety)).
|
|
86
|
+
|
|
87
|
+
### Site-specific handling
|
|
88
|
+
|
|
89
|
+
- **Netease (`c.m.163.com`)** — author and published date live only in the
|
|
90
|
+
embedded `window.__INITIAL_STATE__` JSON, not in standard meta tags.
|
|
91
|
+
`extractNeteaseMeta()` reads the balanced `main` object (source /
|
|
92
|
+
`sourceinfo.tname` / `ptime`), scoped to `163.com` hostnames.
|
|
93
|
+
- **Lazy-loaded images** — Netease and similar sites render images with a
|
|
94
|
+
placeholder `src` (`empty.png`) and the real URL in `data-echo` /
|
|
95
|
+
`data-src` / `data-original`. `extractLazyImageSrc()` prefers those
|
|
96
|
+
attributes (in that order) and rewrites `src` to the real, same-page
|
|
97
|
+
absolute URL before Defuddle.
|
|
98
|
+
|
|
99
|
+
## Network safety
|
|
100
|
+
|
|
101
|
+
- Every request is validated and pinned: non-default ports rejected, redirects
|
|
102
|
+
revalidated, DNS answers must all be public, and each request is bound to an
|
|
103
|
+
approved address while preserving TLS SNI/hostname checks.
|
|
104
|
+
- Static responses require an HTML media type and a bounded body.
|
|
105
|
+
- In the browser, **Chromium native DNS and HTTP(S) are disabled**
|
|
106
|
+
(`--host-resolver-rules=MAP * ~NOTFOUND`). Every HTTP(S) page resource is
|
|
107
|
+
fetched by the pinned Node layer and injected via `route.fulfill`;
|
|
108
|
+
WebSockets, service workers, and downloads are blocked. Request count,
|
|
109
|
+
per-resource bytes, total bytes, and wall time are bounded.
|
|
110
|
+
- The naked `node src/cli.mjs` process may print page-controlled diagnostics
|
|
111
|
+
only internally; the CLI protocol and consuming logs stay free of untrusted
|
|
112
|
+
stack traces.
|
|
113
|
+
|
|
114
|
+
See `src/network-policy.mjs` for the full policy implementation.
|
|
115
|
+
|
|
116
|
+
## Tests
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm test # node --test (requires locked deps installed)
|
|
120
|
+
npm run check # node --check on each src module
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Test files live in `test/` and cover the CLI protocol, extraction, network
|
|
124
|
+
policy, and security regressions.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tiny-codes/web-clip-extractor",
|
|
3
|
+
"version": "0.2.0-alpha.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=18"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"web",
|
|
10
|
+
"clip",
|
|
11
|
+
"extractor",
|
|
12
|
+
"obsidian"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/shijistar/url-to-obsidian",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/shijistar/url-to-obsidian/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/shijistar/url-to-obsidian.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Fengbao Li <shijistar@gmail.com>",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test",
|
|
31
|
+
"check": "node --check src/cli.mjs && node --check src/extractor.mjs && node --check src/network-policy.mjs"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"defuddle": "^0.19.2",
|
|
35
|
+
"linkedom": "^0.18.13",
|
|
36
|
+
"playwright": "^1.61.1"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { extractUrl } from './extractor.mjs';
|
|
4
|
+
|
|
5
|
+
const PUBLIC_ERRORS = {
|
|
6
|
+
INVALID_URL: 'The URL is malformed.',
|
|
7
|
+
UNSUPPORTED_SCHEME: 'Only HTTP and HTTPS URLs are allowed.',
|
|
8
|
+
URL_CREDENTIALS: 'URLs containing credentials are not allowed.',
|
|
9
|
+
NON_DEFAULT_PORT: 'Non-default ports are not allowed.',
|
|
10
|
+
DNS_FAILED: 'The hostname could not be resolved.',
|
|
11
|
+
BLOCKED_ADDRESS: 'The destination is blocked by network policy.',
|
|
12
|
+
TOO_MANY_REDIRECTS: 'The page redirected too many times.',
|
|
13
|
+
INVALID_REDIRECT: 'The page returned an invalid redirect.',
|
|
14
|
+
TIMEOUT: 'The extraction timed out.',
|
|
15
|
+
NETWORK_ERROR: 'The page request failed.',
|
|
16
|
+
HTTP_STATUS: 'The server returned an unsuccessful status.',
|
|
17
|
+
UNSUPPORTED_CONTENT_TYPE: 'The response is not HTML.',
|
|
18
|
+
UNSUPPORTED_ENCODING: 'The response uses an unsupported encoding.',
|
|
19
|
+
BODY_TOO_LARGE: 'The response body is too large.',
|
|
20
|
+
INVALID_RESPONSE: 'The server returned an invalid response.',
|
|
21
|
+
EXTRACTION_FAILED: 'Article extraction failed.',
|
|
22
|
+
QUALITY_GATE: 'The page did not contain a substantial article.',
|
|
23
|
+
BROWSER_FAILED: 'Browser extraction failed.',
|
|
24
|
+
USAGE: 'Usage: node src/cli.mjs <url> [--no-browser]',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function output(value) {
|
|
28
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main(argv) {
|
|
32
|
+
const args = [...argv];
|
|
33
|
+
const noBrowserIndex = args.indexOf('--no-browser');
|
|
34
|
+
const noBrowser = noBrowserIndex !== -1;
|
|
35
|
+
if (noBrowser) args.splice(noBrowserIndex, 1);
|
|
36
|
+
if (args.length !== 1 || args[0].startsWith('--')) {
|
|
37
|
+
output({ ok: false, error: PUBLIC_ERRORS.USAGE, code: 'USAGE' });
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = await extractUrl(args[0], { allowBrowser: !noBrowser });
|
|
44
|
+
output({ ok: true, ...result });
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
const code = typeof cause?.code === 'string' && Object.hasOwn(PUBLIC_ERRORS, cause.code)
|
|
47
|
+
? cause.code
|
|
48
|
+
: 'EXTRACTION_FAILED';
|
|
49
|
+
output({ ok: false, error: PUBLIC_ERRORS[code], code });
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await main(process.argv.slice(2));
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { Defuddle } from 'defuddle/node';
|
|
2
|
+
import { parseHTML } from 'linkedom';
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_TIMEOUT_MS,
|
|
7
|
+
PolicyError,
|
|
8
|
+
fetchResourceOnce,
|
|
9
|
+
fetchHtml as secureFetchHtml,
|
|
10
|
+
normalizeUrl,
|
|
11
|
+
resolveAndValidateUrl,
|
|
12
|
+
} from './network-policy.mjs';
|
|
13
|
+
|
|
14
|
+
export const MIN_MARKDOWN_CHARS = 200;
|
|
15
|
+
export const MAX_BROWSER_REQUESTS = 80;
|
|
16
|
+
export const BROWSER_TIMEOUT_MS = 30_000;
|
|
17
|
+
export const MAX_BROWSER_RESOURCE_BYTES = 4 * 1024 * 1024;
|
|
18
|
+
export const MAX_BROWSER_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const GENERIC_TITLES = new Set([
|
|
21
|
+
'home',
|
|
22
|
+
'homepage',
|
|
23
|
+
'index',
|
|
24
|
+
'new tab',
|
|
25
|
+
'untitled',
|
|
26
|
+
'untitled page',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export class ExtractorError extends Error {
|
|
30
|
+
constructor(code, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'ExtractorError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cleanString(value) {
|
|
38
|
+
const cleaned = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
39
|
+
return cleaned || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function countWords(markdown) {
|
|
43
|
+
const words = markdown
|
|
44
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
45
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
46
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
47
|
+
.match(/[\p{L}\p{N}]+(?:['’_-][\p{L}\p{N}]+)*/gu);
|
|
48
|
+
return words?.length || 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function meaningfulTitle(title) {
|
|
52
|
+
const cleaned = cleanString(title);
|
|
53
|
+
if (!cleaned || cleaned.length < 3) return false;
|
|
54
|
+
if (GENERIC_TITLES.has(cleaned.toLowerCase())) return false;
|
|
55
|
+
return /[\p{L}\p{N}]/u.test(cleaned);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function collectKeywords(document) {
|
|
59
|
+
const selectors = [
|
|
60
|
+
'meta[name="keywords" i]',
|
|
61
|
+
'meta[name="news_keywords" i]',
|
|
62
|
+
'meta[property="article:tag" i]',
|
|
63
|
+
];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const keywords = [];
|
|
66
|
+
for (const selector of selectors) {
|
|
67
|
+
for (const node of document.querySelectorAll(selector)) {
|
|
68
|
+
const raw = node.getAttribute('content');
|
|
69
|
+
if (!raw) continue;
|
|
70
|
+
for (const part of raw.split(/[;,\n]+/)) {
|
|
71
|
+
const cleaned = cleanString(part);
|
|
72
|
+
if (!cleaned || !/[\p{L}\p{N}]/u.test(cleaned)) continue;
|
|
73
|
+
const key = cleaned.toLocaleLowerCase('en-US');
|
|
74
|
+
if (seen.has(key)) continue;
|
|
75
|
+
seen.add(key);
|
|
76
|
+
keywords.push(cleaned);
|
|
77
|
+
if (keywords.length >= 32) return keywords;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return keywords;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function meetsQualityGate(result) {
|
|
85
|
+
return meaningfulTitle(result?.title) && typeof result?.markdown === 'string' && result.markdown.trim().length >= MIN_MARKDOWN_CHARS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Extract the balanced JSON object starting at the given index ("" if unbalanced). */
|
|
89
|
+
function extractBalancedObjectAt(text, startIndex) {
|
|
90
|
+
let depth = 0;
|
|
91
|
+
let inString = false;
|
|
92
|
+
let escaped = false;
|
|
93
|
+
for (let i = startIndex; i < text.length; i += 1) {
|
|
94
|
+
const ch = text[i];
|
|
95
|
+
if (inString) {
|
|
96
|
+
if (escaped) escaped = false;
|
|
97
|
+
else if (ch === '\\') escaped = true;
|
|
98
|
+
else if (ch === '"') inString = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === '"') {
|
|
102
|
+
inString = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === '{') depth += 1;
|
|
106
|
+
else if (ch === '}') {
|
|
107
|
+
depth -= 1;
|
|
108
|
+
if (depth === 0) return text.slice(startIndex, i + 1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Netease mobile pages (c.m.163.com) expose author/published only inside the
|
|
116
|
+
* embedded `window.__INITIAL_STATE__` JSON — not in standard meta tags or
|
|
117
|
+
* JSON-LD. The full state blob may be followed by extra script payloads that
|
|
118
|
+
* make a wholesale JSON.parse unreliable, so we read the balanced `main`
|
|
119
|
+
* object with field-level regexes and ignore recommendation-list sources.
|
|
120
|
+
*/
|
|
121
|
+
function extractNeteaseMeta(html) {
|
|
122
|
+
const marker = 'window.__INITIAL_STATE__';
|
|
123
|
+
const markerIndex = html.indexOf(marker);
|
|
124
|
+
if (markerIndex < 0) return { author: '', published: '' };
|
|
125
|
+
const eqIndex = html.indexOf('=', markerIndex + marker.length);
|
|
126
|
+
if (eqIndex < 0) return { author: '', published: '' };
|
|
127
|
+
|
|
128
|
+
const stateBlock = extractBalancedObjectAt(html, eqIndex + 1);
|
|
129
|
+
if (!stateBlock) return { author: '', published: '' };
|
|
130
|
+
|
|
131
|
+
const mainKey = stateBlock.indexOf('"main"');
|
|
132
|
+
const mainStart = mainKey >= 0 ? stateBlock.indexOf('{', mainKey) : -1;
|
|
133
|
+
const mainBlock = mainStart >= 0 ? extractBalancedObjectAt(stateBlock, mainStart) : stateBlock;
|
|
134
|
+
|
|
135
|
+
let author = /"source"\s*:\s*"([^"]+)"/.exec(mainBlock)?.[1] ?? '';
|
|
136
|
+
if (!author) {
|
|
137
|
+
author = /"sourceinfo"\s*:\s*\{[\s\S]*?"tname"\s*:\s*"([^"]+)"/.exec(mainBlock)?.[1] ?? '';
|
|
138
|
+
}
|
|
139
|
+
const published = /"ptime"\s*:\s*"([^"]+)"/.exec(mainBlock)?.[1] ?? '';
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
author: cleanString(author) || '',
|
|
143
|
+
published: cleanString(published) || '',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function safeCanonical(document, sourceUrl) {
|
|
148
|
+
const canonical = document.querySelector('link[rel~="canonical" i]');
|
|
149
|
+
const href = canonical?.getAttribute('href');
|
|
150
|
+
if (!href) return sourceUrl.href;
|
|
151
|
+
try {
|
|
152
|
+
const normalized = normalizeUrl(new URL(href, sourceUrl).href);
|
|
153
|
+
if (normalized.origin !== sourceUrl.origin) return sourceUrl.href;
|
|
154
|
+
canonical.setAttribute('href', normalized.href);
|
|
155
|
+
return normalized.href;
|
|
156
|
+
} catch {
|
|
157
|
+
canonical?.remove();
|
|
158
|
+
return sourceUrl.href;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function absolutizeLinks(document, sourceUrl) {
|
|
163
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
164
|
+
const href = anchor.getAttribute('href')?.trim();
|
|
165
|
+
if (!href || href.startsWith('#')) continue;
|
|
166
|
+
try {
|
|
167
|
+
const absolute = new URL(href, sourceUrl);
|
|
168
|
+
if (absolute.protocol === 'http:' || absolute.protocol === 'https:' || absolute.protocol === 'mailto:') {
|
|
169
|
+
anchor.setAttribute('href', absolute.href);
|
|
170
|
+
} else {
|
|
171
|
+
anchor.removeAttribute('href');
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
anchor.removeAttribute('href');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const element of document.querySelectorAll('img[src], source[src]')) {
|
|
178
|
+
if (element.tagName?.toLowerCase() === 'img') {
|
|
179
|
+
// 网易等站点用懒加载:src 是占位图,真实地址藏在 data-echo/data-src/data-original。
|
|
180
|
+
// 优先用真实地址覆盖占位 src,否则 Defuddle 只会抓到占位图。
|
|
181
|
+
const lazySrc = extractLazyImageSrc(element, sourceUrl);
|
|
182
|
+
if (lazySrc) element.setAttribute('src', lazySrc);
|
|
183
|
+
}
|
|
184
|
+
const src = element.getAttribute('src')?.trim();
|
|
185
|
+
if (!src) continue;
|
|
186
|
+
try {
|
|
187
|
+
const absolute = new URL(src, sourceUrl);
|
|
188
|
+
if (absolute.protocol === 'http:' || absolute.protocol === 'https:') element.setAttribute('src', absolute.href);
|
|
189
|
+
else element.removeAttribute('src');
|
|
190
|
+
} catch {
|
|
191
|
+
element.removeAttribute('src');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 懒加载真实图片地址:优先尝试 data-echo / data-src / data-original,
|
|
197
|
+
// 解析为同源可访问的 http(s) URL;失败时回退到原 src(可能已是占位图)。
|
|
198
|
+
function extractLazyImageSrc(element, sourceUrl) {
|
|
199
|
+
for (const attr of ['data-echo', 'data-src', 'data-original', 'data-lazy-src']) {
|
|
200
|
+
const candidate = element.getAttribute(attr)?.trim();
|
|
201
|
+
if (!candidate) continue;
|
|
202
|
+
try {
|
|
203
|
+
const absolute = new URL(candidate, sourceUrl);
|
|
204
|
+
if (absolute.protocol === 'http:' || absolute.protocol === 'https:') return absolute.href;
|
|
205
|
+
} catch {
|
|
206
|
+
// 忽略无法解析的候选,继续尝试下一个属性
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Parse already-fetched HTML without evaluating page scripts or allowing Defuddle network fallbacks. */
|
|
213
|
+
export async function extractHtml(html, inputUrl) {
|
|
214
|
+
if (typeof html !== 'string') throw new ExtractorError('INVALID_HTML', 'The HTML input is invalid.');
|
|
215
|
+
const sourceUrl = normalizeUrl(inputUrl);
|
|
216
|
+
const { document } = parseHTML(html);
|
|
217
|
+
const canonicalUrl = safeCanonical(document, sourceUrl);
|
|
218
|
+
absolutizeLinks(document, sourceUrl);
|
|
219
|
+
|
|
220
|
+
let parsed;
|
|
221
|
+
const originalConsoleError = console.error;
|
|
222
|
+
const originalConsoleWarn = console.warn;
|
|
223
|
+
try {
|
|
224
|
+
console.error = () => {};
|
|
225
|
+
console.warn = () => {};
|
|
226
|
+
parsed = await Defuddle(document, sourceUrl.href, {
|
|
227
|
+
markdown: true,
|
|
228
|
+
useAsync: false,
|
|
229
|
+
debug: false,
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
throw new ExtractorError('EXTRACTION_FAILED', 'Article extraction failed.');
|
|
233
|
+
} finally {
|
|
234
|
+
// Defuddle can log page-controlled malformed metadata URLs. Keep the CLI
|
|
235
|
+
// protocol and Hermes logs free of untrusted diagnostics and stack traces.
|
|
236
|
+
console.error = originalConsoleError;
|
|
237
|
+
console.warn = originalConsoleWarn;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const markdown = typeof parsed.content === 'string' ? parsed.content.trim() : '';
|
|
241
|
+
const embeddedMeta = sourceUrl.hostname.includes('163.com') ? extractNeteaseMeta(html) : null;
|
|
242
|
+
return {
|
|
243
|
+
title: cleanString(parsed.title),
|
|
244
|
+
author: cleanString(parsed.author) || embeddedMeta?.author || '',
|
|
245
|
+
published: cleanString(parsed.published) || embeddedMeta?.published || '',
|
|
246
|
+
description: cleanString(parsed.description) || '',
|
|
247
|
+
site: cleanString(parsed.site) || cleanString(parsed.domain) || sourceUrl.hostname,
|
|
248
|
+
canonicalUrl,
|
|
249
|
+
keywords: collectKeywords(document),
|
|
250
|
+
markdown,
|
|
251
|
+
wordCount: Number.isSafeInteger(parsed.wordCount) && parsed.wordCount >= 0
|
|
252
|
+
? parsed.wordCount
|
|
253
|
+
: countWords(markdown),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function qualityError() {
|
|
258
|
+
return new ExtractorError('QUALITY_GATE', 'The page did not contain a substantial article.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function closeQuietly(target) {
|
|
262
|
+
try {
|
|
263
|
+
await target?.close();
|
|
264
|
+
} catch {
|
|
265
|
+
// Cleanup must not hide the extraction result or its original error.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function responseHeadersForBrowser(headers) {
|
|
270
|
+
const blocked = new Set([
|
|
271
|
+
'connection', 'content-encoding', 'content-length', 'keep-alive',
|
|
272
|
+
'proxy-authenticate', 'proxy-authorization', 'set-cookie', 'te',
|
|
273
|
+
'trailer', 'transfer-encoding', 'upgrade',
|
|
274
|
+
]);
|
|
275
|
+
const output = {};
|
|
276
|
+
for (const [name, value] of Object.entries(headers || {})) {
|
|
277
|
+
const lower = name.toLowerCase();
|
|
278
|
+
if (blocked.has(lower) || value == null) continue;
|
|
279
|
+
output[lower] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
280
|
+
}
|
|
281
|
+
return output;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Build a browser route that never lets Chromium perform HTTP(S) I/O. */
|
|
285
|
+
export function buildSecureRouteHandler({
|
|
286
|
+
resolver,
|
|
287
|
+
allowNonDefaultPorts = false,
|
|
288
|
+
secureFetcher = fetchResourceOnce,
|
|
289
|
+
timeoutMs = BROWSER_TIMEOUT_MS,
|
|
290
|
+
maxRequests = MAX_BROWSER_REQUESTS,
|
|
291
|
+
maxResourceBytes = MAX_BROWSER_RESOURCE_BYTES,
|
|
292
|
+
maxTotalBytes = MAX_BROWSER_TOTAL_BYTES,
|
|
293
|
+
} = {}) {
|
|
294
|
+
let requestCount = 0;
|
|
295
|
+
let totalBytes = 0;
|
|
296
|
+
return async route => {
|
|
297
|
+
requestCount += 1;
|
|
298
|
+
if (requestCount > maxRequests) {
|
|
299
|
+
await route.abort('blockedbyclient');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const request = route.request();
|
|
304
|
+
let requestUrl;
|
|
305
|
+
try {
|
|
306
|
+
requestUrl = new URL(request.url());
|
|
307
|
+
} catch {
|
|
308
|
+
await route.abort('blockedbyclient');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (requestUrl.protocol === 'data:' || requestUrl.protocol === 'about:') {
|
|
313
|
+
await route.continue();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if ((requestUrl.protocol !== 'http:' && requestUrl.protocol !== 'https:') || request.method() !== 'GET') {
|
|
317
|
+
await route.abort('blockedbyclient');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const remaining = maxTotalBytes - totalBytes;
|
|
322
|
+
if (remaining <= 0) {
|
|
323
|
+
await route.abort('blockedbyclient');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const response = await secureFetcher(requestUrl.href, {
|
|
328
|
+
resolver,
|
|
329
|
+
allowNonDefaultPorts,
|
|
330
|
+
timeoutMs,
|
|
331
|
+
maxBytes: Math.min(maxResourceBytes, remaining),
|
|
332
|
+
});
|
|
333
|
+
totalBytes += response.body.length;
|
|
334
|
+
await route.fulfill({
|
|
335
|
+
status: response.statusCode,
|
|
336
|
+
headers: responseHeadersForBrowser(response.headers),
|
|
337
|
+
body: response.body,
|
|
338
|
+
});
|
|
339
|
+
} catch {
|
|
340
|
+
await route.abort('blockedbyclient');
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function extractWithPlaywright(inputUrl, {
|
|
346
|
+
resolver,
|
|
347
|
+
allowNonDefaultPorts,
|
|
348
|
+
browserLauncher = chromium,
|
|
349
|
+
timeoutMs = BROWSER_TIMEOUT_MS,
|
|
350
|
+
maxRequests = MAX_BROWSER_REQUESTS,
|
|
351
|
+
} = {}) {
|
|
352
|
+
const approved = await resolveAndValidateUrl(inputUrl, { resolver, allowNonDefaultPorts });
|
|
353
|
+
let browser;
|
|
354
|
+
let context;
|
|
355
|
+
try {
|
|
356
|
+
browser = await browserLauncher.launch({
|
|
357
|
+
headless: true,
|
|
358
|
+
args: [
|
|
359
|
+
'--host-resolver-rules=MAP * ~NOTFOUND',
|
|
360
|
+
'--disable-webrtc',
|
|
361
|
+
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
context = await browser.newContext({
|
|
365
|
+
serviceWorkers: 'block',
|
|
366
|
+
acceptDownloads: false,
|
|
367
|
+
});
|
|
368
|
+
context.setDefaultTimeout(timeoutMs);
|
|
369
|
+
context.setDefaultNavigationTimeout(timeoutMs);
|
|
370
|
+
|
|
371
|
+
await context.route('**/*', buildSecureRouteHandler({
|
|
372
|
+
resolver,
|
|
373
|
+
allowNonDefaultPorts,
|
|
374
|
+
timeoutMs,
|
|
375
|
+
maxRequests,
|
|
376
|
+
}));
|
|
377
|
+
if (typeof context.routeWebSocket === 'function') {
|
|
378
|
+
await context.routeWebSocket('**/*', socket => socket.close({ code: 1008, reason: 'blocked' }));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const page = await context.newPage();
|
|
382
|
+
await page.goto(approved.url.href, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
383
|
+
const finalUrl = page.url();
|
|
384
|
+
await resolveAndValidateUrl(finalUrl, { resolver, allowNonDefaultPorts });
|
|
385
|
+
const result = await extractHtml(await page.content(), finalUrl);
|
|
386
|
+
if (!meetsQualityGate(result)) throw qualityError();
|
|
387
|
+
return { ...result, url: normalizeUrl(finalUrl).href, method: 'playwright' };
|
|
388
|
+
} catch (cause) {
|
|
389
|
+
if (cause instanceof PolicyError || cause instanceof ExtractorError) throw cause;
|
|
390
|
+
throw new ExtractorError('BROWSER_FAILED', 'Browser extraction failed.');
|
|
391
|
+
} finally {
|
|
392
|
+
await closeQuietly(context);
|
|
393
|
+
await closeQuietly(browser);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Fetch and extract a URL statically, with an isolated Chromium fallback for low-quality pages. */
|
|
398
|
+
export async function extractUrl(inputUrl, options = {}) {
|
|
399
|
+
const normalized = normalizeUrl(inputUrl, { allowNonDefaultPorts: options.allowNonDefaultPorts });
|
|
400
|
+
const fetcher = options.fetchHtml || secureFetchHtml;
|
|
401
|
+
const fetched = await fetcher(normalized.href, {
|
|
402
|
+
resolver: options.resolver,
|
|
403
|
+
maxRedirects: options.maxRedirects,
|
|
404
|
+
maxBytes: options.maxBytes,
|
|
405
|
+
timeoutMs: options.staticTimeoutMs || DEFAULT_TIMEOUT_MS,
|
|
406
|
+
allowNonDefaultPorts: options.allowNonDefaultPorts,
|
|
407
|
+
});
|
|
408
|
+
if (!fetched || typeof fetched.html !== 'string' || typeof fetched.finalUrl !== 'string') {
|
|
409
|
+
throw new ExtractorError('INVALID_RESPONSE', 'The static fetch returned an invalid response.');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const staticResult = await extractHtml(fetched.html, fetched.finalUrl);
|
|
413
|
+
if (meetsQualityGate(staticResult)) {
|
|
414
|
+
return { ...staticResult, url: normalizeUrl(fetched.finalUrl).href, method: 'static' };
|
|
415
|
+
}
|
|
416
|
+
if (options.allowBrowser === false || options.dynamic === false || options.browser === false) throw qualityError();
|
|
417
|
+
|
|
418
|
+
return extractWithPlaywright(normalized.href, {
|
|
419
|
+
resolver: options.resolver,
|
|
420
|
+
allowNonDefaultPorts: options.allowNonDefaultPorts,
|
|
421
|
+
browserLauncher: options.browserLauncher,
|
|
422
|
+
timeoutMs: options.browserTimeoutMs || BROWSER_TIMEOUT_MS,
|
|
423
|
+
maxRequests: options.maxBrowserRequests || MAX_BROWSER_REQUESTS,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { promises as dns } from 'node:dns';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { Transform, Writable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
|
|
8
|
+
|
|
9
|
+
export const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
11
|
+
export const DEFAULT_MAX_REDIRECTS = 5;
|
|
12
|
+
|
|
13
|
+
const USER_AGENT = 'Web-Clip-Extractor/0.1 (+local article extractor)';
|
|
14
|
+
const HTML_CONTENT_TYPES = new Set(['text/html', 'application/xhtml+xml']);
|
|
15
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
16
|
+
|
|
17
|
+
export class PolicyError extends Error {
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'PolicyError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ipv4Number(address) {
|
|
26
|
+
if (!net.isIPv4(address)) return null;
|
|
27
|
+
return address.split('.').reduce((value, part) => value * 256 + Number(part), 0) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ipv4InCidr(value, base, prefix) {
|
|
31
|
+
const shift = 32 - prefix;
|
|
32
|
+
return value >>> shift === base >>> shift;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BLOCKED_IPV4_RANGES = [
|
|
36
|
+
['0.0.0.0', 8],
|
|
37
|
+
['10.0.0.0', 8],
|
|
38
|
+
['100.64.0.0', 10],
|
|
39
|
+
['127.0.0.0', 8],
|
|
40
|
+
['169.254.0.0', 16],
|
|
41
|
+
['172.16.0.0', 12],
|
|
42
|
+
['192.0.0.0', 24],
|
|
43
|
+
['192.0.2.0', 24],
|
|
44
|
+
['192.88.99.0', 24],
|
|
45
|
+
['192.168.0.0', 16],
|
|
46
|
+
['198.18.0.0', 15],
|
|
47
|
+
['198.51.100.0', 24],
|
|
48
|
+
['203.0.113.0', 24],
|
|
49
|
+
['224.0.0.0', 4],
|
|
50
|
+
['240.0.0.0', 4],
|
|
51
|
+
].map(([base, prefix]) => [ipv4Number(base), prefix]);
|
|
52
|
+
|
|
53
|
+
function parseIpv6(address) {
|
|
54
|
+
if (typeof address !== 'string' || address.includes('%') || !net.isIPv6(address)) return null;
|
|
55
|
+
|
|
56
|
+
let source = address.toLowerCase();
|
|
57
|
+
const dottedIndex = source.lastIndexOf(':');
|
|
58
|
+
if (source.includes('.')) {
|
|
59
|
+
const dotted = source.slice(dottedIndex + 1);
|
|
60
|
+
const value = ipv4Number(dotted);
|
|
61
|
+
if (value === null) return null;
|
|
62
|
+
source = `${source.slice(0, dottedIndex)}:${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const halves = source.split('::');
|
|
66
|
+
if (halves.length > 2) return null;
|
|
67
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
68
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
69
|
+
const missing = 8 - left.length - right.length;
|
|
70
|
+
if ((halves.length === 1 && missing !== 0) || missing < 0) return null;
|
|
71
|
+
|
|
72
|
+
const words = [...left, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...right].map(
|
|
73
|
+
(part) => Number.parseInt(part, 16),
|
|
74
|
+
);
|
|
75
|
+
if (
|
|
76
|
+
words.length !== 8 ||
|
|
77
|
+
words.some((word) => !Number.isInteger(word) || word < 0 || word > 0xffff)
|
|
78
|
+
)
|
|
79
|
+
return null;
|
|
80
|
+
|
|
81
|
+
return words.reduce((value, word) => (value << 16n) | BigInt(word), 0n);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ipv6InCidr(value, base, prefix) {
|
|
85
|
+
const shift = 128n - BigInt(prefix);
|
|
86
|
+
return value >> shift === base >> shift;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const BLOCKED_IPV6_RANGES = [
|
|
90
|
+
['::', 96],
|
|
91
|
+
['::', 128],
|
|
92
|
+
['::1', 128],
|
|
93
|
+
['64:ff9b::', 96],
|
|
94
|
+
['64:ff9b:1::', 48],
|
|
95
|
+
['100::', 64],
|
|
96
|
+
['2001::', 23],
|
|
97
|
+
['2001:2::', 48],
|
|
98
|
+
['2001:10::', 28],
|
|
99
|
+
['2001:20::', 28],
|
|
100
|
+
['2001:db8::', 32],
|
|
101
|
+
['2002::', 16],
|
|
102
|
+
['3fff::', 20],
|
|
103
|
+
['5f00::', 16],
|
|
104
|
+
['fc00::', 7],
|
|
105
|
+
['fe80::', 10],
|
|
106
|
+
['fec0::', 10],
|
|
107
|
+
['ff00::', 8],
|
|
108
|
+
].map(([base, prefix]) => [parseIpv6(base), prefix]);
|
|
109
|
+
|
|
110
|
+
/** Return true for invalid, non-routable, private, or special-use IP addresses. */
|
|
111
|
+
export function isBlockedIp(address) {
|
|
112
|
+
const ipv4 = ipv4Number(address);
|
|
113
|
+
if (ipv4 !== null) {
|
|
114
|
+
return BLOCKED_IPV4_RANGES.some(([base, prefix]) => ipv4InCidr(ipv4, base, prefix));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ipv6 = parseIpv6(address);
|
|
118
|
+
if (ipv6 === null) return true;
|
|
119
|
+
|
|
120
|
+
// IPv4-mapped IPv6 (::ffff:0:0/96) is classified by its embedded IPv4 value.
|
|
121
|
+
if (ipv6 >> 32n === 0xffffn) {
|
|
122
|
+
const mapped = Number(ipv6 & 0xffffffffn);
|
|
123
|
+
return BLOCKED_IPV4_RANGES.some(([base, prefix]) => ipv4InCidr(mapped, base, prefix));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return BLOCKED_IPV6_RANGES.some(([base, prefix]) => ipv6InCidr(ipv6, base, prefix));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function error(code, message) {
|
|
130
|
+
return new PolicyError(code, message);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse and canonicalize a URL before any DNS or network operation. */
|
|
134
|
+
export function normalizeUrl(input, { allowNonDefaultPorts = false } = {}) {
|
|
135
|
+
let url;
|
|
136
|
+
try {
|
|
137
|
+
url = new URL(input);
|
|
138
|
+
} catch {
|
|
139
|
+
throw error('INVALID_URL', 'The URL is malformed.');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
143
|
+
throw error('UNSUPPORTED_SCHEME', 'Only HTTP and HTTPS URLs are allowed.');
|
|
144
|
+
}
|
|
145
|
+
if (url.username || url.password) {
|
|
146
|
+
throw error('URL_CREDENTIALS', 'URLs containing credentials are not allowed.');
|
|
147
|
+
}
|
|
148
|
+
if (!url.hostname) throw error('INVALID_URL', 'The URL has no hostname.');
|
|
149
|
+
if (url.port && !allowNonDefaultPorts) {
|
|
150
|
+
throw error('NON_DEFAULT_PORT', 'Non-default ports are not allowed.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
url.hash = '';
|
|
154
|
+
const entries = [...url.searchParams.entries()]
|
|
155
|
+
.filter(
|
|
156
|
+
([key]) =>
|
|
157
|
+
!/^utm_/i.test(key) && !['fbclid', 'gclid', 'share_token'].includes(key.toLowerCase()),
|
|
158
|
+
)
|
|
159
|
+
.map(([key, value], index) => ({ key, value, index }))
|
|
160
|
+
.sort((a, b) => {
|
|
161
|
+
if (a.key !== b.key) return a.key < b.key ? -1 : 1;
|
|
162
|
+
if (a.value !== b.value) return a.value < b.value ? -1 : 1;
|
|
163
|
+
return a.index - b.index;
|
|
164
|
+
});
|
|
165
|
+
url.search = '';
|
|
166
|
+
for (const { key, value } of entries) url.searchParams.append(key, value);
|
|
167
|
+
return url;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hostnameForDns(url) {
|
|
171
|
+
return url.hostname.startsWith('[') && url.hostname.endsWith(']')
|
|
172
|
+
? url.hostname.slice(1, -1)
|
|
173
|
+
: url.hostname;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolve a URL once, reject it if any answer is unsafe, and return a pinned answer. */
|
|
177
|
+
export async function resolveAndValidateUrl(
|
|
178
|
+
input,
|
|
179
|
+
{ resolver = dns.lookup, allowNonDefaultPorts = false } = {},
|
|
180
|
+
) {
|
|
181
|
+
const url =
|
|
182
|
+
input instanceof URL
|
|
183
|
+
? normalizeUrl(input.href, { allowNonDefaultPorts })
|
|
184
|
+
: normalizeUrl(input, { allowNonDefaultPorts });
|
|
185
|
+
let answers;
|
|
186
|
+
try {
|
|
187
|
+
answers = await resolver(hostnameForDns(url), { all: true, verbatim: true });
|
|
188
|
+
} catch {
|
|
189
|
+
throw error('DNS_FAILED', 'The hostname could not be resolved.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!Array.isArray(answers) || answers.length === 0) {
|
|
193
|
+
throw error('DNS_FAILED', 'The hostname returned no addresses.');
|
|
194
|
+
}
|
|
195
|
+
for (const answer of answers) {
|
|
196
|
+
if (!answer || (answer.family !== 4 && answer.family !== 6) || isBlockedIp(answer.address)) {
|
|
197
|
+
throw error('BLOCKED_ADDRESS', 'The hostname resolves to a blocked address.');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
url,
|
|
203
|
+
address: answers[0].address,
|
|
204
|
+
family: answers[0].family,
|
|
205
|
+
addresses: answers.map((answer) => ({ address: answer.address, family: answer.family })),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function firstHeader(headers, name) {
|
|
210
|
+
const value = headers?.[name];
|
|
211
|
+
return Array.isArray(value) ? value[0] : value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function contentType(headers) {
|
|
215
|
+
return String(firstHeader(headers, 'content-type') || '')
|
|
216
|
+
.split(';', 1)[0]
|
|
217
|
+
.trim()
|
|
218
|
+
.toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function byteLimit(maxBytes) {
|
|
222
|
+
let seen = 0;
|
|
223
|
+
return new Transform({
|
|
224
|
+
transform(chunk, encoding, callback) {
|
|
225
|
+
seen += chunk.length;
|
|
226
|
+
if (seen > maxBytes) callback(error('BODY_TOO_LARGE', 'The response body is too large.'));
|
|
227
|
+
else callback(null, chunk);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function decoderFor(headers) {
|
|
233
|
+
const encoding = String(firstHeader(headers, 'content-encoding') || 'identity')
|
|
234
|
+
.trim()
|
|
235
|
+
.toLowerCase();
|
|
236
|
+
if (!encoding || encoding === 'identity') return null;
|
|
237
|
+
if (encoding === 'gzip' || encoding === 'x-gzip') return createGunzip();
|
|
238
|
+
if (encoding === 'deflate') return createInflate();
|
|
239
|
+
if (encoding === 'br') return createBrotliDecompress();
|
|
240
|
+
throw error('UNSUPPORTED_ENCODING', 'The response uses an unsupported content encoding.');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function collectBody(response, maxBytes) {
|
|
244
|
+
const chunks = [];
|
|
245
|
+
const collector = new Writable({
|
|
246
|
+
write(chunk, encoding, callback) {
|
|
247
|
+
chunks.push(Buffer.from(chunk));
|
|
248
|
+
callback();
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const decoder = decoderFor(response.headers);
|
|
252
|
+
const streams = decoder
|
|
253
|
+
? [response, byteLimit(maxBytes), decoder, byteLimit(maxBytes), collector]
|
|
254
|
+
: [response, byteLimit(maxBytes), collector];
|
|
255
|
+
try {
|
|
256
|
+
await pipeline(streams);
|
|
257
|
+
} catch (cause) {
|
|
258
|
+
if (cause instanceof PolicyError) throw cause;
|
|
259
|
+
throw error('INVALID_RESPONSE', 'The response body could not be decoded.');
|
|
260
|
+
}
|
|
261
|
+
return Buffer.concat(chunks);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Make one HTTP request while forcing Node to use the already-approved DNS answer. */
|
|
265
|
+
export function performPinnedRequest({ url, address, family, timeoutMs, maxBytes, headers }) {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
268
|
+
const dnsHostname = hostnameForDns(url);
|
|
269
|
+
const request = transport.request(
|
|
270
|
+
{
|
|
271
|
+
protocol: url.protocol,
|
|
272
|
+
hostname: dnsHostname,
|
|
273
|
+
port: url.port || undefined,
|
|
274
|
+
method: 'GET',
|
|
275
|
+
path: `${url.pathname}${url.search}`,
|
|
276
|
+
agent: false,
|
|
277
|
+
family,
|
|
278
|
+
servername: net.isIP(dnsHostname) ? undefined : dnsHostname,
|
|
279
|
+
headers: {
|
|
280
|
+
'User-Agent': USER_AGENT,
|
|
281
|
+
Accept: 'text/html, application/xhtml+xml;q=0.9',
|
|
282
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
283
|
+
...headers,
|
|
284
|
+
Host: url.host,
|
|
285
|
+
},
|
|
286
|
+
lookup(_hostname, lookupOptions, callback) {
|
|
287
|
+
if (lookupOptions?.all) callback(null, [{ address, family }]);
|
|
288
|
+
else callback(null, address, family);
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
async (response) => {
|
|
292
|
+
try {
|
|
293
|
+
const statusCode = response.statusCode || 0;
|
|
294
|
+
if (REDIRECT_STATUSES.has(statusCode)) {
|
|
295
|
+
response.resume();
|
|
296
|
+
resolve({ statusCode, headers: response.headers, body: Buffer.alloc(0) });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const declaredSize = Number(firstHeader(response.headers, 'content-length'));
|
|
300
|
+
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
|
301
|
+
response.destroy();
|
|
302
|
+
reject(error('BODY_TOO_LARGE', 'The response body is too large.'));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const body = await collectBody(response, maxBytes);
|
|
306
|
+
resolve({ statusCode, headers: response.headers, body });
|
|
307
|
+
} catch (cause) {
|
|
308
|
+
reject(cause);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
request.setTimeout(timeoutMs, () =>
|
|
314
|
+
request.destroy(error('TIMEOUT', 'The request timed out.')),
|
|
315
|
+
);
|
|
316
|
+
request.once('error', (cause) => {
|
|
317
|
+
if (cause instanceof PolicyError) reject(cause);
|
|
318
|
+
else reject(error('NETWORK_ERROR', 'The request failed.'));
|
|
319
|
+
});
|
|
320
|
+
request.end();
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Fetch exactly one resource hop through an already-validated and pinned DNS
|
|
326
|
+
* answer. Redirects are returned to the caller so an interception layer can
|
|
327
|
+
* revalidate the browser's next request before any network I/O.
|
|
328
|
+
*/
|
|
329
|
+
export async function fetchResourceOnce(
|
|
330
|
+
input,
|
|
331
|
+
{
|
|
332
|
+
resolver = dns.lookup,
|
|
333
|
+
requestImpl = performPinnedRequest,
|
|
334
|
+
maxBytes = MAX_BODY_BYTES,
|
|
335
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
336
|
+
allowNonDefaultPorts = false,
|
|
337
|
+
headers = { Accept: '*/*' },
|
|
338
|
+
} = {},
|
|
339
|
+
) {
|
|
340
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
341
|
+
throw error('INVALID_OPTIONS', 'The body limit is invalid.');
|
|
342
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
343
|
+
throw error('INVALID_OPTIONS', 'The timeout is invalid.');
|
|
344
|
+
const approved = await resolveAndValidateUrl(input, { resolver, allowNonDefaultPorts });
|
|
345
|
+
const response = await requestImpl({
|
|
346
|
+
url: approved.url,
|
|
347
|
+
address: approved.address,
|
|
348
|
+
family: approved.family,
|
|
349
|
+
timeoutMs,
|
|
350
|
+
maxBytes,
|
|
351
|
+
headers,
|
|
352
|
+
});
|
|
353
|
+
if (
|
|
354
|
+
!response ||
|
|
355
|
+
!Number.isInteger(Number(response.statusCode)) ||
|
|
356
|
+
!Buffer.isBuffer(response.body)
|
|
357
|
+
) {
|
|
358
|
+
throw error('INVALID_RESPONSE', 'The response body is invalid.');
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
statusCode: Number(response.statusCode),
|
|
362
|
+
headers: response.headers || {},
|
|
363
|
+
body: response.body,
|
|
364
|
+
url: approved.url.href,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Securely fetch an HTML document, validating and pinning every redirect hop. */
|
|
369
|
+
export async function fetchHtml(
|
|
370
|
+
input,
|
|
371
|
+
{
|
|
372
|
+
resolver = dns.lookup,
|
|
373
|
+
requestImpl = performPinnedRequest,
|
|
374
|
+
maxRedirects = DEFAULT_MAX_REDIRECTS,
|
|
375
|
+
maxBytes = MAX_BODY_BYTES,
|
|
376
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
377
|
+
allowNonDefaultPorts = false,
|
|
378
|
+
headers,
|
|
379
|
+
} = {},
|
|
380
|
+
) {
|
|
381
|
+
if (!Number.isInteger(maxRedirects) || maxRedirects < 0)
|
|
382
|
+
throw error('INVALID_OPTIONS', 'The redirect limit is invalid.');
|
|
383
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
384
|
+
throw error('INVALID_OPTIONS', 'The body limit is invalid.');
|
|
385
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
386
|
+
throw error('INVALID_OPTIONS', 'The timeout is invalid.');
|
|
387
|
+
|
|
388
|
+
let current = normalizeUrl(input, { allowNonDefaultPorts });
|
|
389
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
390
|
+
const approved = await resolveAndValidateUrl(current, { resolver, allowNonDefaultPorts });
|
|
391
|
+
const response = await requestImpl({
|
|
392
|
+
url: approved.url,
|
|
393
|
+
address: approved.address,
|
|
394
|
+
family: approved.family,
|
|
395
|
+
timeoutMs,
|
|
396
|
+
maxBytes,
|
|
397
|
+
headers,
|
|
398
|
+
});
|
|
399
|
+
const statusCode = Number(response?.statusCode || 0);
|
|
400
|
+
|
|
401
|
+
if (REDIRECT_STATUSES.has(statusCode)) {
|
|
402
|
+
const location = firstHeader(response.headers, 'location');
|
|
403
|
+
if (!location) throw error('INVALID_REDIRECT', 'The redirect has no destination.');
|
|
404
|
+
if (redirects >= maxRedirects)
|
|
405
|
+
throw error('TOO_MANY_REDIRECTS', 'The response redirected too many times.');
|
|
406
|
+
let target;
|
|
407
|
+
try {
|
|
408
|
+
target = new URL(location, approved.url);
|
|
409
|
+
} catch {
|
|
410
|
+
throw error('INVALID_REDIRECT', 'The redirect destination is malformed.');
|
|
411
|
+
}
|
|
412
|
+
current = normalizeUrl(target.href, { allowNonDefaultPorts });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
417
|
+
throw error('HTTP_STATUS', 'The server returned an unsuccessful status.');
|
|
418
|
+
}
|
|
419
|
+
if (!HTML_CONTENT_TYPES.has(contentType(response.headers))) {
|
|
420
|
+
throw error('UNSUPPORTED_CONTENT_TYPE', 'The response is not HTML.');
|
|
421
|
+
}
|
|
422
|
+
if (!Buffer.isBuffer(response.body))
|
|
423
|
+
throw error('INVALID_RESPONSE', 'The response body is invalid.');
|
|
424
|
+
if (response.body.length > maxBytes)
|
|
425
|
+
throw error('BODY_TOO_LARGE', 'The response body is too large.');
|
|
426
|
+
|
|
427
|
+
return { html: response.body.toString('utf8'), finalUrl: approved.url.href };
|
|
428
|
+
}
|
|
429
|
+
}
|