@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +57 -13
  2. package/Dockerfile +26 -21
  3. package/README.md +290 -160
  4. package/bin/web-capture.js +622 -17
  5. package/package.json +15 -4
  6. package/src/animation.js +283 -0
  7. package/src/archive.js +225 -0
  8. package/src/batch.js +180 -0
  9. package/src/browser.js +150 -111
  10. package/src/docx.js +182 -0
  11. package/src/extract-images.js +120 -0
  12. package/src/figures.js +208 -0
  13. package/src/gdocs.js +335 -0
  14. package/src/image.js +65 -8
  15. package/src/index.js +15 -0
  16. package/src/latex.js +127 -0
  17. package/src/lib.js +281 -1
  18. package/src/localize-images.js +202 -0
  19. package/src/markdown.js +7 -2
  20. package/src/metadata.js +333 -0
  21. package/src/pdf.js +76 -0
  22. package/src/popups.js +171 -0
  23. package/src/postprocess.js +263 -0
  24. package/src/retry.js +48 -0
  25. package/src/themed-image.js +167 -0
  26. package/src/verify.js +312 -0
  27. package/tests/e2e/docker.test.js +51 -12
  28. package/tests/e2e/process.test.js +34 -10
  29. package/tests/fixtures/habr-articles.js +45 -0
  30. package/tests/integration/api-endpoints.test.js +231 -0
  31. package/tests/integration/habr-article.test.js +337 -0
  32. package/tests/mock/index.test.js +5 -3
  33. package/tests/unit/animation.test.js +33 -0
  34. package/tests/unit/batch.test.js +114 -0
  35. package/tests/unit/browser.test.js +38 -2
  36. package/tests/unit/extract-images.test.js +233 -0
  37. package/tests/unit/figures.test.js +74 -0
  38. package/tests/unit/gdocs.test.js +278 -0
  39. package/tests/unit/heading-image.test.js +49 -0
  40. package/tests/unit/latex.test.js +123 -0
  41. package/tests/unit/localize-images.test.js +78 -0
  42. package/tests/unit/metadata.test.js +140 -0
  43. package/tests/unit/postprocess.test.js +110 -0
  44. package/tests/unit/retry.test.js +111 -0
  45. package/tests/unit/verify.test.js +182 -0
  46. package/.github/workflows/release.yml +0 -294
  47. package/ARCHITECTURE.md +0 -982
  48. package/LICENSE +0 -24
  49. package/apply.sh +0 -166
  50. package/docs/market-analysis-2025-05-13.md +0 -64
  51. package/scripts/changeset-version.mjs +0 -38
  52. package/scripts/create-github-release.mjs +0 -93
  53. package/scripts/create-manual-changeset.mjs +0 -88
  54. package/scripts/format-github-release.mjs +0 -83
  55. package/scripts/format-release-notes.mjs +0 -218
  56. package/scripts/instant-version-bump.mjs +0 -121
  57. package/scripts/publish-to-npm.mjs +0 -128
  58. package/scripts/setup-npm.mjs +0 -37
  59. package/scripts/validate-changeset.mjs +0 -106
  60. package/scripts/version-and-commit.mjs +0 -237
@@ -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: 'Output format: html, markdown, md, image, png',
36
- default: 'html',
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> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /image?url=<URL>&engine=<ENGINE> Get PNG screenshot\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy'
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 page as PNG`);
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 { format, output, engine } = options;
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
- fetchHtml,
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 === 'markdown' || normalizedFormat === 'md') {
155
- // Markdown format
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 markdown = convertHtmlToMarkdown(html, absoluteUrl);
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 (output) {
192
- fs.writeFileSync(output, buffer);
193
- console.error(`Screenshot saved to: ${output}`);
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