@link-assistant/web-capture 1.1.2 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -13
- package/Dockerfile +26 -21
- package/README.md +290 -160
- package/bin/web-capture.js +622 -17
- package/package.json +15 -4
- package/src/animation.js +283 -0
- package/src/archive.js +225 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/extract-images.js +120 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +335 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +281 -1
- package/src/localize-images.js +202 -0
- package/src/markdown.js +7 -2
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +263 -0
- package/src/retry.js +48 -0
- package/src/themed-image.js +167 -0
- package/src/verify.js +312 -0
- package/tests/e2e/docker.test.js +51 -12
- package/tests/e2e/process.test.js +34 -10
- package/tests/fixtures/habr-articles.js +45 -0
- package/tests/integration/api-endpoints.test.js +231 -0
- package/tests/integration/habr-article.test.js +337 -0
- package/tests/mock/index.test.js +5 -3
- package/tests/unit/animation.test.js +33 -0
- package/tests/unit/batch.test.js +114 -0
- package/tests/unit/browser.test.js +38 -2
- package/tests/unit/extract-images.test.js +233 -0
- package/tests/unit/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +278 -0
- package/tests/unit/heading-image.test.js +49 -0
- package/tests/unit/latex.test.js +123 -0
- package/tests/unit/localize-images.test.js +78 -0
- package/tests/unit/metadata.test.js +140 -0
- package/tests/unit/postprocess.test.js +110 -0
- package/tests/unit/retry.test.js +111 -0
- package/tests/unit/verify.test.js +182 -0
- package/.github/workflows/release.yml +0 -294
- package/ARCHITECTURE.md +0 -982
- package/LICENSE +0 -24
- package/apply.sh +0 -166
- package/docs/market-analysis-2025-05-13.md +0 -64
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -88
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -218
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -128
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -106
- package/scripts/version-and-commit.mjs +0 -237
package/bin/web-capture.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 2. Capture mode: web-capture <url> [options]
|
|
6
6
|
|
|
7
7
|
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
8
9
|
import { URL } from 'node:url';
|
|
9
10
|
import { makeConfig } from 'lino-arguments';
|
|
10
11
|
|
|
@@ -32,8 +33,46 @@ const config = makeConfig({
|
|
|
32
33
|
.option('format', {
|
|
33
34
|
alias: 'f',
|
|
34
35
|
type: 'string',
|
|
35
|
-
description:
|
|
36
|
-
|
|
36
|
+
description:
|
|
37
|
+
'Output format: markdown, md, html, image, png, jpeg, pdf, docx, archive',
|
|
38
|
+
default: 'markdown',
|
|
39
|
+
})
|
|
40
|
+
.option('theme', {
|
|
41
|
+
alias: 't',
|
|
42
|
+
type: 'string',
|
|
43
|
+
description:
|
|
44
|
+
'Color scheme for screenshots/PDF: light, dark, no-preference',
|
|
45
|
+
})
|
|
46
|
+
.option('width', {
|
|
47
|
+
type: 'number',
|
|
48
|
+
description: 'Viewport width in pixels (default: 1280)',
|
|
49
|
+
default: 1280,
|
|
50
|
+
})
|
|
51
|
+
.option('height', {
|
|
52
|
+
type: 'number',
|
|
53
|
+
description: 'Viewport height in pixels (default: 800)',
|
|
54
|
+
default: 800,
|
|
55
|
+
})
|
|
56
|
+
.option('quality', {
|
|
57
|
+
type: 'number',
|
|
58
|
+
description: 'JPEG quality 0-100 (default: 80, only for jpeg format)',
|
|
59
|
+
default: 80,
|
|
60
|
+
})
|
|
61
|
+
.option('fullPage', {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
description: 'Capture full scrollable page (default: false)',
|
|
64
|
+
default: false,
|
|
65
|
+
})
|
|
66
|
+
.option('localImages', {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description: 'Download images locally in archive mode (default: true)',
|
|
69
|
+
default: true,
|
|
70
|
+
})
|
|
71
|
+
.option('documentFormat', {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description:
|
|
74
|
+
'Document format in archive mode: markdown (default) or html',
|
|
75
|
+
default: 'markdown',
|
|
37
76
|
})
|
|
38
77
|
.option('output', {
|
|
39
78
|
alias: 'o',
|
|
@@ -51,6 +90,103 @@ const config = makeConfig({
|
|
|
51
90
|
type: 'string',
|
|
52
91
|
description: 'Path to .lenv configuration file',
|
|
53
92
|
})
|
|
93
|
+
.option('extractLatex', {
|
|
94
|
+
type: 'boolean',
|
|
95
|
+
description:
|
|
96
|
+
'Extract LaTeX formulas from img.formula, KaTeX, MathJax (default: true). Use --no-extract-latex to disable.',
|
|
97
|
+
default: getenv('WEB_CAPTURE_EXTRACT_LATEX', true),
|
|
98
|
+
})
|
|
99
|
+
.option('extractMetadata', {
|
|
100
|
+
type: 'boolean',
|
|
101
|
+
description:
|
|
102
|
+
'Extract article metadata (author, date, hubs, tags) (default: true). Use --no-extract-metadata to disable.',
|
|
103
|
+
default: getenv('WEB_CAPTURE_EXTRACT_METADATA', true),
|
|
104
|
+
})
|
|
105
|
+
.option('postProcess', {
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
description:
|
|
108
|
+
'Apply post-processing (unicode normalization, LaTeX spacing) (default: true). Use --no-post-process to disable.',
|
|
109
|
+
default: getenv('WEB_CAPTURE_POST_PROCESS', true),
|
|
110
|
+
})
|
|
111
|
+
.option('detectCodeLanguage', {
|
|
112
|
+
type: 'boolean',
|
|
113
|
+
description:
|
|
114
|
+
'Detect and correct code block languages (default: true). Use --no-detect-code-language to disable.',
|
|
115
|
+
default: getenv('WEB_CAPTURE_DETECT_CODE_LANGUAGE', true),
|
|
116
|
+
})
|
|
117
|
+
.option('embedImages', {
|
|
118
|
+
type: 'boolean',
|
|
119
|
+
description:
|
|
120
|
+
'Keep images as inline base64 data URIs instead of extracting to files (default: false). Use --embed-images to enable.',
|
|
121
|
+
default: getenv('WEB_CAPTURE_EMBED_IMAGES', false),
|
|
122
|
+
})
|
|
123
|
+
.option('imagesDir', {
|
|
124
|
+
type: 'string',
|
|
125
|
+
description:
|
|
126
|
+
'Directory name for extracted images, relative to output file (default: images)',
|
|
127
|
+
default: getenv('WEB_CAPTURE_IMAGES_DIR', 'images'),
|
|
128
|
+
})
|
|
129
|
+
.option('dataDir', {
|
|
130
|
+
type: 'string',
|
|
131
|
+
description:
|
|
132
|
+
'Base directory for auto-derived output paths when -o is omitted ' +
|
|
133
|
+
'(default: ./data/web-capture)',
|
|
134
|
+
default: getenv('WEB_CAPTURE_DATA_DIR', './data/web-capture'),
|
|
135
|
+
})
|
|
136
|
+
.option('archive', {
|
|
137
|
+
type: 'string',
|
|
138
|
+
description:
|
|
139
|
+
'Create archive output. Formats: zip (default), 7z, tar.gz (alias gz), tar. Use without value for zip.',
|
|
140
|
+
})
|
|
141
|
+
.option('noExtractImages', {
|
|
142
|
+
type: 'boolean',
|
|
143
|
+
description: 'Alias for --embed-images: keep images inline as base64',
|
|
144
|
+
default: false,
|
|
145
|
+
})
|
|
146
|
+
.option('keepOriginalLinks', {
|
|
147
|
+
type: 'boolean',
|
|
148
|
+
description:
|
|
149
|
+
'Keep original remote image URLs instead of downloading or extracting. ' +
|
|
150
|
+
'Base64 data URIs are stripped (no original URL to restore).',
|
|
151
|
+
default: getenv('WEB_CAPTURE_KEEP_ORIGINAL_LINKS', false),
|
|
152
|
+
})
|
|
153
|
+
.option('dualTheme', {
|
|
154
|
+
type: 'boolean',
|
|
155
|
+
description:
|
|
156
|
+
'Capture both light and dark theme screenshots (default: false)',
|
|
157
|
+
default: false,
|
|
158
|
+
})
|
|
159
|
+
.option('configFile', {
|
|
160
|
+
type: 'string',
|
|
161
|
+
description: 'Path to batch configuration file (JSON or MJS)',
|
|
162
|
+
})
|
|
163
|
+
.option('all', {
|
|
164
|
+
type: 'boolean',
|
|
165
|
+
description: 'Process all articles in batch configuration',
|
|
166
|
+
default: false,
|
|
167
|
+
})
|
|
168
|
+
.option('dryRun', {
|
|
169
|
+
type: 'boolean',
|
|
170
|
+
description: 'Show what would be done without making changes',
|
|
171
|
+
default: false,
|
|
172
|
+
})
|
|
173
|
+
.option('verbose', {
|
|
174
|
+
type: 'boolean',
|
|
175
|
+
description: 'Show detailed output',
|
|
176
|
+
default: false,
|
|
177
|
+
})
|
|
178
|
+
.option('apiToken', {
|
|
179
|
+
type: 'string',
|
|
180
|
+
description:
|
|
181
|
+
'API token for authenticated capture (e.g., Google Docs private documents). Can also be set via API_TOKEN env variable.',
|
|
182
|
+
default: getenv('API_TOKEN', undefined),
|
|
183
|
+
})
|
|
184
|
+
.option('noPrettyHtml', {
|
|
185
|
+
type: 'boolean',
|
|
186
|
+
description:
|
|
187
|
+
'Disable HTML pretty-printing in archive output (output minified HTML instead of indented).',
|
|
188
|
+
default: getenv('WEB_CAPTURE_NO_PRETTY_HTML', false),
|
|
189
|
+
})
|
|
54
190
|
.help('help')
|
|
55
191
|
.alias('help', 'h')
|
|
56
192
|
.version()
|
|
@@ -68,12 +204,26 @@ const config = makeConfig({
|
|
|
68
204
|
'web-capture https://example.com --format markdown --output page.md',
|
|
69
205
|
'Capture URL as Markdown to file'
|
|
70
206
|
)
|
|
207
|
+
.option('capture', {
|
|
208
|
+
type: 'string',
|
|
209
|
+
description:
|
|
210
|
+
'Capture method: browser (default, uses Puppeteer/Playwright) or api (direct HTTP fetch, for Google Docs etc.)',
|
|
211
|
+
default: 'browser',
|
|
212
|
+
})
|
|
71
213
|
.example(
|
|
72
214
|
'web-capture https://example.com --format png --engine playwright -o screenshot.png',
|
|
73
215
|
'Capture screenshot using Playwright'
|
|
74
216
|
)
|
|
217
|
+
.example(
|
|
218
|
+
'web-capture https://docs.google.com/document/d/DOC_ID/edit --format markdown',
|
|
219
|
+
'Capture Google Doc as Markdown (API-based, public doc)'
|
|
220
|
+
)
|
|
221
|
+
.example(
|
|
222
|
+
'web-capture https://docs.google.com/document/d/DOC_ID/edit --format markdown --apiToken YOUR_TOKEN',
|
|
223
|
+
'Capture private Google Doc with API token'
|
|
224
|
+
)
|
|
75
225
|
.epilogue(
|
|
76
|
-
'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE>
|
|
226
|
+
'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy\n GET /gdocs?url=<URL>&format=markdown|html|txt Google Docs capture'
|
|
77
227
|
)
|
|
78
228
|
.strict(),
|
|
79
229
|
lenv: {
|
|
@@ -93,9 +243,15 @@ async function startServer(port) {
|
|
|
93
243
|
console.log('Available endpoints:');
|
|
94
244
|
console.log(` GET /html?url=<URL> - Render page as HTML`);
|
|
95
245
|
console.log(` GET /markdown?url=<URL> - Convert page to Markdown`);
|
|
96
|
-
console.log(` GET /image?url=<URL> - Screenshot
|
|
246
|
+
console.log(` GET /image?url=<URL> - Screenshot (PNG/JPEG)`);
|
|
247
|
+
console.log(
|
|
248
|
+
` GET /archive?url=<URL> - ZIP archive with markdown + images`
|
|
249
|
+
);
|
|
250
|
+
console.log(` GET /pdf?url=<URL> - PDF with embedded images`);
|
|
251
|
+
console.log(` GET /docx?url=<URL> - DOCX with embedded images`);
|
|
97
252
|
console.log(` GET /fetch?url=<URL> - Proxy fetch content`);
|
|
98
253
|
console.log(` GET /stream?url=<URL> - Stream content`);
|
|
254
|
+
console.log(` GET /gdocs?url=<URL> - Capture Google Docs document`);
|
|
99
255
|
console.log('');
|
|
100
256
|
console.log('Press Ctrl+C to stop the server');
|
|
101
257
|
resolve(server);
|
|
@@ -122,8 +278,35 @@ async function startServer(port) {
|
|
|
122
278
|
});
|
|
123
279
|
}
|
|
124
280
|
|
|
281
|
+
function deriveOutputPath(absoluteUrl, ext, dataDir) {
|
|
282
|
+
const parsed = new URL(absoluteUrl);
|
|
283
|
+
const host = parsed.hostname;
|
|
284
|
+
let urlPath = parsed.pathname.replace(/\/+$/, '') || '';
|
|
285
|
+
urlPath = urlPath.replace(/^\//, '');
|
|
286
|
+
const dir = path.join(dataDir, host, urlPath);
|
|
287
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
288
|
+
return path.join(dir, `document.${ext}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
125
291
|
async function captureUrl(url, options) {
|
|
126
|
-
const {
|
|
292
|
+
const {
|
|
293
|
+
format,
|
|
294
|
+
output: explicitOutput,
|
|
295
|
+
engine,
|
|
296
|
+
theme,
|
|
297
|
+
width,
|
|
298
|
+
height,
|
|
299
|
+
quality,
|
|
300
|
+
fullPage,
|
|
301
|
+
// localImages is used by archive format
|
|
302
|
+
// eslint-disable-next-line no-unused-vars
|
|
303
|
+
localImages,
|
|
304
|
+
embedImages,
|
|
305
|
+
// eslint-disable-next-line no-unused-vars
|
|
306
|
+
imagesDir,
|
|
307
|
+
dataDir,
|
|
308
|
+
keepOriginalLinks,
|
|
309
|
+
} = options;
|
|
127
310
|
|
|
128
311
|
// Ensure URL is absolute
|
|
129
312
|
let absoluteUrl = url;
|
|
@@ -140,23 +323,401 @@ async function captureUrl(url, options) {
|
|
|
140
323
|
}
|
|
141
324
|
|
|
142
325
|
// Import required modules
|
|
143
|
-
const {
|
|
144
|
-
|
|
145
|
-
convertHtmlToMarkdown,
|
|
146
|
-
convertToUtf8,
|
|
147
|
-
convertRelativeUrls,
|
|
148
|
-
} = await import('../src/lib.js');
|
|
326
|
+
const { fetchHtml, convertToUtf8, convertRelativeUrls } =
|
|
327
|
+
await import('../src/lib.js');
|
|
149
328
|
const { createBrowser } = await import('../src/browser.js');
|
|
329
|
+
const { isGoogleDocsUrl, fetchGoogleDoc, fetchGoogleDocAsMarkdown } =
|
|
330
|
+
await import('../src/gdocs.js');
|
|
331
|
+
const { stripBase64Images } = await import('../src/extract-images.js');
|
|
150
332
|
|
|
151
333
|
const normalizedFormat = format.toLowerCase();
|
|
152
334
|
|
|
335
|
+
// Auto-detect Google Docs URLs and use API-based capture
|
|
336
|
+
if (isGoogleDocsUrl(absoluteUrl)) {
|
|
337
|
+
try {
|
|
338
|
+
const apiToken = options.apiToken;
|
|
339
|
+
const { extractAndSaveImages } = await import('../src/extract-images.js');
|
|
340
|
+
if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
|
|
341
|
+
const { fetchGoogleDocAsArchive } = await import('../src/gdocs.js');
|
|
342
|
+
const { default: archiver } = await import('archiver');
|
|
343
|
+
const archiveFormat = options.archiveFormat || 'zip';
|
|
344
|
+
const ext =
|
|
345
|
+
archiveFormat === 'tar.gz' || archiveFormat === 'gz'
|
|
346
|
+
? 'tar.gz'
|
|
347
|
+
: archiveFormat === '7z'
|
|
348
|
+
? '7z'
|
|
349
|
+
: archiveFormat === 'tar'
|
|
350
|
+
? 'tar'
|
|
351
|
+
: 'zip';
|
|
352
|
+
const archiveResult = await fetchGoogleDocAsArchive(absoluteUrl, {
|
|
353
|
+
apiToken,
|
|
354
|
+
});
|
|
355
|
+
const output =
|
|
356
|
+
explicitOutput === '-'
|
|
357
|
+
? null
|
|
358
|
+
: explicitOutput || deriveOutputPath(absoluteUrl, ext, dataDir);
|
|
359
|
+
if (output) {
|
|
360
|
+
const outStream = fs.createWriteStream(output);
|
|
361
|
+
const archive = archiver.default
|
|
362
|
+
? archiver.default('zip', { zlib: { level: 9 } })
|
|
363
|
+
: archiver('zip', { zlib: { level: 9 } });
|
|
364
|
+
archive.pipe(outStream);
|
|
365
|
+
const { prettyPrintHtml: ppHtml } = await import('../src/lib.js');
|
|
366
|
+
archive.append(archiveResult.markdown, { name: 'document.md' });
|
|
367
|
+
const htmlContent = options.prettyHtml
|
|
368
|
+
? ppHtml(archiveResult.html)
|
|
369
|
+
: archiveResult.html;
|
|
370
|
+
archive.append(htmlContent, {
|
|
371
|
+
name: 'document.html',
|
|
372
|
+
});
|
|
373
|
+
for (const img of archiveResult.images) {
|
|
374
|
+
archive.append(img.data, { name: `images/${img.filename}` });
|
|
375
|
+
}
|
|
376
|
+
await archive.finalize();
|
|
377
|
+
await new Promise((resolve) => outStream.on('close', resolve));
|
|
378
|
+
console.error(`Google Doc (archive) saved to: ${output}`);
|
|
379
|
+
} else {
|
|
380
|
+
const { PassThrough } = await import('stream');
|
|
381
|
+
const passthrough = new PassThrough();
|
|
382
|
+
const archiverMod = await import('archiver');
|
|
383
|
+
const archiverFn =
|
|
384
|
+
archiverMod.default?.default || archiverMod.default;
|
|
385
|
+
const archive = archiverFn('zip', { zlib: { level: 9 } });
|
|
386
|
+
archive.pipe(passthrough);
|
|
387
|
+
passthrough.pipe(process.stdout);
|
|
388
|
+
const { prettyPrintHtml: ppHtml2 } = await import('../src/lib.js');
|
|
389
|
+
archive.append(archiveResult.markdown, { name: 'document.md' });
|
|
390
|
+
const htmlContent2 = options.prettyHtml
|
|
391
|
+
? ppHtml2(archiveResult.html)
|
|
392
|
+
: archiveResult.html;
|
|
393
|
+
archive.append(htmlContent2, {
|
|
394
|
+
name: 'document.html',
|
|
395
|
+
});
|
|
396
|
+
for (const img of archiveResult.images) {
|
|
397
|
+
archive.append(img.data, { name: `images/${img.filename}` });
|
|
398
|
+
}
|
|
399
|
+
await archive.finalize();
|
|
400
|
+
}
|
|
401
|
+
} else if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
|
|
402
|
+
const result = await fetchGoogleDocAsMarkdown(absoluteUrl, {
|
|
403
|
+
apiToken,
|
|
404
|
+
});
|
|
405
|
+
let { markdown } = result;
|
|
406
|
+
const output =
|
|
407
|
+
explicitOutput === '-'
|
|
408
|
+
? null
|
|
409
|
+
: explicitOutput || deriveOutputPath(absoluteUrl, 'md', dataDir);
|
|
410
|
+
if (output) {
|
|
411
|
+
if (embedImages) {
|
|
412
|
+
// Keep base64 data URIs inline
|
|
413
|
+
} else if (keepOriginalLinks) {
|
|
414
|
+
const strip = stripBase64Images(markdown);
|
|
415
|
+
if (strip.stripped > 0) {
|
|
416
|
+
markdown = strip.markdown;
|
|
417
|
+
console.error(
|
|
418
|
+
`Stripped ${strip.stripped} base64 images (keeping original links)`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
const outputDir = path.dirname(output);
|
|
423
|
+
const extracted = extractAndSaveImages(markdown, outputDir, {
|
|
424
|
+
imagesDir: options.imagesDir || 'images',
|
|
425
|
+
});
|
|
426
|
+
if (extracted.extracted > 0) {
|
|
427
|
+
markdown = extracted.markdown;
|
|
428
|
+
console.error(
|
|
429
|
+
`Extracted ${extracted.extracted} images to ${options.imagesDir || 'images'}/`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
434
|
+
fs.writeFileSync(output, markdown, 'utf-8');
|
|
435
|
+
console.error(`Google Doc Markdown saved to: ${output}`);
|
|
436
|
+
} else {
|
|
437
|
+
process.stdout.write(markdown);
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
const gdocsFormat =
|
|
441
|
+
normalizedFormat === 'png' || normalizedFormat === 'image'
|
|
442
|
+
? 'html'
|
|
443
|
+
: normalizedFormat;
|
|
444
|
+
const result = await fetchGoogleDoc(absoluteUrl, {
|
|
445
|
+
format: gdocsFormat,
|
|
446
|
+
apiToken,
|
|
447
|
+
});
|
|
448
|
+
const output =
|
|
449
|
+
explicitOutput === '-'
|
|
450
|
+
? null
|
|
451
|
+
: explicitOutput ||
|
|
452
|
+
deriveOutputPath(absoluteUrl, gdocsFormat, dataDir);
|
|
453
|
+
if (output) {
|
|
454
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
455
|
+
fs.writeFileSync(output, result.content, 'utf-8');
|
|
456
|
+
console.error(`Google Doc (${gdocsFormat}) saved to: ${output}`);
|
|
457
|
+
} else {
|
|
458
|
+
process.stdout.write(result.content);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error(`Error capturing Google Doc: ${err.message}`);
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
153
468
|
try {
|
|
154
|
-
if (normalizedFormat === '
|
|
155
|
-
//
|
|
469
|
+
if (normalizedFormat === 'jpeg') {
|
|
470
|
+
// JPEG screenshot
|
|
471
|
+
const { createBrowser } = await import('../src/browser.js');
|
|
472
|
+
const { dismissPopups, scrollToLoadContent } =
|
|
473
|
+
await import('../src/popups.js');
|
|
474
|
+
const browserOpts = theme ? { colorScheme: theme } : {};
|
|
475
|
+
const browser = await createBrowser(engine, browserOpts);
|
|
476
|
+
try {
|
|
477
|
+
const page = await browser.newPage();
|
|
478
|
+
await page.setViewport({ width, height });
|
|
479
|
+
await page.goto(absoluteUrl, {
|
|
480
|
+
waitUntil: 'networkidle0',
|
|
481
|
+
timeout: 30000,
|
|
482
|
+
});
|
|
483
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
484
|
+
if (fullPage) {
|
|
485
|
+
await scrollToLoadContent(page);
|
|
486
|
+
}
|
|
487
|
+
await dismissPopups(page);
|
|
488
|
+
const buffer = await page.screenshot({
|
|
489
|
+
type: 'jpeg',
|
|
490
|
+
quality,
|
|
491
|
+
fullPage,
|
|
492
|
+
});
|
|
493
|
+
const outPath =
|
|
494
|
+
explicitOutput ||
|
|
495
|
+
`${new URL(absoluteUrl).hostname.replace(/\./g, '_')}_${Date.now()}.jpg`;
|
|
496
|
+
fs.writeFileSync(outPath, buffer);
|
|
497
|
+
console.error(`JPEG screenshot saved to: ${outPath}`);
|
|
498
|
+
} finally {
|
|
499
|
+
await browser.close();
|
|
500
|
+
}
|
|
501
|
+
} else if (normalizedFormat === 'pdf') {
|
|
502
|
+
// PDF export
|
|
503
|
+
const { createBrowser } = await import('../src/browser.js');
|
|
504
|
+
const { dismissPopups, scrollToLoadContent } =
|
|
505
|
+
await import('../src/popups.js');
|
|
506
|
+
const browserOpts = theme ? { colorScheme: theme } : {};
|
|
507
|
+
const browser = await createBrowser(engine, browserOpts);
|
|
508
|
+
try {
|
|
509
|
+
const page = await browser.newPage();
|
|
510
|
+
await page.setViewport({ width, height });
|
|
511
|
+
await page.goto(absoluteUrl, {
|
|
512
|
+
waitUntil: 'networkidle0',
|
|
513
|
+
timeout: 30000,
|
|
514
|
+
});
|
|
515
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
516
|
+
await scrollToLoadContent(page);
|
|
517
|
+
await dismissPopups(page);
|
|
518
|
+
const rawPage = page.rawPage || page;
|
|
519
|
+
const pdfBuffer = await rawPage.pdf({
|
|
520
|
+
format: 'A4',
|
|
521
|
+
printBackground: true,
|
|
522
|
+
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
|
|
523
|
+
});
|
|
524
|
+
const outPath = explicitOutput || 'page.pdf';
|
|
525
|
+
fs.writeFileSync(outPath, pdfBuffer);
|
|
526
|
+
console.error(`PDF saved to: ${outPath}`);
|
|
527
|
+
} finally {
|
|
528
|
+
await browser.close();
|
|
529
|
+
}
|
|
530
|
+
} else if (normalizedFormat === 'docx') {
|
|
531
|
+
// DOCX export – delegate to the docx handler logic
|
|
532
|
+
const { fetchHtml, convertRelativeUrls } = await import('../src/lib.js');
|
|
533
|
+
const cheerio = await import('cheerio');
|
|
534
|
+
// eslint-disable-next-line no-unused-vars
|
|
535
|
+
const { Document, Packer, Paragraph, TextRun, HeadingLevel } =
|
|
536
|
+
await import('docx');
|
|
537
|
+
const html = await fetchHtml(absoluteUrl);
|
|
538
|
+
const absHtml = convertRelativeUrls(html, absoluteUrl);
|
|
539
|
+
const $ = cheerio.load(absHtml);
|
|
540
|
+
$('style, script, noscript, nav, footer, header').remove();
|
|
541
|
+
const children = [];
|
|
542
|
+
const titleText =
|
|
543
|
+
$('h1').first().text().trim() || $('title').text().trim();
|
|
544
|
+
if (titleText) {
|
|
545
|
+
children.push(
|
|
546
|
+
new Paragraph({ text: titleText, heading: HeadingLevel.TITLE })
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
const body = $('article').length ? $('article') : $('body');
|
|
550
|
+
for (const el of body.children().toArray()) {
|
|
551
|
+
const text = $(el).text().trim();
|
|
552
|
+
if (text) {
|
|
553
|
+
children.push(new Paragraph({ text }));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (children.length === 0) {
|
|
557
|
+
children.push(new Paragraph({ text: 'No content extracted.' }));
|
|
558
|
+
}
|
|
559
|
+
const doc = new Document({ sections: [{ children }] });
|
|
560
|
+
const buffer = await Packer.toBuffer(doc);
|
|
561
|
+
const outPath = explicitOutput || 'page.docx';
|
|
562
|
+
fs.writeFileSync(outPath, buffer);
|
|
563
|
+
console.error(`DOCX saved to: ${outPath}`);
|
|
564
|
+
} else if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
|
|
565
|
+
// ZIP archive
|
|
566
|
+
const { default: archiver } = await import('archiver');
|
|
567
|
+
const { fetchHtml, convertHtmlToMarkdown, convertRelativeUrls } =
|
|
568
|
+
await import('../src/lib.js');
|
|
569
|
+
const { retry } = await import('../src/retry.js');
|
|
570
|
+
const cheerio = await import('cheerio');
|
|
571
|
+
const nodeFetch = await import('node-fetch');
|
|
572
|
+
|
|
573
|
+
const html = await retry(() => fetchHtml(absoluteUrl), {
|
|
574
|
+
retries: 3,
|
|
575
|
+
baseDelay: 1000,
|
|
576
|
+
onRetry: (err, attempt) => {
|
|
577
|
+
console.error(`Retry ${attempt} fetching page: ${err.message}`);
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const docFormat = options.documentFormat === 'html' ? 'html' : 'markdown';
|
|
582
|
+
const outPath =
|
|
583
|
+
explicitOutput ||
|
|
584
|
+
`${new URL(absoluteUrl).hostname.replace(/\./g, '-')}-archive.zip`;
|
|
585
|
+
const outStream = fs.createWriteStream(outPath);
|
|
586
|
+
const archive = archiver.default
|
|
587
|
+
? archiver.default('zip', { zlib: { level: 9 } })
|
|
588
|
+
: archiver('zip', { zlib: { level: 9 } });
|
|
589
|
+
archive.pipe(outStream);
|
|
590
|
+
|
|
591
|
+
// Collect images
|
|
592
|
+
const $ = cheerio.load(html);
|
|
593
|
+
const images = [];
|
|
594
|
+
$('img').each(function () {
|
|
595
|
+
const src = $(this).attr('src');
|
|
596
|
+
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
|
|
597
|
+
try {
|
|
598
|
+
images.push(new URL(src, absoluteUrl).href);
|
|
599
|
+
} catch {
|
|
600
|
+
/* skip */
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
const uniqueImages = [...new Set(images)];
|
|
605
|
+
|
|
606
|
+
// Build image map for local downloads
|
|
607
|
+
const imageMap = new Map();
|
|
608
|
+
if (
|
|
609
|
+
options.localImages &&
|
|
610
|
+
!keepOriginalLinks &&
|
|
611
|
+
uniqueImages.length > 0
|
|
612
|
+
) {
|
|
613
|
+
let idx = 1;
|
|
614
|
+
for (const imgUrl of uniqueImages) {
|
|
615
|
+
const ext = imgUrl.match(/\.(jpe?g|gif|webp|svg|png)/i)?.[1] || 'png';
|
|
616
|
+
imageMap.set(imgUrl, `images/image-${idx}.${ext}`);
|
|
617
|
+
idx++;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (docFormat === 'html') {
|
|
622
|
+
const outputHtml = convertRelativeUrls(html, absoluteUrl);
|
|
623
|
+
const $out = cheerio.load(outputHtml);
|
|
624
|
+
$out('script, noscript').remove();
|
|
625
|
+
if (imageMap.size > 0) {
|
|
626
|
+
$out('img').each(function () {
|
|
627
|
+
const src = $out(this).attr('src');
|
|
628
|
+
if (src) {
|
|
629
|
+
try {
|
|
630
|
+
const resolved = new URL(src, absoluteUrl).href;
|
|
631
|
+
if (imageMap.has(resolved)) {
|
|
632
|
+
$out(this).attr('src', imageMap.get(resolved));
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
/* skip */
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
const { prettyPrintHtml } = await import('../src/lib.js');
|
|
641
|
+
const formattedHtml = options.prettyHtml
|
|
642
|
+
? prettyPrintHtml($out.html())
|
|
643
|
+
: $out.html();
|
|
644
|
+
archive.append(formattedHtml, { name: 'document.html' });
|
|
645
|
+
} else {
|
|
646
|
+
let markdown = convertHtmlToMarkdown(html, absoluteUrl);
|
|
647
|
+
if (imageMap.size > 0) {
|
|
648
|
+
for (const [remoteUrl, localPath] of imageMap) {
|
|
649
|
+
markdown = markdown.split(remoteUrl).join(localPath);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
archive.append(markdown, { name: 'document.md' });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Download images
|
|
656
|
+
if (imageMap.size > 0) {
|
|
657
|
+
for (const [imgUrl, localPath] of imageMap) {
|
|
658
|
+
try {
|
|
659
|
+
const fetchFn = nodeFetch.default || nodeFetch;
|
|
660
|
+
const resp = await retry(() => fetchFn(imgUrl), {
|
|
661
|
+
retries: 2,
|
|
662
|
+
baseDelay: 500,
|
|
663
|
+
});
|
|
664
|
+
if (resp.ok) {
|
|
665
|
+
const buffer = await resp.buffer();
|
|
666
|
+
archive.append(buffer, { name: localPath });
|
|
667
|
+
}
|
|
668
|
+
} catch {
|
|
669
|
+
/* skip failed image downloads */
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
await archive.finalize();
|
|
675
|
+
await new Promise((resolve) => outStream.on('close', resolve));
|
|
676
|
+
console.error(`Archive saved to: ${outPath}`);
|
|
677
|
+
} else if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
|
|
678
|
+
// Markdown format — enhanced conversion is now the default
|
|
156
679
|
const html = await fetchHtml(absoluteUrl);
|
|
157
|
-
const
|
|
680
|
+
const { convertHtmlToMarkdownEnhanced } = await import('../src/lib.js');
|
|
681
|
+
const { extractAndSaveImages } = await import('../src/extract-images.js');
|
|
682
|
+
const result = convertHtmlToMarkdownEnhanced(html, absoluteUrl, {
|
|
683
|
+
extractLatex: options.extractLatex,
|
|
684
|
+
extractMetadata: options.extractMetadata,
|
|
685
|
+
postProcess: options.postProcess,
|
|
686
|
+
detectCodeLanguage: options.detectCodeLanguage,
|
|
687
|
+
});
|
|
688
|
+
let markdown = result.markdown;
|
|
689
|
+
|
|
690
|
+
const output =
|
|
691
|
+
explicitOutput === '-'
|
|
692
|
+
? null
|
|
693
|
+
: explicitOutput || deriveOutputPath(absoluteUrl, 'md', dataDir);
|
|
694
|
+
if (output) {
|
|
695
|
+
if (embedImages) {
|
|
696
|
+
// Keep base64 data URIs inline
|
|
697
|
+
} else if (keepOriginalLinks) {
|
|
698
|
+
const strip = stripBase64Images(markdown);
|
|
699
|
+
if (strip.stripped > 0) {
|
|
700
|
+
markdown = strip.markdown;
|
|
701
|
+
console.error(
|
|
702
|
+
`Stripped ${strip.stripped} base64 images (keeping original links)`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
} else {
|
|
706
|
+
const outputDir = path.dirname(output);
|
|
707
|
+
const extracted = extractAndSaveImages(markdown, outputDir, {
|
|
708
|
+
imagesDir: options.imagesDir || 'images',
|
|
709
|
+
});
|
|
710
|
+
if (extracted.extracted > 0) {
|
|
711
|
+
markdown = extracted.markdown;
|
|
712
|
+
console.error(
|
|
713
|
+
`Extracted ${extracted.extracted} images to ${options.imagesDir || 'images'}/`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
158
718
|
|
|
159
719
|
if (output) {
|
|
720
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
160
721
|
fs.writeFileSync(output, markdown, 'utf-8');
|
|
161
722
|
console.error(`Markdown saved to: ${output}`);
|
|
162
723
|
} else {
|
|
@@ -188,9 +749,9 @@ async function captureUrl(url, options) {
|
|
|
188
749
|
|
|
189
750
|
const buffer = await page.screenshot({ type: 'png' });
|
|
190
751
|
|
|
191
|
-
if (
|
|
192
|
-
fs.writeFileSync(
|
|
193
|
-
console.error(`Screenshot saved to: ${
|
|
752
|
+
if (explicitOutput) {
|
|
753
|
+
fs.writeFileSync(explicitOutput, buffer);
|
|
754
|
+
console.error(`Screenshot saved to: ${explicitOutput}`);
|
|
194
755
|
} else {
|
|
195
756
|
// Generate default filename based on URL
|
|
196
757
|
const urlObj = new URL(absoluteUrl);
|
|
@@ -244,7 +805,12 @@ async function captureUrl(url, options) {
|
|
|
244
805
|
resultHtml = convertRelativeUrls(utf8Html, absoluteUrl);
|
|
245
806
|
}
|
|
246
807
|
|
|
808
|
+
const output =
|
|
809
|
+
explicitOutput === '-'
|
|
810
|
+
? null
|
|
811
|
+
: explicitOutput || deriveOutputPath(absoluteUrl, 'html', dataDir);
|
|
247
812
|
if (output) {
|
|
813
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
248
814
|
fs.writeFileSync(output, resultHtml, 'utf-8');
|
|
249
815
|
console.error(`HTML saved to: ${output}`);
|
|
250
816
|
} else {
|
|
@@ -265,11 +831,50 @@ async function main() {
|
|
|
265
831
|
// Server mode
|
|
266
832
|
await startServer(config.port);
|
|
267
833
|
} else if (url) {
|
|
834
|
+
// --no-extract-images is an alias for --embed-images
|
|
835
|
+
if (config.noExtractImages) {
|
|
836
|
+
config.embedImages = true;
|
|
837
|
+
}
|
|
838
|
+
// --archive flag overrides format
|
|
839
|
+
if (config.archive !== undefined) {
|
|
840
|
+
const archiveFormat =
|
|
841
|
+
config.archive === true || config.archive === ''
|
|
842
|
+
? 'zip'
|
|
843
|
+
: config.archive;
|
|
844
|
+
const validFormats = ['zip', '7z', 'tar.gz', 'gz', 'tar'];
|
|
845
|
+
if (!validFormats.includes(archiveFormat)) {
|
|
846
|
+
console.error(
|
|
847
|
+
`Error: Unsupported archive format "${archiveFormat}". Supported: ${validFormats.join(', ')}`
|
|
848
|
+
);
|
|
849
|
+
process.exit(1);
|
|
850
|
+
}
|
|
851
|
+
config.archiveFormat = archiveFormat === 'gz' ? 'tar.gz' : archiveFormat;
|
|
852
|
+
config.format = 'archive';
|
|
853
|
+
}
|
|
268
854
|
// Capture mode
|
|
269
855
|
await captureUrl(url, {
|
|
270
856
|
format: config.format,
|
|
271
857
|
output: config.output,
|
|
272
858
|
engine: config.engine,
|
|
859
|
+
theme: config.theme,
|
|
860
|
+
width: config.width,
|
|
861
|
+
height: config.height,
|
|
862
|
+
quality: config.quality,
|
|
863
|
+
fullPage: config.fullPage,
|
|
864
|
+
localImages: config.localImages,
|
|
865
|
+
documentFormat: config.documentFormat,
|
|
866
|
+
extractLatex: config.extractLatex,
|
|
867
|
+
extractMetadata: config.extractMetadata,
|
|
868
|
+
postProcess: config.postProcess,
|
|
869
|
+
detectCodeLanguage: config.detectCodeLanguage,
|
|
870
|
+
dualTheme: config.dualTheme,
|
|
871
|
+
apiToken: config.apiToken,
|
|
872
|
+
embedImages: config.embedImages,
|
|
873
|
+
imagesDir: config.imagesDir,
|
|
874
|
+
dataDir: config.dataDir,
|
|
875
|
+
archiveFormat: config.archiveFormat,
|
|
876
|
+
keepOriginalLinks: config.keepOriginalLinks,
|
|
877
|
+
prettyHtml: !config.noPrettyHtml,
|
|
273
878
|
});
|
|
274
879
|
} else {
|
|
275
880
|
// No arguments - show error
|