@link-assistant/web-capture 1.4.2 → 1.7.2
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 +47 -0
- package/Dockerfile +26 -21
- package/README.md +92 -72
- package/bin/web-capture.js +259 -51
- package/package.json +5 -1
- package/src/archive.js +19 -6
- package/src/batch.js +3 -3
- package/src/extract-images.js +120 -0
- package/src/gdocs.js +8 -6
- package/src/lib.js +57 -2
- package/src/markdown.js +7 -2
- package/src/postprocess.js +2 -4
- package/tests/unit/batch.test.js +4 -4
- package/tests/unit/extract-images.test.js +233 -0
- package/tests/unit/gdocs.test.js +75 -0
- package/tests/unit/heading-image.test.js +49 -0
- package/tests/unit/postprocess.test.js +4 -2
- package/tests/unit/publish-verification.test.js +57 -0
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
|
|
|
@@ -33,8 +34,8 @@ const config = makeConfig({
|
|
|
33
34
|
alias: 'f',
|
|
34
35
|
type: 'string',
|
|
35
36
|
description:
|
|
36
|
-
'Output format:
|
|
37
|
-
default: '
|
|
37
|
+
'Output format: markdown, md, html, image, png, jpeg, pdf, docx, archive',
|
|
38
|
+
default: 'markdown',
|
|
38
39
|
})
|
|
39
40
|
.option('theme', {
|
|
40
41
|
alias: 't',
|
|
@@ -89,35 +90,65 @@ const config = makeConfig({
|
|
|
89
90
|
type: 'string',
|
|
90
91
|
description: 'Path to .lenv configuration file',
|
|
91
92
|
})
|
|
92
|
-
.option('enhanced', {
|
|
93
|
-
type: 'boolean',
|
|
94
|
-
description:
|
|
95
|
-
'Use enhanced markdown conversion with LaTeX extraction, metadata, and post-processing (default: false)',
|
|
96
|
-
default: false,
|
|
97
|
-
})
|
|
98
93
|
.option('extractLatex', {
|
|
99
94
|
type: 'boolean',
|
|
100
95
|
description:
|
|
101
|
-
'Extract LaTeX formulas from img.formula, KaTeX, MathJax (default: true
|
|
102
|
-
default: true,
|
|
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),
|
|
103
98
|
})
|
|
104
99
|
.option('extractMetadata', {
|
|
105
100
|
type: 'boolean',
|
|
106
101
|
description:
|
|
107
|
-
'Extract article metadata (author, date, hubs, tags) (default: true
|
|
108
|
-
default: true,
|
|
102
|
+
'Extract article metadata (author, date, hubs, tags) (default: true). Use --no-extract-metadata to disable.',
|
|
103
|
+
default: getenv('WEB_CAPTURE_EXTRACT_METADATA', true),
|
|
109
104
|
})
|
|
110
105
|
.option('postProcess', {
|
|
111
106
|
type: 'boolean',
|
|
112
107
|
description:
|
|
113
|
-
'Apply post-processing (unicode normalization, LaTeX spacing) (default: true
|
|
114
|
-
default: true,
|
|
108
|
+
'Apply post-processing (unicode normalization, LaTeX spacing) (default: true). Use --no-post-process to disable.',
|
|
109
|
+
default: getenv('WEB_CAPTURE_POST_PROCESS', true),
|
|
115
110
|
})
|
|
116
111
|
.option('detectCodeLanguage', {
|
|
117
112
|
type: 'boolean',
|
|
118
113
|
description:
|
|
119
|
-
'Detect and correct code block languages (default: true
|
|
120
|
-
default: true,
|
|
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),
|
|
121
152
|
})
|
|
122
153
|
.option('dualTheme', {
|
|
123
154
|
type: 'boolean',
|
|
@@ -150,6 +181,12 @@ const config = makeConfig({
|
|
|
150
181
|
'API token for authenticated capture (e.g., Google Docs private documents). Can also be set via API_TOKEN env variable.',
|
|
151
182
|
default: getenv('API_TOKEN', undefined),
|
|
152
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
|
+
})
|
|
153
190
|
.help('help')
|
|
154
191
|
.alias('help', 'h')
|
|
155
192
|
.version()
|
|
@@ -241,10 +278,20 @@ async function startServer(port) {
|
|
|
241
278
|
});
|
|
242
279
|
}
|
|
243
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
|
+
|
|
244
291
|
async function captureUrl(url, options) {
|
|
245
292
|
const {
|
|
246
293
|
format,
|
|
247
|
-
output,
|
|
294
|
+
output: explicitOutput,
|
|
248
295
|
engine,
|
|
249
296
|
theme,
|
|
250
297
|
width,
|
|
@@ -254,6 +301,11 @@ async function captureUrl(url, options) {
|
|
|
254
301
|
// localImages is used by archive format
|
|
255
302
|
// eslint-disable-next-line no-unused-vars
|
|
256
303
|
localImages,
|
|
304
|
+
embedImages,
|
|
305
|
+
// eslint-disable-next-line no-unused-vars
|
|
306
|
+
imagesDir,
|
|
307
|
+
dataDir,
|
|
308
|
+
keepOriginalLinks,
|
|
257
309
|
} = options;
|
|
258
310
|
|
|
259
311
|
// Ensure URL is absolute
|
|
@@ -271,15 +323,12 @@ async function captureUrl(url, options) {
|
|
|
271
323
|
}
|
|
272
324
|
|
|
273
325
|
// Import required modules
|
|
274
|
-
const {
|
|
275
|
-
|
|
276
|
-
convertHtmlToMarkdown,
|
|
277
|
-
convertToUtf8,
|
|
278
|
-
convertRelativeUrls,
|
|
279
|
-
} = await import('../src/lib.js');
|
|
326
|
+
const { fetchHtml, convertToUtf8, convertRelativeUrls } =
|
|
327
|
+
await import('../src/lib.js');
|
|
280
328
|
const { createBrowser } = await import('../src/browser.js');
|
|
281
329
|
const { isGoogleDocsUrl, fetchGoogleDoc, fetchGoogleDocAsMarkdown } =
|
|
282
330
|
await import('../src/gdocs.js');
|
|
331
|
+
const { stripBase64Images } = await import('../src/extract-images.js');
|
|
283
332
|
|
|
284
333
|
const normalizedFormat = format.toLowerCase();
|
|
285
334
|
|
|
@@ -287,15 +336,105 @@ async function captureUrl(url, options) {
|
|
|
287
336
|
if (isGoogleDocsUrl(absoluteUrl)) {
|
|
288
337
|
try {
|
|
289
338
|
const apiToken = options.apiToken;
|
|
290
|
-
|
|
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') {
|
|
291
402
|
const result = await fetchGoogleDocAsMarkdown(absoluteUrl, {
|
|
292
403
|
apiToken,
|
|
293
404
|
});
|
|
405
|
+
let { markdown } = result;
|
|
406
|
+
const output =
|
|
407
|
+
explicitOutput === '-'
|
|
408
|
+
? null
|
|
409
|
+
: explicitOutput || deriveOutputPath(absoluteUrl, 'md', dataDir);
|
|
294
410
|
if (output) {
|
|
295
|
-
|
|
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');
|
|
296
435
|
console.error(`Google Doc Markdown saved to: ${output}`);
|
|
297
436
|
} else {
|
|
298
|
-
process.stdout.write(
|
|
437
|
+
process.stdout.write(markdown);
|
|
299
438
|
}
|
|
300
439
|
} else {
|
|
301
440
|
const gdocsFormat =
|
|
@@ -306,7 +445,13 @@ async function captureUrl(url, options) {
|
|
|
306
445
|
format: gdocsFormat,
|
|
307
446
|
apiToken,
|
|
308
447
|
});
|
|
448
|
+
const output =
|
|
449
|
+
explicitOutput === '-'
|
|
450
|
+
? null
|
|
451
|
+
: explicitOutput ||
|
|
452
|
+
deriveOutputPath(absoluteUrl, gdocsFormat, dataDir);
|
|
309
453
|
if (output) {
|
|
454
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
310
455
|
fs.writeFileSync(output, result.content, 'utf-8');
|
|
311
456
|
console.error(`Google Doc (${gdocsFormat}) saved to: ${output}`);
|
|
312
457
|
} else {
|
|
@@ -346,7 +491,7 @@ async function captureUrl(url, options) {
|
|
|
346
491
|
fullPage,
|
|
347
492
|
});
|
|
348
493
|
const outPath =
|
|
349
|
-
|
|
494
|
+
explicitOutput ||
|
|
350
495
|
`${new URL(absoluteUrl).hostname.replace(/\./g, '_')}_${Date.now()}.jpg`;
|
|
351
496
|
fs.writeFileSync(outPath, buffer);
|
|
352
497
|
console.error(`JPEG screenshot saved to: ${outPath}`);
|
|
@@ -376,7 +521,7 @@ async function captureUrl(url, options) {
|
|
|
376
521
|
printBackground: true,
|
|
377
522
|
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
|
|
378
523
|
});
|
|
379
|
-
const outPath =
|
|
524
|
+
const outPath = explicitOutput || 'page.pdf';
|
|
380
525
|
fs.writeFileSync(outPath, pdfBuffer);
|
|
381
526
|
console.error(`PDF saved to: ${outPath}`);
|
|
382
527
|
} finally {
|
|
@@ -413,7 +558,7 @@ async function captureUrl(url, options) {
|
|
|
413
558
|
}
|
|
414
559
|
const doc = new Document({ sections: [{ children }] });
|
|
415
560
|
const buffer = await Packer.toBuffer(doc);
|
|
416
|
-
const outPath =
|
|
561
|
+
const outPath = explicitOutput || 'page.docx';
|
|
417
562
|
fs.writeFileSync(outPath, buffer);
|
|
418
563
|
console.error(`DOCX saved to: ${outPath}`);
|
|
419
564
|
} else if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
|
|
@@ -435,7 +580,7 @@ async function captureUrl(url, options) {
|
|
|
435
580
|
|
|
436
581
|
const docFormat = options.documentFormat === 'html' ? 'html' : 'markdown';
|
|
437
582
|
const outPath =
|
|
438
|
-
|
|
583
|
+
explicitOutput ||
|
|
439
584
|
`${new URL(absoluteUrl).hostname.replace(/\./g, '-')}-archive.zip`;
|
|
440
585
|
const outStream = fs.createWriteStream(outPath);
|
|
441
586
|
const archive = archiver.default
|
|
@@ -460,7 +605,11 @@ async function captureUrl(url, options) {
|
|
|
460
605
|
|
|
461
606
|
// Build image map for local downloads
|
|
462
607
|
const imageMap = new Map();
|
|
463
|
-
if (
|
|
608
|
+
if (
|
|
609
|
+
options.localImages &&
|
|
610
|
+
!keepOriginalLinks &&
|
|
611
|
+
uniqueImages.length > 0
|
|
612
|
+
) {
|
|
464
613
|
let idx = 1;
|
|
465
614
|
for (const imgUrl of uniqueImages) {
|
|
466
615
|
const ext = imgUrl.match(/\.(jpe?g|gif|webp|svg|png)/i)?.[1] || 'png';
|
|
@@ -488,7 +637,11 @@ async function captureUrl(url, options) {
|
|
|
488
637
|
}
|
|
489
638
|
});
|
|
490
639
|
}
|
|
491
|
-
|
|
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' });
|
|
492
645
|
} else {
|
|
493
646
|
let markdown = convertHtmlToMarkdown(html, absoluteUrl);
|
|
494
647
|
if (imageMap.size > 0) {
|
|
@@ -496,7 +649,7 @@ async function captureUrl(url, options) {
|
|
|
496
649
|
markdown = markdown.split(remoteUrl).join(localPath);
|
|
497
650
|
}
|
|
498
651
|
}
|
|
499
|
-
archive.append(markdown, { name: '
|
|
652
|
+
archive.append(markdown, { name: 'document.md' });
|
|
500
653
|
}
|
|
501
654
|
|
|
502
655
|
// Download images
|
|
@@ -522,24 +675,49 @@ async function captureUrl(url, options) {
|
|
|
522
675
|
await new Promise((resolve) => outStream.on('close', resolve));
|
|
523
676
|
console.error(`Archive saved to: ${outPath}`);
|
|
524
677
|
} else if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
|
|
525
|
-
// Markdown format
|
|
678
|
+
// Markdown format — enhanced conversion is now the default
|
|
526
679
|
const html = await fetchHtml(absoluteUrl);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
}
|
|
540
717
|
}
|
|
541
718
|
|
|
542
719
|
if (output) {
|
|
720
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
543
721
|
fs.writeFileSync(output, markdown, 'utf-8');
|
|
544
722
|
console.error(`Markdown saved to: ${output}`);
|
|
545
723
|
} else {
|
|
@@ -571,9 +749,9 @@ async function captureUrl(url, options) {
|
|
|
571
749
|
|
|
572
750
|
const buffer = await page.screenshot({ type: 'png' });
|
|
573
751
|
|
|
574
|
-
if (
|
|
575
|
-
fs.writeFileSync(
|
|
576
|
-
console.error(`Screenshot saved to: ${
|
|
752
|
+
if (explicitOutput) {
|
|
753
|
+
fs.writeFileSync(explicitOutput, buffer);
|
|
754
|
+
console.error(`Screenshot saved to: ${explicitOutput}`);
|
|
577
755
|
} else {
|
|
578
756
|
// Generate default filename based on URL
|
|
579
757
|
const urlObj = new URL(absoluteUrl);
|
|
@@ -627,7 +805,12 @@ async function captureUrl(url, options) {
|
|
|
627
805
|
resultHtml = convertRelativeUrls(utf8Html, absoluteUrl);
|
|
628
806
|
}
|
|
629
807
|
|
|
808
|
+
const output =
|
|
809
|
+
explicitOutput === '-'
|
|
810
|
+
? null
|
|
811
|
+
: explicitOutput || deriveOutputPath(absoluteUrl, 'html', dataDir);
|
|
630
812
|
if (output) {
|
|
813
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
631
814
|
fs.writeFileSync(output, resultHtml, 'utf-8');
|
|
632
815
|
console.error(`HTML saved to: ${output}`);
|
|
633
816
|
} else {
|
|
@@ -648,6 +831,26 @@ async function main() {
|
|
|
648
831
|
// Server mode
|
|
649
832
|
await startServer(config.port);
|
|
650
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
|
+
}
|
|
651
854
|
// Capture mode
|
|
652
855
|
await captureUrl(url, {
|
|
653
856
|
format: config.format,
|
|
@@ -660,13 +863,18 @@ async function main() {
|
|
|
660
863
|
fullPage: config.fullPage,
|
|
661
864
|
localImages: config.localImages,
|
|
662
865
|
documentFormat: config.documentFormat,
|
|
663
|
-
enhanced: config.enhanced,
|
|
664
866
|
extractLatex: config.extractLatex,
|
|
665
867
|
extractMetadata: config.extractMetadata,
|
|
666
868
|
postProcess: config.postProcess,
|
|
667
869
|
detectCodeLanguage: config.detectCodeLanguage,
|
|
668
870
|
dualTheme: config.dualTheme,
|
|
669
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,
|
|
670
878
|
});
|
|
671
879
|
} else {
|
|
672
880
|
// No arguments - show error
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/web-capture",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -73,6 +73,10 @@
|
|
|
73
73
|
"microservice"
|
|
74
74
|
],
|
|
75
75
|
"license": "Unlicense",
|
|
76
|
+
"repository": {
|
|
77
|
+
"type": "git",
|
|
78
|
+
"url": "https://github.com/link-assistant/web-capture"
|
|
79
|
+
},
|
|
76
80
|
"engines": {
|
|
77
81
|
"node": ">=22.0.0 <23.0.0"
|
|
78
82
|
},
|
package/src/archive.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ZIP archive handler.
|
|
3
3
|
*
|
|
4
4
|
* Downloads a web page as a self-contained ZIP archive containing:
|
|
5
|
-
* -
|
|
5
|
+
* - document.md or document.html (document with either remote or local asset links)
|
|
6
6
|
* - images/ (directory of downloaded images, when localImages=true)
|
|
7
7
|
* - css/ (directory for stylesheets, when documentFormat=html and localImages=true)
|
|
8
8
|
* - js/ (directory for scripts, when documentFormat=html and localImages=true)
|
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
fetchHtml,
|
|
24
24
|
convertHtmlToMarkdown,
|
|
25
25
|
convertRelativeUrls,
|
|
26
|
+
prettyPrintHtml,
|
|
26
27
|
} from './lib.js';
|
|
27
28
|
import { retry } from './retry.js';
|
|
29
|
+
import { extractBase64ToBuffers } from './extract-images.js';
|
|
28
30
|
|
|
29
31
|
export async function archiveHandler(req, res) {
|
|
30
32
|
const url = req.query.url;
|
|
@@ -32,7 +34,11 @@ export async function archiveHandler(req, res) {
|
|
|
32
34
|
return res.status(400).send('Missing `url` parameter');
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
const
|
|
37
|
+
const keepOriginalLinks = req.query.keepOriginalLinks === 'true';
|
|
38
|
+
const localImages = keepOriginalLinks
|
|
39
|
+
? false
|
|
40
|
+
: req.query.localImages !== 'false';
|
|
41
|
+
const embedImages = req.query.embedImages === 'true';
|
|
36
42
|
const documentFormat =
|
|
37
43
|
req.query.documentFormat === 'html' ? 'html' : 'markdown';
|
|
38
44
|
|
|
@@ -135,8 +141,8 @@ export async function archiveHandler(req, res) {
|
|
|
135
141
|
});
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
outputHtml = $out.html();
|
|
139
|
-
archive.append(outputHtml, { name: '
|
|
144
|
+
outputHtml = prettyPrintHtml($out.html());
|
|
145
|
+
archive.append(outputHtml, { name: 'document.html' });
|
|
140
146
|
|
|
141
147
|
// Download and add CSS files
|
|
142
148
|
for (const { url: cssUrl, localPath } of cssFiles) {
|
|
@@ -158,13 +164,20 @@ export async function archiveHandler(req, res) {
|
|
|
158
164
|
let markdown = convertHtmlToMarkdown(html, absoluteUrl);
|
|
159
165
|
|
|
160
166
|
if (localImages && imageMap.size > 0) {
|
|
161
|
-
// Rewrite image URLs in markdown to local paths
|
|
162
167
|
for (const [remoteUrl, localPath] of imageMap) {
|
|
163
168
|
markdown = markdown.split(remoteUrl).join(localPath);
|
|
164
169
|
}
|
|
165
170
|
}
|
|
166
171
|
|
|
167
|
-
|
|
172
|
+
if (!embedImages && !keepOriginalLinks) {
|
|
173
|
+
const b64 = extractBase64ToBuffers(markdown, 'images');
|
|
174
|
+
markdown = b64.markdown;
|
|
175
|
+
for (const img of b64.images) {
|
|
176
|
+
archive.append(img.buffer, { name: `images/${img.filename}` });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
archive.append(markdown, { name: 'document.md' });
|
|
168
181
|
}
|
|
169
182
|
|
|
170
183
|
// Download and add images if local mode
|
package/src/batch.js
CHANGED
|
@@ -139,9 +139,9 @@ export function createConfigFromUrls(urls, defaults = {}) {
|
|
|
139
139
|
url,
|
|
140
140
|
title: `Article ${id}`,
|
|
141
141
|
archivePath: `archive/${hostname.replace(/\./g, '-')}/${id}`,
|
|
142
|
-
markdownFile: '
|
|
143
|
-
screenshotLightFile: '
|
|
144
|
-
screenshotDarkFile: '
|
|
142
|
+
markdownFile: 'document.md',
|
|
143
|
+
screenshotLightFile: 'document-light.png',
|
|
144
|
+
screenshotDarkFile: 'document-dark.png',
|
|
145
145
|
imagesDir: 'images',
|
|
146
146
|
hasLocalImages: true,
|
|
147
147
|
};
|