@link-assistant/web-capture 1.7.10 → 1.7.11

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.7.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 71e7eb9: Fall back to the Google Docs export pipeline when browser capture cannot read editor model chunks, preserving archive content and embedded images.
8
+ - 7b4d2d2: Fix Rust CLI `--capture browser` silently routing through direct HTTP
9
+ fetches for non-Google-Docs URLs (issue #80):
10
+ - `rust/src/main.rs` now passes `--capture browser` through
11
+ `render_html` for markdown, archive, and html output formats so the
12
+ flag actually launches headless Chrome instead of calling
13
+ `fetch_html`.
14
+ - `rust/src/browser.rs` `capture_screenshot` is no longer a stub — it
15
+ launches headless Chrome with `--screenshot` and returns real PNG
16
+ bytes.
17
+ - Adds Rust integration tests that run a local HTTP fixture whose DOM
18
+ is mutated by JavaScript after load and verify the rendered HTML,
19
+ the CLI `--capture browser` markdown output, and the PNG signature
20
+ on the screenshot bytes.
21
+
22
+ No JS package behavior changes in this patch; the Rust CLI is the only
23
+ affected surface.
24
+
3
25
  ## 1.7.10
4
26
 
5
27
  ### Patch Changes
@@ -459,7 +459,7 @@ async function captureUrl(url, options) {
459
459
  fetchGoogleDoc,
460
460
  fetchGoogleDocAsMarkdown,
461
461
  fetchGoogleDocFromDocsApi,
462
- captureGoogleDocWithBrowser,
462
+ captureGoogleDocWithBrowserOrFallback,
463
463
  selectGoogleDocsCaptureMethod,
464
464
  } = await import('../src/gdocs.js');
465
465
  const { stripBase64Images } = await import('../src/extract-images.js');
@@ -511,40 +511,60 @@ async function captureUrl(url, options) {
511
511
  // Screenshot/PDF/DOCX formats should use the regular browser pipeline below.
512
512
  } else if (gdocsMethod === 'browser-model') {
513
513
  try {
514
- const result = await captureGoogleDocWithBrowser(absoluteUrl, {
515
- engine,
516
- apiToken,
517
- log,
518
- });
514
+ const result = await captureGoogleDocWithBrowserOrFallback(
515
+ absoluteUrl,
516
+ {
517
+ engine,
518
+ apiToken,
519
+ format: normalizedFormat,
520
+ log,
521
+ onFallback: (err) => {
522
+ console.error(
523
+ `Warning: Google Docs browser capture could not read editor content (${err.message}); falling back to Google Docs export API.`
524
+ );
525
+ },
526
+ }
527
+ );
528
+ const resultMethod = result.fallback ? result.method : gdocsMethod;
529
+ const resultCapture = result.capture || {};
519
530
  log.debug(() => ({
520
- event: 'gdocs.capture.browser-model.rendered',
531
+ event: result.fallback
532
+ ? 'gdocs.capture.browser-model.fallback-rendered'
533
+ : 'gdocs.capture.browser-model.rendered',
521
534
  documentId: result.documentId,
522
- blocks: result.capture?.blocks?.length || 0,
523
- tables: result.capture?.tables?.length || 0,
524
- images: result.capture?.images?.length || 0,
535
+ method: resultMethod,
536
+ blocks: resultCapture.blocks?.length || 0,
537
+ tables: resultCapture.tables?.length || 0,
538
+ images: resultCapture.images?.length || result.images?.length || 0,
525
539
  markdownBytes: Buffer.byteLength(result.markdown || ''),
526
540
  htmlBytes: Buffer.byteLength(result.html || ''),
527
541
  textBytes: Buffer.byteLength(result.text || ''),
528
542
  }));
529
543
  if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
530
- const { localizeGoogleDocsModelImages } =
531
- await import('../src/gdocs.js');
532
- const localized = await localizeGoogleDocsModelImages(result, {
533
- log,
534
- });
535
- log.debug(() => ({
536
- event: 'gdocs.capture.browser-model.archive.localized',
537
- images: localized.images.length,
538
- markdownBytes: Buffer.byteLength(localized.markdown),
539
- htmlBytes: Buffer.byteLength(localized.html),
540
- }));
541
- await writeGoogleDocsArchive({
542
- archiveResult: {
544
+ let archiveResult;
545
+ if (result.fallback) {
546
+ archiveResult = result;
547
+ } else {
548
+ const { localizeGoogleDocsModelImages } =
549
+ await import('../src/gdocs.js');
550
+ const localized = await localizeGoogleDocsModelImages(result, {
551
+ log,
552
+ });
553
+ log.debug(() => ({
554
+ event: 'gdocs.capture.browser-model.archive.localized',
555
+ images: localized.images.length,
556
+ markdownBytes: Buffer.byteLength(localized.markdown),
557
+ htmlBytes: Buffer.byteLength(localized.html),
558
+ }));
559
+ archiveResult = {
543
560
  ...result,
544
561
  markdown: localized.markdown,
545
562
  html: localized.html,
546
563
  images: localized.images,
547
- },
564
+ };
565
+ }
566
+ await writeGoogleDocsArchive({
567
+ archiveResult,
548
568
  absoluteUrl,
549
569
  explicitOutput,
550
570
  dataDir,
@@ -554,10 +574,10 @@ async function captureUrl(url, options) {
554
574
  } else {
555
575
  const rendered =
556
576
  normalizedFormat === 'html'
557
- ? result.html
577
+ ? result.html || result.content
558
578
  : normalizedFormat === 'txt' || normalizedFormat === 'text'
559
- ? result.text
560
- : result.markdown;
579
+ ? result.text || result.content
580
+ : result.markdown || result.content;
561
581
  const ext =
562
582
  normalizedFormat === 'html'
563
583
  ? 'html'
@@ -570,7 +590,7 @@ async function captureUrl(url, options) {
570
590
  ext,
571
591
  explicitOutput,
572
592
  dataDir,
573
- label: `Google Doc (${gdocsMethod})`,
593
+ label: `Google Doc (${resultMethod})`,
574
594
  });
575
595
  }
576
596
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.10",
3
+ "version": "1.7.11",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,152 @@
1
+ // Google Docs browser-model fallback helpers.
2
+ //
3
+ // When browser-model capture cannot read DOCS_modelChunk data from the editor
4
+ // page, fall back to the public export pipeline so that content and images
5
+ // are preserved (instead of returning an empty or lossy capture).
6
+
7
+ const GDOCS_BROWSER_MODEL_UNAVAILABLE = 'GDOCS_BROWSER_MODEL_UNAVAILABLE';
8
+
9
+ /**
10
+ * Build the sentinel error thrown when browser-model capture finds no chunks.
11
+ *
12
+ * @param {string} message - Human-readable error message
13
+ * @returns {Error} Error carrying the sentinel code
14
+ */
15
+ export function googleDocsBrowserModelUnavailableError(message) {
16
+ const err = new Error(message);
17
+ err.code = GDOCS_BROWSER_MODEL_UNAVAILABLE;
18
+ return err;
19
+ }
20
+
21
+ /**
22
+ * Check whether an error means Google Docs browser-model data was unavailable.
23
+ *
24
+ * @param {Error} err - Error to classify
25
+ * @returns {boolean} True if export fallback is appropriate
26
+ */
27
+ export function isGoogleDocsBrowserModelUnavailableError(err) {
28
+ return (
29
+ err?.code === GDOCS_BROWSER_MODEL_UNAVAILABLE ||
30
+ String(err?.message || '').includes('did not expose DOCS_modelChunk data')
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Fetch a Google Doc through the public export pipeline for a requested output
36
+ * format. This is used as the lossless fallback when browser-model capture
37
+ * cannot read DOCS_modelChunk data from the editor page.
38
+ *
39
+ * @param {Object} deps - Injected dependencies from gdocs.js
40
+ * @param {string} url - Google Docs URL
41
+ * @param {Object} [options] - Options
42
+ * @returns {Promise<Object>} Export result normalized for CLI rendering
43
+ */
44
+ export async function fetchGoogleDocByExportFormat(deps, url, options = {}) {
45
+ const { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive } =
46
+ deps;
47
+ const { format = 'markdown', apiToken, log } = options;
48
+ const normalized = (format || 'markdown').toLowerCase();
49
+
50
+ if (normalized === 'archive' || normalized === 'zip') {
51
+ const archiveResult = await fetchGoogleDocAsArchive(url, { apiToken, log });
52
+ return {
53
+ ...archiveResult,
54
+ content: archiveResult.markdown,
55
+ sourceFormat: 'archive',
56
+ };
57
+ }
58
+
59
+ if (normalized === 'markdown' || normalized === 'md') {
60
+ const result = await fetchGoogleDocAsMarkdown(url, { apiToken, log });
61
+ return {
62
+ ...result,
63
+ content: result.markdown,
64
+ sourceFormat: 'markdown',
65
+ };
66
+ }
67
+
68
+ if (normalized === 'html') {
69
+ const result = await fetchGoogleDoc(url, {
70
+ format: 'html',
71
+ apiToken,
72
+ log,
73
+ });
74
+ return {
75
+ ...result,
76
+ html: result.content,
77
+ sourceFormat: 'html',
78
+ };
79
+ }
80
+
81
+ if (normalized === 'txt' || normalized === 'text') {
82
+ const result = await fetchGoogleDoc(url, {
83
+ format: 'txt',
84
+ apiToken,
85
+ log,
86
+ });
87
+ return {
88
+ ...result,
89
+ text: result.content,
90
+ sourceFormat: 'txt',
91
+ };
92
+ }
93
+
94
+ throw new Error(
95
+ `Unsupported Google Docs export fallback format "${format}".`
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Capture a Google Doc through the browser model, falling back to public export
101
+ * when the editor does not expose model chunks.
102
+ *
103
+ * @param {Object} deps - Injected dependencies from gdocs.js
104
+ * @param {string} url - Google Docs URL
105
+ * @param {Object} [options] - Capture options
106
+ * @returns {Promise<Object>} Browser-model or public-export result
107
+ */
108
+ export async function captureGoogleDocWithBrowserOrFallback(
109
+ deps,
110
+ url,
111
+ options = {}
112
+ ) {
113
+ const { captureGoogleDocWithBrowser } = deps;
114
+ const {
115
+ format = 'markdown',
116
+ fallback = true,
117
+ onFallback,
118
+ ...browserOptions
119
+ } = options;
120
+
121
+ try {
122
+ const result = await captureGoogleDocWithBrowser(url, browserOptions);
123
+ return {
124
+ ...result,
125
+ method: 'browser-model',
126
+ fallback: false,
127
+ };
128
+ } catch (err) {
129
+ if (!fallback || !isGoogleDocsBrowserModelUnavailableError(err)) {
130
+ throw err;
131
+ }
132
+
133
+ onFallback?.(err);
134
+ browserOptions.log?.warn?.(() => ({
135
+ event: 'gdocs.browser-model.fallback-public-export',
136
+ reason: err.message,
137
+ format,
138
+ }));
139
+
140
+ const result = await fetchGoogleDocByExportFormat(deps, url, {
141
+ format,
142
+ apiToken: browserOptions.apiToken,
143
+ log: browserOptions.log,
144
+ });
145
+ return {
146
+ ...result,
147
+ method: 'public-export',
148
+ fallback: true,
149
+ browserError: err.message,
150
+ };
151
+ }
152
+ }
package/src/gdocs.js CHANGED
@@ -13,8 +13,18 @@ import { convertHtmlToMarkdown } from './lib.js';
13
13
  import { createBrowser as defaultCreateBrowser } from './browser.js';
14
14
  import { preprocessGoogleDocsExportHtml } from './gdocs-preprocess.js';
15
15
  import { localizeGoogleDocsModelImages } from './gdocs-images.js';
16
-
17
- export { preprocessGoogleDocsExportHtml, localizeGoogleDocsModelImages };
16
+ import {
17
+ googleDocsBrowserModelUnavailableError,
18
+ isGoogleDocsBrowserModelUnavailableError,
19
+ fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
20
+ captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
21
+ } from './gdocs-fallback.js';
22
+
23
+ export {
24
+ preprocessGoogleDocsExportHtml,
25
+ localizeGoogleDocsModelImages,
26
+ isGoogleDocsBrowserModelUnavailableError,
27
+ };
18
28
 
19
29
  const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
20
30
 
@@ -308,6 +318,43 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
308
318
  };
309
319
  }
310
320
 
321
+ /**
322
+ * Fetch a Google Doc through the public export pipeline for a requested output
323
+ * format. Thin wrapper over the fallback module that injects sibling fetchers.
324
+ *
325
+ * @param {string} url - Google Docs URL
326
+ * @param {Object} [options] - Options
327
+ * @returns {Promise<Object>} Export result normalized for CLI rendering
328
+ */
329
+ export function fetchGoogleDocByExportFormat(url, options = {}) {
330
+ return fetchGoogleDocByExportFormatImpl(
331
+ { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive },
332
+ url,
333
+ options
334
+ );
335
+ }
336
+
337
+ /**
338
+ * Capture a Google Doc through the browser model, falling back to public export
339
+ * when the editor does not expose model chunks. Thin wrapper.
340
+ *
341
+ * @param {string} url - Google Docs URL
342
+ * @param {Object} [options] - Capture options
343
+ * @returns {Promise<Object>} Browser-model or public-export result
344
+ */
345
+ export function captureGoogleDocWithBrowserOrFallback(url, options = {}) {
346
+ return captureGoogleDocWithBrowserOrFallbackImpl(
347
+ {
348
+ captureGoogleDocWithBrowser,
349
+ fetchGoogleDoc,
350
+ fetchGoogleDocAsMarkdown,
351
+ fetchGoogleDocAsArchive,
352
+ },
353
+ url,
354
+ options
355
+ );
356
+ }
357
+
311
358
  /**
312
359
  * Capture a Google Doc from the editor page model (`DOCS_modelChunk`).
313
360
  *
@@ -409,7 +456,7 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
409
456
  textBytes: Buffer.byteLength(capture.text || ''),
410
457
  }));
411
458
  if (capture.blocks.length === 0) {
412
- throw new Error(
459
+ throw googleDocsBrowserModelUnavailableError(
413
460
  'Google Docs editor page did not expose DOCS_modelChunk data'
414
461
  );
415
462
  }
@@ -6,6 +6,8 @@ import {
6
6
  buildEditUrl,
7
7
  buildDocsApiUrl,
8
8
  extractBase64Images,
9
+ captureGoogleDocWithBrowserOrFallback,
10
+ isGoogleDocsBrowserModelUnavailableError,
9
11
  parseGoogleDocsModelChunks,
10
12
  renderGoogleDocsCapture,
11
13
  renderDocsApiDocument,
@@ -22,6 +24,7 @@ import {
22
24
  import fs from 'fs';
23
25
  import os from 'os';
24
26
  import path from 'path';
27
+ import nock from 'nock';
25
28
 
26
29
  jest.setTimeout(30000);
27
30
 
@@ -539,6 +542,49 @@ describe('gdocs', () => {
539
542
  });
540
543
  });
541
544
 
545
+ describe('captureGoogleDocWithBrowserOrFallback (issue #81)', () => {
546
+ afterEach(() => {
547
+ nock.cleanAll();
548
+ });
549
+
550
+ it('falls back to public export archive when the editor exposes no model chunks', async () => {
551
+ const tinyPng =
552
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
553
+ nock('https://docs.google.com')
554
+ .get('/document/d/fallback-doc/export')
555
+ .query({ format: 'html' })
556
+ .reply(
557
+ 200,
558
+ `<html><body><h1>Fallback Doc</h1><p>Complete text</p><img src="data:image/png;base64,${tinyPng}" alt="diagram"></body></html>`,
559
+ { 'content-type': 'text/html; charset=utf-8' }
560
+ );
561
+
562
+ const fallbackErrors = [];
563
+ const result = await captureGoogleDocWithBrowserOrFallback(
564
+ 'https://docs.google.com/document/d/fallback-doc/edit',
565
+ {
566
+ format: 'archive',
567
+ waitMs: 0,
568
+ createBrowser: async () =>
569
+ fakeBrowserReturningModel({ chunks: [], cidUrlMap: {} }),
570
+ onFallback: (err) => fallbackErrors.push(err),
571
+ }
572
+ );
573
+
574
+ expect(result.method).toBe('public-export');
575
+ expect(result.fallback).toBe(true);
576
+ expect(result.markdown).toContain('Fallback Doc');
577
+ expect(result.markdown).toContain('Complete text');
578
+ expect(result.images).toHaveLength(1);
579
+ expect(result.html).toContain('images/image-01.png');
580
+ expect(fallbackErrors).toHaveLength(1);
581
+ expect(isGoogleDocsBrowserModelUnavailableError(fallbackErrors[0])).toBe(
582
+ true
583
+ );
584
+ expect(nock.isDone()).toBe(true);
585
+ });
586
+ });
587
+
542
588
  describe('GDOCS_EXPORT_FORMATS', () => {
543
589
  it('contains all expected formats', () => {
544
590
  expect(GDOCS_EXPORT_FORMATS).toEqual({
@@ -806,3 +852,23 @@ describe('gdocs', () => {
806
852
  });
807
853
  });
808
854
  });
855
+
856
+ function fakeBrowserReturningModel(modelData) {
857
+ return {
858
+ async newPage() {
859
+ return {
860
+ async addInitScript() {},
861
+ async setUserAgent() {},
862
+ async setExtraHTTPHeaders() {},
863
+ async setViewport() {},
864
+ async goto() {},
865
+ async waitForTimeout() {},
866
+ async evaluate() {
867
+ return modelData;
868
+ },
869
+ async close() {},
870
+ };
871
+ },
872
+ async close() {},
873
+ };
874
+ }