@link-assistant/web-capture 1.7.15 → 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 +12 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/gdocs-model-wait.js +186 -0
- package/src/gdocs-render-markdown.js +21 -3
- package/src/gdocs.js +36 -58
- package/tests/integration/gdocs-public-doc.test.js +1 -1
- package/tests/unit/gdocs.test.js +164 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 1.7.16
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- bf7eaec: Keep Google Docs browser-model soft line breaks outside inline mark tags and preserve image dimensions in HTML output.
|
|
14
|
+
|
|
3
15
|
## 1.7.15
|
|
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
|
+
}
|
|
@@ -138,9 +138,27 @@ function renderTextRunsMarkdown(nodes = []) {
|
|
|
138
138
|
italic: Boolean(node.italic),
|
|
139
139
|
strike: Boolean(node.strike),
|
|
140
140
|
};
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
const text = node.text || '';
|
|
142
|
+
let start = 0;
|
|
143
|
+
for (let idx = 0; idx < text.length; idx++) {
|
|
144
|
+
if (text[idx] !== '\n') {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (idx > start) {
|
|
148
|
+
output += markdownStyleTransition(active, next);
|
|
149
|
+
output += text.slice(start, idx);
|
|
150
|
+
active = next;
|
|
151
|
+
}
|
|
152
|
+
output += markdownStyleTransition(active, inactive);
|
|
153
|
+
output += '\n';
|
|
154
|
+
active = inactive;
|
|
155
|
+
start = idx + 1;
|
|
156
|
+
}
|
|
157
|
+
if (start < text.length) {
|
|
158
|
+
output += markdownStyleTransition(active, next);
|
|
159
|
+
output += text.slice(start);
|
|
160
|
+
active = next;
|
|
161
|
+
}
|
|
144
162
|
}
|
|
145
163
|
output += markdownStyleTransition(active, inactive);
|
|
146
164
|
return output;
|
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:
|
|
@@ -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
|
|
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,35 +425,10 @@ 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
|
});
|
|
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
|
*
|
|
@@ -1219,7 +1187,17 @@ function renderContentHtml(content = []) {
|
|
|
1219
1187
|
}
|
|
1220
1188
|
|
|
1221
1189
|
function renderMarkedHtml(node) {
|
|
1222
|
-
|
|
1190
|
+
return String(node.text || '')
|
|
1191
|
+
.split('\n')
|
|
1192
|
+
.map((segment) => renderMarkedHtmlSegment(node, segment))
|
|
1193
|
+
.join('<br>');
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function renderMarkedHtmlSegment(node, text) {
|
|
1197
|
+
if (!text) {
|
|
1198
|
+
return '';
|
|
1199
|
+
}
|
|
1200
|
+
let output = escapeHtml(text);
|
|
1223
1201
|
if (node.bold) {
|
|
1224
1202
|
output = `<strong>${output}</strong>`;
|
|
1225
1203
|
}
|
|
@@ -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
|
|
185
|
+
retry(() => captureGoogleDocWithBrowser(url), {
|
|
186
186
|
retries: 2,
|
|
187
187
|
baseDelay: 2000,
|
|
188
188
|
});
|
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,
|
|
@@ -26,10 +27,26 @@ import {
|
|
|
26
27
|
import fs from 'fs';
|
|
27
28
|
import os from 'os';
|
|
28
29
|
import path from 'path';
|
|
30
|
+
import { fileURLToPath } from 'url';
|
|
29
31
|
import nock from 'nock';
|
|
30
32
|
|
|
31
33
|
jest.setTimeout(30000);
|
|
32
34
|
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
36
|
+
const __dirname = path.dirname(__filename);
|
|
37
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
38
|
+
const ISSUE_104_FIXTURE_DIR = path.join(
|
|
39
|
+
REPO_ROOT,
|
|
40
|
+
'docs',
|
|
41
|
+
'case-studies',
|
|
42
|
+
'issue-104',
|
|
43
|
+
'fixtures'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function readIssue104Fixture(filename) {
|
|
47
|
+
return fs.readFileSync(path.join(ISSUE_104_FIXTURE_DIR, filename), 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
describe('gdocs', () => {
|
|
34
51
|
describe('isGoogleDocsUrl', () => {
|
|
35
52
|
it('returns true for standard Google Docs edit URL', () => {
|
|
@@ -648,6 +665,74 @@ describe('gdocs', () => {
|
|
|
648
665
|
);
|
|
649
666
|
});
|
|
650
667
|
|
|
668
|
+
it('renders soft breaks outside marks and preserves image dimensions (issue #104)', () => {
|
|
669
|
+
const fixture = JSON.parse(
|
|
670
|
+
readIssue104Fixture('multiline-marked-inline-image-model.json')
|
|
671
|
+
);
|
|
672
|
+
const capture = parseGoogleDocsModelChunks(
|
|
673
|
+
fixture.chunks,
|
|
674
|
+
fixture.cidUrlMap
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
expect(capture.blocks[0].content).toEqual([
|
|
678
|
+
expect.objectContaining({
|
|
679
|
+
type: 'text',
|
|
680
|
+
text: 'Line one of bold text.',
|
|
681
|
+
bold: true,
|
|
682
|
+
}),
|
|
683
|
+
expect.objectContaining({ type: 'text', text: '\n', bold: false }),
|
|
684
|
+
expect.objectContaining({
|
|
685
|
+
type: 'text',
|
|
686
|
+
text: 'Line two of bold text.',
|
|
687
|
+
bold: true,
|
|
688
|
+
}),
|
|
689
|
+
expect.objectContaining({
|
|
690
|
+
type: 'image',
|
|
691
|
+
alt: 'Inline diagram',
|
|
692
|
+
width: 320,
|
|
693
|
+
height: 180,
|
|
694
|
+
}),
|
|
695
|
+
expect.objectContaining({ type: 'text', text: '\n\n', bold: false }),
|
|
696
|
+
expect.objectContaining({
|
|
697
|
+
type: 'text',
|
|
698
|
+
text: 'Line three of bold text.',
|
|
699
|
+
bold: true,
|
|
700
|
+
}),
|
|
701
|
+
]);
|
|
702
|
+
expect(renderGoogleDocsCapture(capture, 'html')).toBe(
|
|
703
|
+
readIssue104Fixture(
|
|
704
|
+
'multiline-marked-inline-image.expected.html'
|
|
705
|
+
).trimEnd()
|
|
706
|
+
);
|
|
707
|
+
expect(renderGoogleDocsCapture(capture, 'markdown')).toBe(
|
|
708
|
+
readIssue104Fixture('multiline-marked-inline-image.expected.md')
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('does not keep inline marks open across embedded newlines (issue #104)', () => {
|
|
713
|
+
const capture = {
|
|
714
|
+
blocks: [
|
|
715
|
+
{
|
|
716
|
+
type: 'paragraph',
|
|
717
|
+
content: [
|
|
718
|
+
{
|
|
719
|
+
type: 'text',
|
|
720
|
+
text: 'Alpha\nBeta',
|
|
721
|
+
bold: true,
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
expect(renderGoogleDocsCapture(capture, 'html')).toBe(
|
|
729
|
+
'<!doctype html><html><body><p><strong>Alpha</strong><br><strong>Beta</strong></p></body></html>'
|
|
730
|
+
);
|
|
731
|
+
expect(renderGoogleDocsCapture(capture, 'markdown')).toBe(
|
|
732
|
+
'**Alpha**\n**Beta**\n'
|
|
733
|
+
);
|
|
734
|
+
});
|
|
735
|
+
|
|
651
736
|
it('keeps nested bold and italic markers balanced (issue #96)', () => {
|
|
652
737
|
const text = 'Bold text with italic inside and back to bold\n';
|
|
653
738
|
const chunks = [
|
|
@@ -864,6 +949,57 @@ describe('gdocs', () => {
|
|
|
864
949
|
});
|
|
865
950
|
});
|
|
866
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
|
+
|
|
867
1003
|
describe('GDOCS_EXPORT_FORMATS', () => {
|
|
868
1004
|
it('contains all expected formats', () => {
|
|
869
1005
|
expect(GDOCS_EXPORT_FORMATS).toEqual({
|
|
@@ -1219,3 +1355,31 @@ function fakeBrowserReturningModel(modelData) {
|
|
|
1219
1355
|
async close() {},
|
|
1220
1356
|
};
|
|
1221
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
|
+
}
|