@link-assistant/web-capture 1.7.16 → 1.7.17

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,11 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.7.17
4
+
5
+ ### Patch Changes
6
+
7
+ - 86b6fc5: Wait for Google Docs browser-model chunks to quiesce before parsing captures.
8
+
3
9
  ## 1.7.16
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -102,6 +102,11 @@ web-capture https://docs.google.com/document/d/DOC_ID/edit --capture api
102
102
  web-capture https://docs.google.com/document/d/DOC_ID/edit --capture api --apiToken YOUR_TOKEN
103
103
  ```
104
104
 
105
+ Browser-model capture waits until `DOCS_modelChunk` data stops changing before
106
+ parsing. For very large or slow documents, tune the wait with
107
+ `WEB_CAPTURE_GDOCS_STABILITY_MS` (default `1500`) and
108
+ `WEB_CAPTURE_GDOCS_MAX_WAIT_MS` (default `30000`).
109
+
105
110
  ## API Endpoints (Server Mode)
106
111
 
107
112
  Start the server with `web-capture --serve` and use the endpoints below.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.16",
3
+ "version": "1.7.17",
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,186 @@
1
+ /* global document, window */
2
+ import { googleDocsBrowserModelUnavailableError } from './gdocs-fallback.js';
3
+
4
+ const DEFAULT_GDOCS_MODEL_STABILITY_MS = 1500;
5
+ const DEFAULT_GDOCS_MODEL_MAX_WAIT_MS = 30000;
6
+ const DEFAULT_GDOCS_MODEL_POLL_MS = 250;
7
+
8
+ export async function waitForGoogleDocsModelQuiescence(
9
+ page,
10
+ { documentId, maxWaitMs, stabilityMs, pollMs, log }
11
+ ) {
12
+ const started = Date.now();
13
+ let lastFingerprint = null;
14
+ let stableSince = null;
15
+ let lastModelData = null;
16
+ let lastStableForMs = 0;
17
+ let pollCount = 0;
18
+
19
+ while (true) {
20
+ const modelData = await readGoogleDocsModelData(page);
21
+ pollCount += 1;
22
+ const fingerprint = googleDocsModelFingerprint(modelData);
23
+ lastModelData = modelData;
24
+
25
+ if (
26
+ fingerprint.chunks > 0 &&
27
+ sameModelFingerprint(fingerprint, lastFingerprint)
28
+ ) {
29
+ if (stableSince === null) {
30
+ stableSince = Date.now();
31
+ }
32
+ lastStableForMs = Date.now() - stableSince;
33
+ if (lastStableForMs >= stabilityMs) {
34
+ log?.debug?.(() => ({
35
+ event: 'gdocs.browser-model.quiesced',
36
+ documentId,
37
+ chunks: fingerprint.chunks,
38
+ chunkPayloadBytes: fingerprint.payloadBytes,
39
+ cidUrls: Object.keys(modelData.cidUrlMap || {}).length,
40
+ pollCount,
41
+ stableForMs: lastStableForMs,
42
+ elapsedMs: Date.now() - started,
43
+ }));
44
+ return {
45
+ ...modelData,
46
+ chunkPayloadBytes: fingerprint.payloadBytes,
47
+ pollCount,
48
+ stableForMs: lastStableForMs,
49
+ };
50
+ }
51
+ } else {
52
+ lastFingerprint = fingerprint;
53
+ stableSince = null;
54
+ lastStableForMs = 0;
55
+ }
56
+
57
+ const elapsedMs = Date.now() - started;
58
+ if (elapsedMs >= maxWaitMs) {
59
+ const lastChunks = lastModelData?.chunks?.length || 0;
60
+ const lastCidUrls = Object.keys(lastModelData?.cidUrlMap || {}).length;
61
+ throw googleDocsBrowserModelUnavailableError(
62
+ `Google Docs DOCS_modelChunk stream did not quiesce within ${maxWaitMs} ms for document ${documentId} (last chunks=${lastChunks}, payload_bytes=${fingerprint.payloadBytes}, cid_urls=${lastCidUrls}, poll_count=${pollCount}, stable_for_ms=${lastStableForMs})`
63
+ );
64
+ }
65
+
66
+ await waitForPage(page, Math.min(pollMs, maxWaitMs - elapsedMs));
67
+ }
68
+ }
69
+
70
+ export function resolveGoogleDocsModelWaitOptions(options) {
71
+ return {
72
+ stabilityMs: readDurationMs(
73
+ options.modelStabilityMs,
74
+ 'WEB_CAPTURE_GDOCS_STABILITY_MS',
75
+ DEFAULT_GDOCS_MODEL_STABILITY_MS
76
+ ),
77
+ maxWaitMs: readDurationMs(
78
+ options.modelMaxWaitMs ?? options.waitMs,
79
+ 'WEB_CAPTURE_GDOCS_MAX_WAIT_MS',
80
+ DEFAULT_GDOCS_MODEL_MAX_WAIT_MS
81
+ ),
82
+ pollMs: Math.max(
83
+ 1,
84
+ readDurationMs(
85
+ options.modelPollMs,
86
+ 'WEB_CAPTURE_GDOCS_POLL_MS',
87
+ DEFAULT_GDOCS_MODEL_POLL_MS
88
+ )
89
+ ),
90
+ };
91
+ }
92
+
93
+ async function readGoogleDocsModelData(page) {
94
+ return await evaluateOnPage(page, () => {
95
+ const chunks = [...(window.__captured_chunks || [])];
96
+ if (
97
+ window.DOCS_modelChunk &&
98
+ chunks.length === 0 &&
99
+ !chunks.includes(window.DOCS_modelChunk)
100
+ ) {
101
+ chunks.push(window.DOCS_modelChunk);
102
+ }
103
+ const cidUrlMap = {};
104
+ const scripts = document.querySelectorAll('script');
105
+ for (const script of scripts) {
106
+ const text = script.textContent || '';
107
+ if (!text.includes('docs-images-rt')) {
108
+ continue;
109
+ }
110
+ const regex =
111
+ /"([A-Za-z0-9_-]{20,})"\s*:\s*"(https:\/\/docs\.google\.com\/docs-images-rt\/[^"]+)"/g;
112
+ let match;
113
+ while ((match = regex.exec(text)) !== null) {
114
+ cidUrlMap[match[1]] = match[2]
115
+ .replace(/\\u003d/g, '=')
116
+ .replace(/\\u0026/g, '&')
117
+ .replace(/\\\//g, '/');
118
+ }
119
+ }
120
+ return { chunks, cidUrlMap };
121
+ });
122
+ }
123
+
124
+ function googleDocsModelFingerprint(modelData) {
125
+ const chunks = modelData?.chunks || [];
126
+ return {
127
+ chunks: chunks.length,
128
+ payloadBytes: chunks.reduce(
129
+ (total, chunk) => total + jsonPayloadByteLength(chunk),
130
+ 0
131
+ ),
132
+ };
133
+ }
134
+
135
+ function sameModelFingerprint(left, right) {
136
+ return (
137
+ Boolean(left) &&
138
+ Boolean(right) &&
139
+ left.chunks === right.chunks &&
140
+ left.payloadBytes === right.payloadBytes
141
+ );
142
+ }
143
+
144
+ function jsonPayloadByteLength(value) {
145
+ try {
146
+ return Buffer.byteLength(JSON.stringify(value) || '');
147
+ } catch {
148
+ return 0;
149
+ }
150
+ }
151
+
152
+ function readDurationMs(optionValue, envName, defaultValue) {
153
+ const rawValue = optionValue ?? process.env[envName];
154
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
155
+ return defaultValue;
156
+ }
157
+ const parsed = Number(rawValue);
158
+ if (!Number.isFinite(parsed) || parsed < 0) {
159
+ return defaultValue;
160
+ }
161
+ return Math.floor(parsed);
162
+ }
163
+
164
+ async function waitForPage(page, waitMs) {
165
+ if (waitMs <= 0) {
166
+ return;
167
+ }
168
+ const rawPage = page.rawPage || page;
169
+ if (typeof rawPage.waitForTimeout === 'function') {
170
+ await rawPage.waitForTimeout(waitMs);
171
+ return;
172
+ }
173
+ if (typeof page.waitForTimeout === 'function') {
174
+ await page.waitForTimeout(waitMs);
175
+ return;
176
+ }
177
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
178
+ }
179
+
180
+ async function evaluateOnPage(page, fn) {
181
+ const rawPage = page.rawPage || page;
182
+ if (rawPage !== page && typeof rawPage.evaluate === 'function') {
183
+ return await rawPage.evaluate(fn);
184
+ }
185
+ return await page.evaluate(fn);
186
+ }
package/src/gdocs.js CHANGED
@@ -1,4 +1,4 @@
1
- /* global document, window */
1
+ /* global window */
2
2
  // Google Docs capture module.
3
3
  //
4
4
  // Public export capture uses:
@@ -23,6 +23,10 @@ import {
23
23
  fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
24
24
  captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
25
25
  } from './gdocs-fallback.js';
26
+ import {
27
+ resolveGoogleDocsModelWaitOptions,
28
+ waitForGoogleDocsModelQuiescence,
29
+ } from './gdocs-model-wait.js';
26
30
 
27
31
  export {
28
32
  normalizeGoogleDocsExportMarkdown,
@@ -369,7 +373,10 @@ export function captureGoogleDocWithBrowserOrFallback(url, options = {}) {
369
373
  * @param {Object} [options] - Capture options
370
374
  * @param {string} [options.engine='playwright'] - Browser engine
371
375
  * @param {string} [options.apiToken] - Optional Authorization header token
372
- * @param {number} [options.waitMs=8000] - Post-load wait for chunks
376
+ * @param {number} [options.waitMs] - Deprecated alias for modelMaxWaitMs
377
+ * @param {number} [options.modelStabilityMs=1500] - Required quiet period after the last model change
378
+ * @param {number} [options.modelMaxWaitMs=30000] - Hard timeout for model quiescence
379
+ * @param {number} [options.modelPollMs=250] - Poll interval while waiting for model quiescence
373
380
  * @param {Function} [options.createBrowser] - Browser factory for tests
374
381
  * @returns {Promise<{capture: Object, markdown: string, html: string, text: string, documentId: string, exportUrl: string}>}
375
382
  */
@@ -377,10 +384,10 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
377
384
  const {
378
385
  engine = 'playwright',
379
386
  apiToken,
380
- waitMs = 8000,
381
387
  createBrowser = defaultCreateBrowser,
382
388
  log,
383
389
  } = options;
390
+ const modelWait = resolveGoogleDocsModelWaitOptions(options);
384
391
  const documentId = extractDocumentId(url);
385
392
  if (!documentId) {
386
393
  throw new Error(`Not a valid Google Docs URL: ${url}`);
@@ -392,7 +399,9 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
392
399
  documentId,
393
400
  editUrl,
394
401
  engine,
395
- waitMs,
402
+ modelStabilityMs: modelWait.stabilityMs,
403
+ modelMaxWaitMs: modelWait.maxWaitMs,
404
+ modelPollMs: modelWait.pollMs,
396
405
  hasApiToken: Boolean(apiToken),
397
406
  }));
398
407
  const browser = await createBrowser(engine);
@@ -416,35 +425,10 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
416
425
  documentId,
417
426
  editUrl,
418
427
  }));
419
- await waitForPage(page, waitMs);
420
-
421
- const modelData = await evaluateOnPage(page, () => {
422
- const chunks = [...(window.__captured_chunks || [])];
423
- if (
424
- window.DOCS_modelChunk &&
425
- chunks.length === 0 &&
426
- !chunks.includes(window.DOCS_modelChunk)
427
- ) {
428
- chunks.push(window.DOCS_modelChunk);
429
- }
430
- const cidUrlMap = {};
431
- const scripts = document.querySelectorAll('script');
432
- for (const script of scripts) {
433
- const text = script.textContent || '';
434
- if (!text.includes('docs-images-rt')) {
435
- continue;
436
- }
437
- const regex =
438
- /"([A-Za-z0-9_-]{20,})"\s*:\s*"(https:\/\/docs\.google\.com\/docs-images-rt\/[^"]+)"/g;
439
- let match;
440
- while ((match = regex.exec(text)) !== null) {
441
- cidUrlMap[match[1]] = match[2]
442
- .replace(/\\u003d/g, '=')
443
- .replace(/\\u0026/g, '&')
444
- .replace(/\\\//g, '/');
445
- }
446
- }
447
- return { chunks, cidUrlMap };
428
+ const modelData = await waitForGoogleDocsModelQuiescence(page, {
429
+ ...modelWait,
430
+ documentId,
431
+ log,
448
432
  });
449
433
 
450
434
  const capture = parseGoogleDocsModelChunks(
@@ -457,6 +441,9 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
457
441
  documentId,
458
442
  chunks: modelData.chunks?.length || 0,
459
443
  cidUrls: Object.keys(modelData.cidUrlMap || {}).length,
444
+ chunkPayloadBytes: modelData.chunkPayloadBytes,
445
+ pollCount: modelData.pollCount,
446
+ stableForMs: modelData.stableForMs,
460
447
  blocks: capture.blocks.length,
461
448
  tables: capture.tables.length,
462
449
  images: capture.images.length,
@@ -474,6 +461,11 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
474
461
  text: renderGoogleDocsCapture(capture, 'txt'),
475
462
  documentId,
476
463
  exportUrl: editUrl,
464
+ diagnostics: {
465
+ modelChunkPayloadBytes: modelData.chunkPayloadBytes,
466
+ modelPollCount: modelData.pollCount,
467
+ modelStableForMs: modelData.stableForMs,
468
+ },
477
469
  };
478
470
  } finally {
479
471
  if (page) {
@@ -548,30 +540,6 @@ async function installDocsModelCapture(page) {
548
540
  }
549
541
  }
550
542
 
551
- async function waitForPage(page, waitMs) {
552
- if (waitMs <= 0) {
553
- return;
554
- }
555
- const rawPage = page.rawPage || page;
556
- if (typeof rawPage.waitForTimeout === 'function') {
557
- await rawPage.waitForTimeout(waitMs);
558
- return;
559
- }
560
- if (typeof page.waitForTimeout === 'function') {
561
- await page.waitForTimeout(waitMs);
562
- return;
563
- }
564
- await new Promise((resolve) => setTimeout(resolve, waitMs));
565
- }
566
-
567
- async function evaluateOnPage(page, fn) {
568
- const rawPage = page.rawPage || page;
569
- if (rawPage !== page && typeof rawPage.evaluate === 'function') {
570
- return await rawPage.evaluate(fn);
571
- }
572
- return await page.evaluate(fn);
573
- }
574
-
575
543
  /**
576
544
  * Extract a CID-to-image URL map from Google Docs editor HTML.
577
545
  *
@@ -182,7 +182,7 @@ describe('Google Docs public test document (issue #90)', () => {
182
182
  });
183
183
 
184
184
  const captureBrowserWithRetry = (url) =>
185
- retry(() => captureGoogleDocWithBrowser(url, { waitMs: 3000 }), {
185
+ retry(() => captureGoogleDocWithBrowser(url), {
186
186
  retries: 2,
187
187
  baseDelay: 2000,
188
188
  });
@@ -7,6 +7,7 @@ import {
7
7
  buildDocsApiUrl,
8
8
  extractBase64Images,
9
9
  captureGoogleDocWithBrowserOrFallback,
10
+ captureGoogleDocWithBrowser,
10
11
  isGoogleDocsBrowserModelUnavailableError,
11
12
  parseGoogleDocsModelChunks,
12
13
  renderGoogleDocsCapture,
@@ -948,6 +949,57 @@ describe('gdocs', () => {
948
949
  });
949
950
  });
950
951
 
952
+ describe('captureGoogleDocWithBrowser (issue #106)', () => {
953
+ it('waits for DOCS_modelChunk data to stop changing before parsing', async () => {
954
+ const browser = fakeBrowserReturningModelSequence([
955
+ {
956
+ chunks: [{ chunk: [{ ty: 'is', s: 'First paragraph\n' }] }],
957
+ cidUrlMap: {},
958
+ },
959
+ {
960
+ chunks: [{ chunk: [{ ty: 'is', s: 'First paragraph\n' }] }],
961
+ cidUrlMap: {},
962
+ },
963
+ {
964
+ chunks: [
965
+ { chunk: [{ ty: 'is', s: 'First paragraph\n' }] },
966
+ { chunk: [{ ty: 'is', s: 'Second paragraph\n' }] },
967
+ ],
968
+ cidUrlMap: {},
969
+ },
970
+ {
971
+ chunks: [
972
+ { chunk: [{ ty: 'is', s: 'First paragraph\n' }] },
973
+ { chunk: [{ ty: 'is', s: 'Second paragraph\n' }] },
974
+ ],
975
+ cidUrlMap: {},
976
+ },
977
+ {
978
+ chunks: [
979
+ { chunk: [{ ty: 'is', s: 'First paragraph\n' }] },
980
+ { chunk: [{ ty: 'is', s: 'Second paragraph\n' }] },
981
+ ],
982
+ cidUrlMap: {},
983
+ },
984
+ ]);
985
+
986
+ const result = await captureGoogleDocWithBrowser(
987
+ 'https://docs.google.com/document/d/quiescence-doc/edit',
988
+ {
989
+ waitMs: 0,
990
+ modelMaxWaitMs: 200,
991
+ modelPollMs: 2,
992
+ modelStabilityMs: 2,
993
+ createBrowser: async () => browser,
994
+ }
995
+ );
996
+
997
+ expect(result.markdown).toContain('First paragraph');
998
+ expect(result.markdown).toContain('Second paragraph');
999
+ expect(browser.evaluateCalls).toBeGreaterThan(2);
1000
+ });
1001
+ });
1002
+
951
1003
  describe('GDOCS_EXPORT_FORMATS', () => {
952
1004
  it('contains all expected formats', () => {
953
1005
  expect(GDOCS_EXPORT_FORMATS).toEqual({
@@ -1303,3 +1355,31 @@ function fakeBrowserReturningModel(modelData) {
1303
1355
  async close() {},
1304
1356
  };
1305
1357
  }
1358
+
1359
+ function fakeBrowserReturningModelSequence(modelDataSequence) {
1360
+ let evaluateCalls = 0;
1361
+ return {
1362
+ get evaluateCalls() {
1363
+ return evaluateCalls;
1364
+ },
1365
+ async newPage() {
1366
+ return {
1367
+ async addInitScript() {},
1368
+ async setUserAgent() {},
1369
+ async setExtraHTTPHeaders() {},
1370
+ async setViewport() {},
1371
+ async goto() {},
1372
+ async waitForTimeout(waitMs) {
1373
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1374
+ },
1375
+ async evaluate() {
1376
+ const index = Math.min(evaluateCalls, modelDataSequence.length - 1);
1377
+ evaluateCalls += 1;
1378
+ return JSON.parse(JSON.stringify(modelDataSequence[index]));
1379
+ },
1380
+ async close() {},
1381
+ };
1382
+ },
1383
+ async close() {},
1384
+ };
1385
+ }