@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 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
+ }