@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 +12 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/gdocs-model-wait.js +186 -0
- package/src/gdocs.js +172 -76
- package/tests/integration/gdocs-public-doc.test.js +63 -4
- package/tests/unit/gdocs.test.js +188 -3
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
|
@@ -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
|
|
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
|
-
//
|
|
204
|
+
// Keep HTML markup escaped so literal examples such as `<ol>` do not
|
|
205
|
+
// become real tags before the HTML parser sees the document.
|
|
200
206
|
const content =
|
|
201
|
-
format === '
|
|
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
|
|
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
|
-
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
980
|
-
|
|
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(
|
|
186
|
-
|
|
187
|
-
|
|
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
|
});
|
package/tests/unit/gdocs.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|