@link-assistant/web-capture 1.7.16 → 1.7.18

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,17 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.7.18
4
+
5
+ ### Patch Changes
6
+
7
+ - 2c69a73: Fix Google Docs browser capture list semantics and continuation paragraph handling.
8
+
9
+ ## 1.7.17
10
+
11
+ ### Patch Changes
12
+
13
+ - 86b6fc5: Wait for Google Docs browser-model chunks to quiesce before parsing captures.
14
+
3
15
  ## 1.7.16
4
16
 
5
17
  ### 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.18",
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:
@@ -9,6 +9,7 @@
9
9
 
10
10
  import fetch from 'node-fetch';
11
11
  import he from 'he';
12
+ import * as cheerio from 'cheerio';
12
13
  import { convertHtmlToMarkdown } from './lib.js';
13
14
  import { createBrowser as defaultCreateBrowser } from './browser.js';
14
15
  import {
@@ -23,6 +24,10 @@ import {
23
24
  fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
24
25
  captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
25
26
  } from './gdocs-fallback.js';
27
+ import {
28
+ resolveGoogleDocsModelWaitOptions,
29
+ waitForGoogleDocsModelQuiescence,
30
+ } from './gdocs-model-wait.js';
26
31
 
27
32
  export {
28
33
  normalizeGoogleDocsExportMarkdown,
@@ -196,11 +201,10 @@ export async function fetchGoogleDoc(url, options = {}) {
196
201
  bytes: Buffer.byteLength(rawContent),
197
202
  }));
198
203
 
199
- // Decode HTML entities to unicode for text-based formats
204
+ // Keep HTML markup escaped so literal examples such as `&lt;ol&gt;` do not
205
+ // become real tags before the HTML parser sees the document.
200
206
  const content =
201
- format === 'html' || format === 'txt' || format === 'md'
202
- ? he.decode(rawContent)
203
- : rawContent;
207
+ format === 'txt' || format === 'md' ? he.decode(rawContent) : rawContent;
204
208
 
205
209
  return {
206
210
  content,
@@ -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,47 +425,30 @@ 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
  });
433
+ const exportHtml = await fetchGoogleDocsExportHtmlFromPage(
434
+ page,
435
+ documentId,
436
+ log
437
+ );
449
438
 
450
439
  const capture = parseGoogleDocsModelChunks(
451
440
  modelData.chunks,
452
441
  modelData.cidUrlMap,
453
- { log }
442
+ { log, exportHtml }
454
443
  );
455
444
  log?.debug?.(() => ({
456
445
  event: 'gdocs.browser-model.parsed',
457
446
  documentId,
458
447
  chunks: modelData.chunks?.length || 0,
459
448
  cidUrls: Object.keys(modelData.cidUrlMap || {}).length,
449
+ chunkPayloadBytes: modelData.chunkPayloadBytes,
450
+ pollCount: modelData.pollCount,
451
+ stableForMs: modelData.stableForMs,
460
452
  blocks: capture.blocks.length,
461
453
  tables: capture.tables.length,
462
454
  images: capture.images.length,
@@ -474,6 +466,11 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
474
466
  text: renderGoogleDocsCapture(capture, 'txt'),
475
467
  documentId,
476
468
  exportUrl: editUrl,
469
+ diagnostics: {
470
+ modelChunkPayloadBytes: modelData.chunkPayloadBytes,
471
+ modelPollCount: modelData.pollCount,
472
+ modelStableForMs: modelData.stableForMs,
473
+ },
477
474
  };
478
475
  } finally {
479
476
  if (page) {
@@ -548,30 +545,6 @@ async function installDocsModelCapture(page) {
548
545
  }
549
546
  }
550
547
 
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
548
  /**
576
549
  * Extract a CID-to-image URL map from Google Docs editor HTML.
577
550
  *
@@ -599,6 +572,7 @@ export function extractCidUrlMapFromHtml(html) {
599
572
  * @param {Object<string,string>} [cidUrlMap={}] - CID-to-docs-images-rt URL map
600
573
  * @param {Object} [options] - Parse options
601
574
  * @param {Object} [options.log] - Optional verbose logger
575
+ * @param {string} [options.exportHtml] - Optional Google Docs export HTML used for list and quote semantics
602
576
  * @returns {{blocks: Array, tables: Array, images: Array, text: string}}
603
577
  */
604
578
  export function parseGoogleDocsModelChunks(
@@ -845,12 +819,17 @@ export function parseGoogleDocsModelChunks(
845
819
  }
846
820
  }
847
821
 
848
- return {
822
+ const capture = {
849
823
  blocks,
850
824
  tables,
851
825
  images,
852
826
  text: contentBlocksToText(blocks),
853
827
  };
828
+ if (options.exportHtml) {
829
+ applyGoogleDocsExportSemanticHints(capture, options.exportHtml);
830
+ capture.text = contentBlocksToText(capture.blocks);
831
+ }
832
+ return capture;
854
833
  }
855
834
 
856
835
  function cellIsEmpty(cell) {
@@ -921,13 +900,13 @@ function applyInlineTextStyle(inlineStyles, start, end, patch) {
921
900
 
922
901
  function textStyleFromModel(item) {
923
902
  const style = {};
924
- if (item.sm?.ts_bd) {
903
+ if (item.sm?.ts_bd && item.sm?.ts_bd_i !== true) {
925
904
  style.bold = true;
926
905
  }
927
- if (item.sm?.ts_it) {
906
+ if (item.sm?.ts_it && item.sm?.ts_it_i !== true) {
928
907
  style.italic = true;
929
908
  }
930
- if (item.sm?.ts_st) {
909
+ if (item.sm?.ts_st && item.sm?.ts_st_i !== true) {
931
910
  style.strike = true;
932
911
  }
933
912
  return style;
@@ -976,19 +955,136 @@ function paragraphMetaForEndPosition(styleMaps, endPos, text) {
976
955
  }
977
956
 
978
957
  function inferOrderedList(list, text) {
979
- // Google Docs does not expose imported DOCX list marker type in the public
980
- // model chunk for this document. Prefer ordered markers for list IDs and
981
- // item labels that are demonstrably numbered in the reference document.
982
- if (
983
- /ordered|^\d+(?:\.|\))|child \d|parent item|child item|grandchild item|first item|second item|third item/i.test(
984
- text
985
- )
986
- ) {
987
- return /^kix\.list\.(7|8|9|10|11|13)$/u.test(list.id);
988
- }
958
+ void list;
959
+ void text;
989
960
  return false;
990
961
  }
991
962
 
963
+ async function fetchGoogleDocsExportHtmlFromPage(page, documentId, log) {
964
+ const exportUrl = buildExportUrl(documentId, 'html');
965
+ try {
966
+ const result = await page.evaluate(async (url) => {
967
+ const response = await fetch(url, {
968
+ credentials: 'include',
969
+ redirect: 'follow',
970
+ });
971
+ return {
972
+ ok: response.ok,
973
+ status: response.status,
974
+ statusText: response.statusText,
975
+ contentType: response.headers.get('content-type'),
976
+ body: await response.text(),
977
+ };
978
+ }, exportUrl);
979
+ log?.debug?.(() => ({
980
+ event: 'gdocs.browser-model.export-hints.response',
981
+ documentId,
982
+ exportUrl,
983
+ status: result.status,
984
+ ok: result.ok,
985
+ contentType: result.contentType,
986
+ bytes: Buffer.byteLength(result.body || ''),
987
+ }));
988
+ return result.ok ? result.body || '' : null;
989
+ } catch (error) {
990
+ log?.debug?.(() => ({
991
+ event: 'gdocs.browser-model.export-hints.failed',
992
+ documentId,
993
+ exportUrl,
994
+ error: error?.message || String(error),
995
+ }));
996
+ return null;
997
+ }
998
+ }
999
+
1000
+ function applyGoogleDocsExportSemanticHints(capture, exportHtml) {
1001
+ const hints = extractGoogleDocsExportSemanticHints(exportHtml);
1002
+ let cursor = 0;
1003
+ for (const block of capture.blocks || []) {
1004
+ if (block.type !== 'paragraph') {
1005
+ continue;
1006
+ }
1007
+ const text = normalizeSemanticText(contentToText(block.content));
1008
+ if (!text) {
1009
+ continue;
1010
+ }
1011
+ const match = findNextSemanticHint(
1012
+ hints,
1013
+ text,
1014
+ cursor,
1015
+ Boolean(block.list)
1016
+ );
1017
+ if (!match) {
1018
+ continue;
1019
+ }
1020
+ cursor = match.index + 1;
1021
+ if (block.list && match.hint.listOrdered !== null) {
1022
+ block.list.ordered = match.hint.listOrdered;
1023
+ } else if (!block.list) {
1024
+ block.quote = match.hint.quote;
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ function findNextSemanticHint(hints, text, cursor, needsListHint) {
1030
+ for (let index = cursor; index < hints.length; index++) {
1031
+ const hint = hints[index];
1032
+ if (hint.text !== text) {
1033
+ continue;
1034
+ }
1035
+ if (needsListHint && hint.listOrdered === null) {
1036
+ continue;
1037
+ }
1038
+ if (!needsListHint && hint.listOrdered !== null) {
1039
+ continue;
1040
+ }
1041
+ return { index, hint };
1042
+ }
1043
+ return null;
1044
+ }
1045
+
1046
+ function extractGoogleDocsExportSemanticHints(exportHtml) {
1047
+ const preprocessed = preprocessGoogleDocsExportHtml(exportHtml).html;
1048
+ const $ = cheerio.load(preprocessed);
1049
+ const hints = [];
1050
+ $('body')
1051
+ .find('h1,h2,h3,h4,h5,h6,p,li')
1052
+ .each((_, element) => {
1053
+ const tag = element.tagName?.toLowerCase();
1054
+ const text = getExportElementSemanticText($, element, tag);
1055
+ if (!text) {
1056
+ return;
1057
+ }
1058
+ const parentList = $(element).parents('ol,ul').first();
1059
+ const listOrdered =
1060
+ tag === 'li' && parentList.length
1061
+ ? parentList[0].tagName?.toLowerCase() === 'ol'
1062
+ : null;
1063
+ hints.push({
1064
+ text,
1065
+ listOrdered,
1066
+ quote: tag !== 'li' && $(element).parents('blockquote').length > 0,
1067
+ });
1068
+ });
1069
+ return hints;
1070
+ }
1071
+
1072
+ function getExportElementSemanticText($, element, tag) {
1073
+ if (tag !== 'li') {
1074
+ return normalizeSemanticText($(element).text());
1075
+ }
1076
+ const clone = $(element).clone();
1077
+ clone.find('ol,ul').remove();
1078
+ return normalizeSemanticText(clone.text());
1079
+ }
1080
+
1081
+ function normalizeSemanticText(text) {
1082
+ return String(text || '')
1083
+ .replace(/\u00a0/g, ' ')
1084
+ .replace(/\s+/g, ' ')
1085
+ .trim();
1086
+ }
1087
+
992
1088
  function collectModelItems(chunks) {
993
1089
  const items = [];
994
1090
  for (const chunk of chunks || []) {
@@ -86,6 +86,33 @@ export const PUBLIC_TEST_DOCUMENT = {
86
86
  ],
87
87
  };
88
88
 
89
+ // The issue #108 v2 document extends the public reference fixture with Section
90
+ // 15, covering browser-model list type and continuation-paragraph regressions.
91
+ export const PUBLIC_TEST_DOCUMENT_V2 = {
92
+ id: '1Rvaod_u2wgkAUNdXG-e29yQem-P36KAQ',
93
+ canonicalUrl:
94
+ 'https://docs.google.com/document/d/1Rvaod_u2wgkAUNdXG-e29yQem-P36KAQ/edit',
95
+ };
96
+
97
+ function expectIssue108Markdown(markdown) {
98
+ expect(markdown).toContain(
99
+ '15. List & Blockquote Edge Cases (browser-mode reproducers)'
100
+ );
101
+ expect(markdown).toMatch(/1\.\s+Apple/u);
102
+ expect(markdown).toMatch(/2\.\s+Banana/u);
103
+ expect(markdown).toMatch(/3\.\s+Cherry/u);
104
+ expect(markdown).not.toMatch(/^-\s+Apple$/mu);
105
+ expect(markdown).toContain(
106
+ 'Continuation paragraph that is not a list item. Same indent as Step one.'
107
+ );
108
+ expect(markdown).not.toContain(
109
+ '> Continuation paragraph that is not a list item.'
110
+ );
111
+ expect(markdown).toMatch(/^-\s+Red$/mu);
112
+ expect(markdown).toMatch(/^-\s+Green$/mu);
113
+ expect(markdown).toMatch(/^-\s+Blue$/mu);
114
+ }
115
+
89
116
  describe('Google Docs public test document (issue #90)', () => {
90
117
  describe('URL variations', () => {
91
118
  it.each(PUBLIC_TEST_DOCUMENT.urlVariations)(
@@ -181,11 +208,19 @@ describe('Google Docs public test document (issue #90)', () => {
181
208
  baseDelay: 2000,
182
209
  });
183
210
 
211
+ const browserEngine =
212
+ process.env.GDOCS_BROWSER_ENGINE || process.env.BROWSER_ENGINE;
184
213
  const captureBrowserWithRetry = (url) =>
185
- retry(() => captureGoogleDocWithBrowser(url, { waitMs: 3000 }), {
186
- retries: 2,
187
- baseDelay: 2000,
188
- });
214
+ retry(
215
+ () =>
216
+ captureGoogleDocWithBrowser(url, {
217
+ ...(browserEngine ? { engine: browserEngine } : {}),
218
+ }),
219
+ {
220
+ retries: 2,
221
+ baseDelay: 2000,
222
+ }
223
+ );
189
224
 
190
225
  it('fetches the public document via --capture api and returns markdown', async () => {
191
226
  const { markdown, documentId, exportUrl } = await fetchWithRetry(
@@ -252,5 +287,29 @@ describe('Google Docs public test document (issue #90)', () => {
252
287
  expect(markdown).toContain('docs-images-rt/');
253
288
  expect(markdown).toContain('---');
254
289
  });
290
+
291
+ it('fetches the issue #108 v2 document via --capture api and preserves Section 15 semantics', async () => {
292
+ const { markdown, documentId, exportUrl } = await fetchWithRetry(
293
+ PUBLIC_TEST_DOCUMENT_V2.canonicalUrl
294
+ );
295
+
296
+ expect(documentId).toBe(PUBLIC_TEST_DOCUMENT_V2.id);
297
+ expect(exportUrl).toBe(
298
+ buildExportUrl(PUBLIC_TEST_DOCUMENT_V2.id, 'html')
299
+ );
300
+ expectIssue108Markdown(markdown);
301
+ });
302
+
303
+ it('captures the issue #108 v2 document via --capture browser and preserves Section 15 semantics', async () => {
304
+ const { markdown, documentId, exportUrl, capture } =
305
+ await captureBrowserWithRetry(PUBLIC_TEST_DOCUMENT_V2.canonicalUrl);
306
+
307
+ expect(documentId).toBe(PUBLIC_TEST_DOCUMENT_V2.id);
308
+ expect(exportUrl).toBe(buildEditUrl(PUBLIC_TEST_DOCUMENT_V2.id));
309
+ expect(capture.blocks.length).toBeGreaterThan(30);
310
+ expectIssue108Markdown(markdown);
311
+ expect(markdown).not.toContain('*Apple*');
312
+ expect(markdown).not.toContain('*Red*');
313
+ });
255
314
  });
256
315
  });
@@ -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,
@@ -430,7 +431,16 @@ describe('gdocs', () => {
430
431
  ],
431
432
  },
432
433
  ];
433
- const capture = parseGoogleDocsModelChunks(chunks);
434
+ const exportHtml = `
435
+ <html><body>
436
+ <ol>
437
+ <li>First item</li>
438
+ <li>Second item</li>
439
+ <li>Third item</li>
440
+ </ol>
441
+ </body></html>
442
+ `;
443
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
434
444
  const markdown = renderGoogleDocsCapture(capture, 'markdown');
435
445
  const lines = markdown.split('\n');
436
446
 
@@ -464,7 +474,15 @@ describe('gdocs', () => {
464
474
  ],
465
475
  },
466
476
  ];
467
- const capture = parseGoogleDocsModelChunks(chunks);
477
+ const exportHtml = `
478
+ <html><body>
479
+ <ol>
480
+ <li>First item</li>
481
+ <li>Second item</li>
482
+ </ol>
483
+ </body></html>
484
+ `;
485
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
468
486
  const markdown = renderGoogleDocsCapture(capture, 'markdown');
469
487
 
470
488
  expect(markdown).not.toMatch(/First item\n\n2\. Second item/u);
@@ -539,7 +557,10 @@ describe('gdocs', () => {
539
557
  ],
540
558
  },
541
559
  ];
542
- const capture = parseGoogleDocsModelChunks(chunks);
560
+ const exportHtml = `
561
+ <html><body><ol><li>Parent item 1<ol><li>Child item 1.1</li><li>Child item 1.2<ol><li>Grandchild item 1.2.1</li><li>Grandchild item 1.2.2</li></ol></li><li>Child item 1.3</li></ol></li><li>Parent item 2</li></ol></body></html>
562
+ `;
563
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
543
564
  const markdown = renderGoogleDocsCapture(capture, 'markdown');
544
565
 
545
566
  expect(markdown).toContain(
@@ -557,6 +578,91 @@ describe('gdocs', () => {
557
578
  expect(markdown).not.toContain('- Child item 1.1');
558
579
  });
559
580
 
581
+ it('uses export semantics for ambiguous browser-model lists and continuation paragraphs (issue #108)', () => {
582
+ const text = [
583
+ 'Apple',
584
+ 'Banana',
585
+ 'Cherry',
586
+ 'Step one',
587
+ 'Continuation paragraph that is not a list item. Same indent as Step one.',
588
+ 'Step two',
589
+ 'Red',
590
+ 'Green',
591
+ 'Blue',
592
+ '',
593
+ ].join('\n');
594
+ const lineEnd = (needle) => text.indexOf('\n', text.indexOf(needle)) + 1;
595
+ const listStyle = (needle, id) => ({
596
+ ty: 'as',
597
+ st: 'list',
598
+ si: lineEnd(needle),
599
+ ei: lineEnd(needle),
600
+ sm: { ls_id: id },
601
+ });
602
+ const paragraphStyle = (needle, indentStart, indentFirstLine) => ({
603
+ ty: 'as',
604
+ st: 'paragraph',
605
+ si: lineEnd(needle),
606
+ ei: lineEnd(needle),
607
+ sm: { ps_il: indentStart, ps_ifl: indentFirstLine },
608
+ });
609
+ const chunks = [
610
+ {
611
+ chunk: [
612
+ { ty: 'is', s: text },
613
+ listStyle('Apple', 'kix.ordered'),
614
+ listStyle('Banana', 'kix.ordered'),
615
+ listStyle('Cherry', 'kix.ordered'),
616
+ listStyle('Step one', 'kix.step-one'),
617
+ paragraphStyle(
618
+ 'Continuation paragraph that is not a list item',
619
+ 36,
620
+ 36
621
+ ),
622
+ listStyle('Step two', 'kix.step-two'),
623
+ listStyle('Red', 'kix.bullets'),
624
+ listStyle('Green', 'kix.bullets'),
625
+ listStyle('Blue', 'kix.bullets'),
626
+ {
627
+ ty: 'as',
628
+ st: 'text',
629
+ si: 1,
630
+ ei: text.length - 1,
631
+ sm: { ts_it: true, ts_it_i: true },
632
+ },
633
+ ],
634
+ },
635
+ ];
636
+ const exportHtml = `
637
+ <html><body>
638
+ <ol><li>Apple</li><li>Banana</li><li>Cherry</li></ol>
639
+ <ol><li>Step one</li></ol>
640
+ <p>Continuation paragraph that is not a list item. Same indent as Step one.</p>
641
+ <ol><li>Step two</li></ol>
642
+ <ul><li>Red</li><li>Green</li><li>Blue</li></ul>
643
+ </body></html>
644
+ `;
645
+ const capture = parseGoogleDocsModelChunks(chunks, {}, { exportHtml });
646
+ const markdown = renderGoogleDocsCapture(capture, 'markdown');
647
+ const html = renderGoogleDocsCapture(capture, 'html');
648
+
649
+ expect(markdown).toContain('1. Apple\n2. Banana\n3. Cherry');
650
+ expect(markdown).not.toContain('- Apple');
651
+ expect(markdown).toContain(
652
+ 'Continuation paragraph that is not a list item. Same indent as Step one.'
653
+ );
654
+ expect(markdown).not.toContain(
655
+ '> Continuation paragraph that is not a list item.'
656
+ );
657
+ expect(markdown).toContain('- Red\n- Green\n- Blue');
658
+ expect(markdown).not.toContain('*Apple*');
659
+ expect(html).toContain('<ol><li>Apple</li></ol>');
660
+ expect(html).toContain('<ul><li>Red</li></ul>');
661
+ expect(html).not.toContain(
662
+ '<blockquote>Continuation paragraph that is not a list item.'
663
+ );
664
+ });
665
+
560
666
  it('renders model style records for headings, inline formatting, links, lists, blockquotes, rules, and images', () => {
561
667
  const text = [
562
668
  'Title',
@@ -948,6 +1054,57 @@ describe('gdocs', () => {
948
1054
  });
949
1055
  });
950
1056
 
1057
+ describe('captureGoogleDocWithBrowser (issue #106)', () => {
1058
+ it('waits for DOCS_modelChunk data to stop changing before parsing', async () => {
1059
+ const browser = fakeBrowserReturningModelSequence([
1060
+ {
1061
+ chunks: [{ chunk: [{ ty: 'is', s: 'First paragraph\n' }] }],
1062
+ cidUrlMap: {},
1063
+ },
1064
+ {
1065
+ chunks: [{ chunk: [{ ty: 'is', s: 'First paragraph\n' }] }],
1066
+ cidUrlMap: {},
1067
+ },
1068
+ {
1069
+ chunks: [
1070
+ { chunk: [{ ty: 'is', s: 'First paragraph\n' }] },
1071
+ { chunk: [{ ty: 'is', s: 'Second paragraph\n' }] },
1072
+ ],
1073
+ cidUrlMap: {},
1074
+ },
1075
+ {
1076
+ chunks: [
1077
+ { chunk: [{ ty: 'is', s: 'First paragraph\n' }] },
1078
+ { chunk: [{ ty: 'is', s: 'Second paragraph\n' }] },
1079
+ ],
1080
+ cidUrlMap: {},
1081
+ },
1082
+ {
1083
+ chunks: [
1084
+ { chunk: [{ ty: 'is', s: 'First paragraph\n' }] },
1085
+ { chunk: [{ ty: 'is', s: 'Second paragraph\n' }] },
1086
+ ],
1087
+ cidUrlMap: {},
1088
+ },
1089
+ ]);
1090
+
1091
+ const result = await captureGoogleDocWithBrowser(
1092
+ 'https://docs.google.com/document/d/quiescence-doc/edit',
1093
+ {
1094
+ waitMs: 0,
1095
+ modelMaxWaitMs: 200,
1096
+ modelPollMs: 2,
1097
+ modelStabilityMs: 2,
1098
+ createBrowser: async () => browser,
1099
+ }
1100
+ );
1101
+
1102
+ expect(result.markdown).toContain('First paragraph');
1103
+ expect(result.markdown).toContain('Second paragraph');
1104
+ expect(browser.evaluateCalls).toBeGreaterThan(2);
1105
+ });
1106
+ });
1107
+
951
1108
  describe('GDOCS_EXPORT_FORMATS', () => {
952
1109
  it('contains all expected formats', () => {
953
1110
  expect(GDOCS_EXPORT_FORMATS).toEqual({
@@ -1303,3 +1460,31 @@ function fakeBrowserReturningModel(modelData) {
1303
1460
  async close() {},
1304
1461
  };
1305
1462
  }
1463
+
1464
+ function fakeBrowserReturningModelSequence(modelDataSequence) {
1465
+ let evaluateCalls = 0;
1466
+ return {
1467
+ get evaluateCalls() {
1468
+ return evaluateCalls;
1469
+ },
1470
+ async newPage() {
1471
+ return {
1472
+ async addInitScript() {},
1473
+ async setUserAgent() {},
1474
+ async setExtraHTTPHeaders() {},
1475
+ async setViewport() {},
1476
+ async goto() {},
1477
+ async waitForTimeout(waitMs) {
1478
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1479
+ },
1480
+ async evaluate() {
1481
+ const index = Math.min(evaluateCalls, modelDataSequence.length - 1);
1482
+ evaluateCalls += 1;
1483
+ return JSON.parse(JSON.stringify(modelDataSequence[index]));
1484
+ },
1485
+ async close() {},
1486
+ };
1487
+ },
1488
+ async close() {},
1489
+ };
1490
+ }