@link-assistant/web-capture 1.7.9 → 1.7.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/bin/web-capture.js +51 -15
- package/package.json +1 -1
- package/src/gdocs-fallback.js +152 -0
- package/src/gdocs-images.js +130 -0
- package/src/gdocs-preprocess.js +105 -0
- package/src/gdocs.js +194 -23
- package/tests/unit/gdocs.test.js +317 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 71e7eb9: Fall back to the Google Docs export pipeline when browser capture cannot read editor model chunks, preserving archive content and embedded images.
|
|
8
|
+
- 7b4d2d2: Fix Rust CLI `--capture browser` silently routing through direct HTTP
|
|
9
|
+
fetches for non-Google-Docs URLs (issue #80):
|
|
10
|
+
- `rust/src/main.rs` now passes `--capture browser` through
|
|
11
|
+
`render_html` for markdown, archive, and html output formats so the
|
|
12
|
+
flag actually launches headless Chrome instead of calling
|
|
13
|
+
`fetch_html`.
|
|
14
|
+
- `rust/src/browser.rs` `capture_screenshot` is no longer a stub — it
|
|
15
|
+
launches headless Chrome with `--screenshot` and returns real PNG
|
|
16
|
+
bytes.
|
|
17
|
+
- Adds Rust integration tests that run a local HTTP fixture whose DOM
|
|
18
|
+
is mutated by JavaScript after load and verify the rendered HTML,
|
|
19
|
+
the CLI `--capture browser` markdown output, and the PNG signature
|
|
20
|
+
on the screenshot bytes.
|
|
21
|
+
|
|
22
|
+
No JS package behavior changes in this patch; the Rust CLI is the only
|
|
23
|
+
affected surface.
|
|
24
|
+
|
|
25
|
+
## 1.7.10
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- fcc031f: Fix remaining Google Docs capture gaps from issue #92 in both the JS and
|
|
30
|
+
Rust CLIs:
|
|
31
|
+
- Browser capture now keeps multi-column table rows intact, renders
|
|
32
|
+
ordered lists with sequential `1. 2. 3.` numbering, and joins same-list
|
|
33
|
+
items with a single newline so tight-list markdown matches the source
|
|
34
|
+
document.
|
|
35
|
+
- Archive mode downloads `docs-images-rt/...` image URLs into the
|
|
36
|
+
archive's `images/` directory and rewrites markdown/html references so
|
|
37
|
+
exports are self-contained.
|
|
38
|
+
- API mode (`--capture api`) runs the export HTML through a shared
|
|
39
|
+
preprocessor that hoists inline bold / italic / strikethrough spans to
|
|
40
|
+
semantic tags, strips Google Docs' heading-numbering spans and empty
|
|
41
|
+
anchor wrappers, unwraps `google.com/url?q=` redirects, and normalizes
|
|
42
|
+
non-breaking spaces.
|
|
43
|
+
|
|
3
44
|
## 1.7.9
|
|
4
45
|
|
|
5
46
|
### Patch Changes
|
package/bin/web-capture.js
CHANGED
|
@@ -459,7 +459,7 @@ async function captureUrl(url, options) {
|
|
|
459
459
|
fetchGoogleDoc,
|
|
460
460
|
fetchGoogleDocAsMarkdown,
|
|
461
461
|
fetchGoogleDocFromDocsApi,
|
|
462
|
-
|
|
462
|
+
captureGoogleDocWithBrowserOrFallback,
|
|
463
463
|
selectGoogleDocsCaptureMethod,
|
|
464
464
|
} = await import('../src/gdocs.js');
|
|
465
465
|
const { stripBase64Images } = await import('../src/extract-images.js');
|
|
@@ -511,24 +511,60 @@ async function captureUrl(url, options) {
|
|
|
511
511
|
// Screenshot/PDF/DOCX formats should use the regular browser pipeline below.
|
|
512
512
|
} else if (gdocsMethod === 'browser-model') {
|
|
513
513
|
try {
|
|
514
|
-
const result = await
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
514
|
+
const result = await captureGoogleDocWithBrowserOrFallback(
|
|
515
|
+
absoluteUrl,
|
|
516
|
+
{
|
|
517
|
+
engine,
|
|
518
|
+
apiToken,
|
|
519
|
+
format: normalizedFormat,
|
|
520
|
+
log,
|
|
521
|
+
onFallback: (err) => {
|
|
522
|
+
console.error(
|
|
523
|
+
`Warning: Google Docs browser capture could not read editor content (${err.message}); falling back to Google Docs export API.`
|
|
524
|
+
);
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
const resultMethod = result.fallback ? result.method : gdocsMethod;
|
|
529
|
+
const resultCapture = result.capture || {};
|
|
519
530
|
log.debug(() => ({
|
|
520
|
-
event:
|
|
531
|
+
event: result.fallback
|
|
532
|
+
? 'gdocs.capture.browser-model.fallback-rendered'
|
|
533
|
+
: 'gdocs.capture.browser-model.rendered',
|
|
521
534
|
documentId: result.documentId,
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
535
|
+
method: resultMethod,
|
|
536
|
+
blocks: resultCapture.blocks?.length || 0,
|
|
537
|
+
tables: resultCapture.tables?.length || 0,
|
|
538
|
+
images: resultCapture.images?.length || result.images?.length || 0,
|
|
525
539
|
markdownBytes: Buffer.byteLength(result.markdown || ''),
|
|
526
540
|
htmlBytes: Buffer.byteLength(result.html || ''),
|
|
527
541
|
textBytes: Buffer.byteLength(result.text || ''),
|
|
528
542
|
}));
|
|
529
543
|
if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
|
|
544
|
+
let archiveResult;
|
|
545
|
+
if (result.fallback) {
|
|
546
|
+
archiveResult = result;
|
|
547
|
+
} else {
|
|
548
|
+
const { localizeGoogleDocsModelImages } =
|
|
549
|
+
await import('../src/gdocs.js');
|
|
550
|
+
const localized = await localizeGoogleDocsModelImages(result, {
|
|
551
|
+
log,
|
|
552
|
+
});
|
|
553
|
+
log.debug(() => ({
|
|
554
|
+
event: 'gdocs.capture.browser-model.archive.localized',
|
|
555
|
+
images: localized.images.length,
|
|
556
|
+
markdownBytes: Buffer.byteLength(localized.markdown),
|
|
557
|
+
htmlBytes: Buffer.byteLength(localized.html),
|
|
558
|
+
}));
|
|
559
|
+
archiveResult = {
|
|
560
|
+
...result,
|
|
561
|
+
markdown: localized.markdown,
|
|
562
|
+
html: localized.html,
|
|
563
|
+
images: localized.images,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
530
566
|
await writeGoogleDocsArchive({
|
|
531
|
-
archiveResult
|
|
567
|
+
archiveResult,
|
|
532
568
|
absoluteUrl,
|
|
533
569
|
explicitOutput,
|
|
534
570
|
dataDir,
|
|
@@ -538,10 +574,10 @@ async function captureUrl(url, options) {
|
|
|
538
574
|
} else {
|
|
539
575
|
const rendered =
|
|
540
576
|
normalizedFormat === 'html'
|
|
541
|
-
? result.html
|
|
577
|
+
? result.html || result.content
|
|
542
578
|
: normalizedFormat === 'txt' || normalizedFormat === 'text'
|
|
543
|
-
? result.text
|
|
544
|
-
: result.markdown;
|
|
579
|
+
? result.text || result.content
|
|
580
|
+
: result.markdown || result.content;
|
|
545
581
|
const ext =
|
|
546
582
|
normalizedFormat === 'html'
|
|
547
583
|
? 'html'
|
|
@@ -554,7 +590,7 @@ async function captureUrl(url, options) {
|
|
|
554
590
|
ext,
|
|
555
591
|
explicitOutput,
|
|
556
592
|
dataDir,
|
|
557
|
-
label: `Google Doc (${
|
|
593
|
+
label: `Google Doc (${resultMethod})`,
|
|
558
594
|
});
|
|
559
595
|
}
|
|
560
596
|
return;
|
package/package.json
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Google Docs browser-model fallback helpers.
|
|
2
|
+
//
|
|
3
|
+
// When browser-model capture cannot read DOCS_modelChunk data from the editor
|
|
4
|
+
// page, fall back to the public export pipeline so that content and images
|
|
5
|
+
// are preserved (instead of returning an empty or lossy capture).
|
|
6
|
+
|
|
7
|
+
const GDOCS_BROWSER_MODEL_UNAVAILABLE = 'GDOCS_BROWSER_MODEL_UNAVAILABLE';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the sentinel error thrown when browser-model capture finds no chunks.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} message - Human-readable error message
|
|
13
|
+
* @returns {Error} Error carrying the sentinel code
|
|
14
|
+
*/
|
|
15
|
+
export function googleDocsBrowserModelUnavailableError(message) {
|
|
16
|
+
const err = new Error(message);
|
|
17
|
+
err.code = GDOCS_BROWSER_MODEL_UNAVAILABLE;
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether an error means Google Docs browser-model data was unavailable.
|
|
23
|
+
*
|
|
24
|
+
* @param {Error} err - Error to classify
|
|
25
|
+
* @returns {boolean} True if export fallback is appropriate
|
|
26
|
+
*/
|
|
27
|
+
export function isGoogleDocsBrowserModelUnavailableError(err) {
|
|
28
|
+
return (
|
|
29
|
+
err?.code === GDOCS_BROWSER_MODEL_UNAVAILABLE ||
|
|
30
|
+
String(err?.message || '').includes('did not expose DOCS_modelChunk data')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch a Google Doc through the public export pipeline for a requested output
|
|
36
|
+
* format. This is used as the lossless fallback when browser-model capture
|
|
37
|
+
* cannot read DOCS_modelChunk data from the editor page.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} deps - Injected dependencies from gdocs.js
|
|
40
|
+
* @param {string} url - Google Docs URL
|
|
41
|
+
* @param {Object} [options] - Options
|
|
42
|
+
* @returns {Promise<Object>} Export result normalized for CLI rendering
|
|
43
|
+
*/
|
|
44
|
+
export async function fetchGoogleDocByExportFormat(deps, url, options = {}) {
|
|
45
|
+
const { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive } =
|
|
46
|
+
deps;
|
|
47
|
+
const { format = 'markdown', apiToken, log } = options;
|
|
48
|
+
const normalized = (format || 'markdown').toLowerCase();
|
|
49
|
+
|
|
50
|
+
if (normalized === 'archive' || normalized === 'zip') {
|
|
51
|
+
const archiveResult = await fetchGoogleDocAsArchive(url, { apiToken, log });
|
|
52
|
+
return {
|
|
53
|
+
...archiveResult,
|
|
54
|
+
content: archiveResult.markdown,
|
|
55
|
+
sourceFormat: 'archive',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (normalized === 'markdown' || normalized === 'md') {
|
|
60
|
+
const result = await fetchGoogleDocAsMarkdown(url, { apiToken, log });
|
|
61
|
+
return {
|
|
62
|
+
...result,
|
|
63
|
+
content: result.markdown,
|
|
64
|
+
sourceFormat: 'markdown',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (normalized === 'html') {
|
|
69
|
+
const result = await fetchGoogleDoc(url, {
|
|
70
|
+
format: 'html',
|
|
71
|
+
apiToken,
|
|
72
|
+
log,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
...result,
|
|
76
|
+
html: result.content,
|
|
77
|
+
sourceFormat: 'html',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (normalized === 'txt' || normalized === 'text') {
|
|
82
|
+
const result = await fetchGoogleDoc(url, {
|
|
83
|
+
format: 'txt',
|
|
84
|
+
apiToken,
|
|
85
|
+
log,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
...result,
|
|
89
|
+
text: result.content,
|
|
90
|
+
sourceFormat: 'txt',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Unsupported Google Docs export fallback format "${format}".`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Capture a Google Doc through the browser model, falling back to public export
|
|
101
|
+
* when the editor does not expose model chunks.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} deps - Injected dependencies from gdocs.js
|
|
104
|
+
* @param {string} url - Google Docs URL
|
|
105
|
+
* @param {Object} [options] - Capture options
|
|
106
|
+
* @returns {Promise<Object>} Browser-model or public-export result
|
|
107
|
+
*/
|
|
108
|
+
export async function captureGoogleDocWithBrowserOrFallback(
|
|
109
|
+
deps,
|
|
110
|
+
url,
|
|
111
|
+
options = {}
|
|
112
|
+
) {
|
|
113
|
+
const { captureGoogleDocWithBrowser } = deps;
|
|
114
|
+
const {
|
|
115
|
+
format = 'markdown',
|
|
116
|
+
fallback = true,
|
|
117
|
+
onFallback,
|
|
118
|
+
...browserOptions
|
|
119
|
+
} = options;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const result = await captureGoogleDocWithBrowser(url, browserOptions);
|
|
123
|
+
return {
|
|
124
|
+
...result,
|
|
125
|
+
method: 'browser-model',
|
|
126
|
+
fallback: false,
|
|
127
|
+
};
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (!fallback || !isGoogleDocsBrowserModelUnavailableError(err)) {
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onFallback?.(err);
|
|
134
|
+
browserOptions.log?.warn?.(() => ({
|
|
135
|
+
event: 'gdocs.browser-model.fallback-public-export',
|
|
136
|
+
reason: err.message,
|
|
137
|
+
format,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const result = await fetchGoogleDocByExportFormat(deps, url, {
|
|
141
|
+
format,
|
|
142
|
+
apiToken: browserOptions.apiToken,
|
|
143
|
+
log: browserOptions.log,
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
...result,
|
|
147
|
+
method: 'public-export',
|
|
148
|
+
fallback: true,
|
|
149
|
+
browserError: err.message,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Google Docs editor-model image localization (issue #92 R5).
|
|
2
|
+
//
|
|
3
|
+
// The browser-model capture returns `docs-images-rt/…` URLs inside the
|
|
4
|
+
// markdown/html. Archive mode needs those images copied into `images/` so
|
|
5
|
+
// the archive is self-contained. This module downloads the images and
|
|
6
|
+
// rewrites the captured output accordingly.
|
|
7
|
+
|
|
8
|
+
import fetch from 'node-fetch';
|
|
9
|
+
import { URL } from 'url';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_USER_AGENT =
|
|
12
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
13
|
+
|
|
14
|
+
function getExtensionFromImageUrl(url) {
|
|
15
|
+
try {
|
|
16
|
+
const u = new URL(url);
|
|
17
|
+
const match = u.pathname.match(/\.(png|jpe?g|gif|webp|svg)$/iu);
|
|
18
|
+
if (match) {
|
|
19
|
+
const ext = match[1].toLowerCase();
|
|
20
|
+
return ext === 'jpeg' ? '.jpg' : `.${ext}`;
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// fall through to default
|
|
24
|
+
}
|
|
25
|
+
return '.png';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mimeTypeForExtension(filename) {
|
|
29
|
+
const dot = filename.lastIndexOf('.');
|
|
30
|
+
const ext = dot >= 0 ? filename.slice(dot + 1).toLowerCase() : 'png';
|
|
31
|
+
switch (ext) {
|
|
32
|
+
case 'jpg':
|
|
33
|
+
case 'jpeg':
|
|
34
|
+
return 'image/jpeg';
|
|
35
|
+
case 'gif':
|
|
36
|
+
return 'image/gif';
|
|
37
|
+
case 'webp':
|
|
38
|
+
return 'image/webp';
|
|
39
|
+
case 'svg':
|
|
40
|
+
return 'image/svg+xml';
|
|
41
|
+
default:
|
|
42
|
+
return 'image/png';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Download the `docs-images-rt` URLs captured from an editor-model capture.
|
|
48
|
+
*
|
|
49
|
+
* Produces `{ filename, data, mimeType }` entries compatible with
|
|
50
|
+
* `writeGoogleDocsArchive` and rewrites the markdown/html to reference the
|
|
51
|
+
* local `images/<filename>` paths.
|
|
52
|
+
*
|
|
53
|
+
* @param {{markdown: string, html: string, capture: Object}} modelResult
|
|
54
|
+
* @param {Object} [options] - Options
|
|
55
|
+
* @param {typeof fetch} [options.fetchImpl=fetch] - Injected fetch for tests
|
|
56
|
+
* @param {Object} [options.log] - Verbose logger
|
|
57
|
+
* @returns {Promise<{markdown: string, html: string, images: Array}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function localizeGoogleDocsModelImages(modelResult, options = {}) {
|
|
60
|
+
const { fetchImpl = fetch, log } = options;
|
|
61
|
+
const images = [];
|
|
62
|
+
const seen = new Map();
|
|
63
|
+
let nextIndex = 1;
|
|
64
|
+
|
|
65
|
+
const resolveFilename = (url, existingAlt) => {
|
|
66
|
+
if (seen.has(url)) {
|
|
67
|
+
return seen.get(url);
|
|
68
|
+
}
|
|
69
|
+
const ext = getExtensionFromImageUrl(url);
|
|
70
|
+
const filename = `image-${String(nextIndex).padStart(2, '0')}${ext}`;
|
|
71
|
+
nextIndex += 1;
|
|
72
|
+
seen.set(url, filename);
|
|
73
|
+
log?.debug?.(() => ({
|
|
74
|
+
event: 'gdocs.archive.image.scheduled',
|
|
75
|
+
url,
|
|
76
|
+
filename,
|
|
77
|
+
alt: existingAlt || null,
|
|
78
|
+
}));
|
|
79
|
+
return filename;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const candidates = (modelResult?.capture?.images || []).filter(
|
|
83
|
+
(img) => img?.url
|
|
84
|
+
);
|
|
85
|
+
for (const img of candidates) {
|
|
86
|
+
const filename = resolveFilename(img.url, img.alt);
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetchImpl(img.url, {
|
|
89
|
+
headers: {
|
|
90
|
+
'User-Agent': DEFAULT_USER_AGENT,
|
|
91
|
+
Accept: 'image/*,*/*;q=0.8',
|
|
92
|
+
},
|
|
93
|
+
redirect: 'follow',
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`HTTP ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
99
|
+
const data = Buffer.from(arrayBuffer);
|
|
100
|
+
const mimeType =
|
|
101
|
+
response.headers?.get?.('content-type') ||
|
|
102
|
+
mimeTypeForExtension(filename);
|
|
103
|
+
images.push({ filename, data, mimeType });
|
|
104
|
+
log?.debug?.(() => ({
|
|
105
|
+
event: 'gdocs.archive.image.downloaded',
|
|
106
|
+
url: img.url,
|
|
107
|
+
filename,
|
|
108
|
+
bytes: data.length,
|
|
109
|
+
mimeType,
|
|
110
|
+
}));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log?.debug?.(() => ({
|
|
113
|
+
event: 'gdocs.archive.image.failed',
|
|
114
|
+
url: img.url,
|
|
115
|
+
filename,
|
|
116
|
+
error: err?.message || String(err),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let markdown = modelResult?.markdown || '';
|
|
122
|
+
let html = modelResult?.html || '';
|
|
123
|
+
for (const [url, filename] of seen.entries()) {
|
|
124
|
+
const localPath = `images/${filename}`;
|
|
125
|
+
markdown = markdown.split(url).join(localPath);
|
|
126
|
+
html = html.split(url).join(localPath);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { markdown, html, images };
|
|
130
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Google Docs export HTML preprocessor (issue #92 R6).
|
|
2
|
+
//
|
|
3
|
+
// Google Drive's `/export?format=html` payload serves bold/italic/
|
|
4
|
+
// strikethrough as inline `style` spans, numbers headings with
|
|
5
|
+
// `<a id><span>1. </span>`, and routes every link through a
|
|
6
|
+
// `google.com/url?q=` redirect. The generic Cheerio + Turndown pipeline
|
|
7
|
+
// discards those signals, so the browser-less API path loses the
|
|
8
|
+
// formatting and gains noisy redirect URLs.
|
|
9
|
+
//
|
|
10
|
+
// This module rewrites the export HTML into semantic markup before the
|
|
11
|
+
// generic converter runs.
|
|
12
|
+
|
|
13
|
+
import * as cheerio from 'cheerio';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pre-process Google Docs export HTML so the generic Cheerio+Turndown
|
|
17
|
+
* pipeline preserves inline formatting, heading numbers, and link targets.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} html - Raw export HTML returned by Google Docs.
|
|
20
|
+
* @returns {{html: string, hoisted: number, unwrappedLinks: number}}
|
|
21
|
+
*/
|
|
22
|
+
export function preprocessGoogleDocsExportHtml(html) {
|
|
23
|
+
if (!html || typeof html !== 'string') {
|
|
24
|
+
return { html: html || '', hoisted: 0, unwrappedLinks: 0 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const $ = cheerio.load(html, { decodeEntities: false });
|
|
28
|
+
let hoisted = 0;
|
|
29
|
+
let unwrappedLinks = 0;
|
|
30
|
+
|
|
31
|
+
$('span[style], p[style]').each(function () {
|
|
32
|
+
const style = String($(this).attr('style') || '');
|
|
33
|
+
const hasBold = /font-weight\s*:\s*(?:bold|[6-9]\d{2})/i.test(style);
|
|
34
|
+
const hasItalic = /font-style\s*:\s*italic/i.test(style);
|
|
35
|
+
const hasStrike = /text-decoration[^;]*\bline-through\b/i.test(style);
|
|
36
|
+
if (!hasBold && !hasItalic && !hasStrike) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const inner = $(this).html() || '';
|
|
40
|
+
let wrapped = inner;
|
|
41
|
+
if (hasStrike) {
|
|
42
|
+
wrapped = `<del>${wrapped}</del>`;
|
|
43
|
+
}
|
|
44
|
+
if (hasItalic) {
|
|
45
|
+
wrapped = `<em>${wrapped}</em>`;
|
|
46
|
+
}
|
|
47
|
+
if (hasBold) {
|
|
48
|
+
wrapped = `<strong>${wrapped}</strong>`;
|
|
49
|
+
}
|
|
50
|
+
$(this).replaceWith(wrapped);
|
|
51
|
+
hoisted += 1;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
$('h1, h2, h3, h4, h5, h6').each(function () {
|
|
55
|
+
const $h = $(this);
|
|
56
|
+
$h.find('a').each(function () {
|
|
57
|
+
const $a = $(this);
|
|
58
|
+
if (!$a.attr('href') && !$a.text().trim() && !$a.find('img').length) {
|
|
59
|
+
$a.remove();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
$h.find('> span, > strong > span').each(function () {
|
|
63
|
+
const $span = $(this);
|
|
64
|
+
const text = $span.text();
|
|
65
|
+
if (/^\s*\d+(?:\.\d+)*\.?\s*$/.test(text)) {
|
|
66
|
+
$span.remove();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
$('a[href*="google.com/url?q="]').each(function () {
|
|
72
|
+
const href = String($(this).attr('href') || '');
|
|
73
|
+
const match = href.match(/[?&]q=([^&]+)/);
|
|
74
|
+
if (match) {
|
|
75
|
+
try {
|
|
76
|
+
const decoded = decodeURIComponent(match[1]);
|
|
77
|
+
$(this).attr('href', decoded);
|
|
78
|
+
unwrappedLinks += 1;
|
|
79
|
+
} catch {
|
|
80
|
+
// Leave the original href in place if it can't be decoded.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
$('*').each(function () {
|
|
86
|
+
if (this.type !== 'tag') {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
$(this)
|
|
90
|
+
.contents()
|
|
91
|
+
.each(function () {
|
|
92
|
+
if (this.type === 'text' && this.data) {
|
|
93
|
+
const replaced = this.data.replace(/\u00A0/g, ' ');
|
|
94
|
+
if (replaced !== this.data) {
|
|
95
|
+
this.data = replaced;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let rewritten = $.html();
|
|
102
|
+
rewritten = rewritten.replace(/ /g, ' ');
|
|
103
|
+
|
|
104
|
+
return { html: rewritten, hoisted, unwrappedLinks };
|
|
105
|
+
}
|
package/src/gdocs.js
CHANGED
|
@@ -11,6 +11,20 @@ import fetch from 'node-fetch';
|
|
|
11
11
|
import he from 'he';
|
|
12
12
|
import { convertHtmlToMarkdown } from './lib.js';
|
|
13
13
|
import { createBrowser as defaultCreateBrowser } from './browser.js';
|
|
14
|
+
import { preprocessGoogleDocsExportHtml } from './gdocs-preprocess.js';
|
|
15
|
+
import { localizeGoogleDocsModelImages } from './gdocs-images.js';
|
|
16
|
+
import {
|
|
17
|
+
googleDocsBrowserModelUnavailableError,
|
|
18
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
19
|
+
fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
|
|
20
|
+
captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
|
|
21
|
+
} from './gdocs-fallback.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
preprocessGoogleDocsExportHtml,
|
|
25
|
+
localizeGoogleDocsModelImages,
|
|
26
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
27
|
+
};
|
|
14
28
|
|
|
15
29
|
const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
|
|
16
30
|
|
|
@@ -283,7 +297,14 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
|
|
|
283
297
|
log,
|
|
284
298
|
});
|
|
285
299
|
|
|
286
|
-
const
|
|
300
|
+
const preprocessed = preprocessGoogleDocsExportHtml(result.content);
|
|
301
|
+
log?.debug?.(() => ({
|
|
302
|
+
event: 'gdocs.export.style-hoist',
|
|
303
|
+
documentId: result.documentId,
|
|
304
|
+
hoisted: preprocessed.hoisted,
|
|
305
|
+
unwrappedLinks: preprocessed.unwrappedLinks,
|
|
306
|
+
}));
|
|
307
|
+
const markdown = convertHtmlToMarkdown(preprocessed.html, result.exportUrl);
|
|
287
308
|
log?.debug?.(() => ({
|
|
288
309
|
event: 'gdocs.public-export.markdown',
|
|
289
310
|
documentId: result.documentId,
|
|
@@ -297,6 +318,43 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
|
|
|
297
318
|
};
|
|
298
319
|
}
|
|
299
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Fetch a Google Doc through the public export pipeline for a requested output
|
|
323
|
+
* format. Thin wrapper over the fallback module that injects sibling fetchers.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} url - Google Docs URL
|
|
326
|
+
* @param {Object} [options] - Options
|
|
327
|
+
* @returns {Promise<Object>} Export result normalized for CLI rendering
|
|
328
|
+
*/
|
|
329
|
+
export function fetchGoogleDocByExportFormat(url, options = {}) {
|
|
330
|
+
return fetchGoogleDocByExportFormatImpl(
|
|
331
|
+
{ fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive },
|
|
332
|
+
url,
|
|
333
|
+
options
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Capture a Google Doc through the browser model, falling back to public export
|
|
339
|
+
* when the editor does not expose model chunks. Thin wrapper.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} url - Google Docs URL
|
|
342
|
+
* @param {Object} [options] - Capture options
|
|
343
|
+
* @returns {Promise<Object>} Browser-model or public-export result
|
|
344
|
+
*/
|
|
345
|
+
export function captureGoogleDocWithBrowserOrFallback(url, options = {}) {
|
|
346
|
+
return captureGoogleDocWithBrowserOrFallbackImpl(
|
|
347
|
+
{
|
|
348
|
+
captureGoogleDocWithBrowser,
|
|
349
|
+
fetchGoogleDoc,
|
|
350
|
+
fetchGoogleDocAsMarkdown,
|
|
351
|
+
fetchGoogleDocAsArchive,
|
|
352
|
+
},
|
|
353
|
+
url,
|
|
354
|
+
options
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
300
358
|
/**
|
|
301
359
|
* Capture a Google Doc from the editor page model (`DOCS_modelChunk`).
|
|
302
360
|
*
|
|
@@ -384,7 +442,8 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
|
|
|
384
442
|
|
|
385
443
|
const capture = parseGoogleDocsModelChunks(
|
|
386
444
|
modelData.chunks,
|
|
387
|
-
modelData.cidUrlMap
|
|
445
|
+
modelData.cidUrlMap,
|
|
446
|
+
{ log }
|
|
388
447
|
);
|
|
389
448
|
log?.debug?.(() => ({
|
|
390
449
|
event: 'gdocs.browser-model.parsed',
|
|
@@ -397,7 +456,7 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
|
|
|
397
456
|
textBytes: Buffer.byteLength(capture.text || ''),
|
|
398
457
|
}));
|
|
399
458
|
if (capture.blocks.length === 0) {
|
|
400
|
-
throw
|
|
459
|
+
throw googleDocsBrowserModelUnavailableError(
|
|
401
460
|
'Google Docs editor page did not expose DOCS_modelChunk data'
|
|
402
461
|
);
|
|
403
462
|
}
|
|
@@ -531,9 +590,16 @@ export function extractCidUrlMapFromHtml(html) {
|
|
|
531
590
|
*
|
|
532
591
|
* @param {Array} chunks - Captured DOCS_modelChunk values
|
|
533
592
|
* @param {Object<string,string>} [cidUrlMap={}] - CID-to-docs-images-rt URL map
|
|
593
|
+
* @param {Object} [options] - Parse options
|
|
594
|
+
* @param {Object} [options.log] - Optional verbose logger
|
|
534
595
|
* @returns {{blocks: Array, tables: Array, images: Array, text: string}}
|
|
535
596
|
*/
|
|
536
|
-
export function parseGoogleDocsModelChunks(
|
|
597
|
+
export function parseGoogleDocsModelChunks(
|
|
598
|
+
chunks = [],
|
|
599
|
+
cidUrlMap = {},
|
|
600
|
+
options = {}
|
|
601
|
+
) {
|
|
602
|
+
const { log } = options;
|
|
537
603
|
const items = collectModelItems(chunks);
|
|
538
604
|
const fullText = items
|
|
539
605
|
.filter((item) => item.ty === 'is' || item.ty === 'iss')
|
|
@@ -581,6 +647,16 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
581
647
|
let table = null;
|
|
582
648
|
let row = null;
|
|
583
649
|
let cell = null;
|
|
650
|
+
const tableHistograms = [];
|
|
651
|
+
let currentHistogram = null;
|
|
652
|
+
|
|
653
|
+
const bumpHistogram = (code) => {
|
|
654
|
+
if (!currentHistogram) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const key = `0x${code.toString(16).padStart(2, '0')}`;
|
|
658
|
+
currentHistogram[key] = (currentHistogram[key] || 0) + 1;
|
|
659
|
+
};
|
|
584
660
|
|
|
585
661
|
const flushParagraph = (endPos = null) => {
|
|
586
662
|
const text = contentToText(paragraph).trim();
|
|
@@ -594,15 +670,18 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
594
670
|
paragraph = [];
|
|
595
671
|
};
|
|
596
672
|
|
|
597
|
-
const flushCell = () => {
|
|
673
|
+
const flushCell = ({ dropEmpty = false } = {}) => {
|
|
598
674
|
if (cell && row) {
|
|
599
|
-
|
|
675
|
+
const isEmpty = cell.content.length === 0;
|
|
676
|
+
if (!dropEmpty || !isEmpty) {
|
|
677
|
+
row.cells.push(cell);
|
|
678
|
+
}
|
|
600
679
|
}
|
|
601
680
|
cell = null;
|
|
602
681
|
};
|
|
603
682
|
|
|
604
|
-
const flushRow = () => {
|
|
605
|
-
flushCell();
|
|
683
|
+
const flushRow = ({ dropEmptyTrailingCell = false } = {}) => {
|
|
684
|
+
flushCell({ dropEmpty: dropEmptyTrailingCell });
|
|
606
685
|
if (row && table) {
|
|
607
686
|
table.rows.push(row);
|
|
608
687
|
}
|
|
@@ -610,11 +689,23 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
610
689
|
};
|
|
611
690
|
|
|
612
691
|
const flushTable = () => {
|
|
613
|
-
flushRow();
|
|
692
|
+
flushRow({ dropEmptyTrailingCell: true });
|
|
614
693
|
if (table) {
|
|
694
|
+
// Drop trailing empty rows that Google Docs sometimes emits after the
|
|
695
|
+
// last cell content (from `\n` just before the `0x11` close marker).
|
|
696
|
+
while (
|
|
697
|
+
table.rows.length > 1 &&
|
|
698
|
+
table.rows[table.rows.length - 1].cells.length === 0
|
|
699
|
+
) {
|
|
700
|
+
table.rows.pop();
|
|
701
|
+
}
|
|
615
702
|
tables.push(table);
|
|
616
703
|
blocks.push(table);
|
|
617
704
|
}
|
|
705
|
+
if (currentHistogram) {
|
|
706
|
+
tableHistograms.push(currentHistogram);
|
|
707
|
+
}
|
|
708
|
+
currentHistogram = null;
|
|
618
709
|
table = null;
|
|
619
710
|
};
|
|
620
711
|
|
|
@@ -636,18 +727,29 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
636
727
|
if (code === 0x10) {
|
|
637
728
|
flushParagraph(i + 1);
|
|
638
729
|
table = { type: 'table', rows: [] };
|
|
730
|
+
currentHistogram = {
|
|
731
|
+
'0x0a': 0,
|
|
732
|
+
'0x0b': 0,
|
|
733
|
+
'0x10': 1,
|
|
734
|
+
'0x11': 0,
|
|
735
|
+
'0x12': 0,
|
|
736
|
+
'0x1c': 0,
|
|
737
|
+
};
|
|
639
738
|
continue;
|
|
640
739
|
}
|
|
641
740
|
if (code === 0x11) {
|
|
741
|
+
bumpHistogram(code);
|
|
642
742
|
flushTable();
|
|
643
743
|
continue;
|
|
644
744
|
}
|
|
645
745
|
if (code === 0x12) {
|
|
646
|
-
|
|
746
|
+
bumpHistogram(code);
|
|
747
|
+
flushRow({ dropEmptyTrailingCell: true });
|
|
647
748
|
row = { cells: [] };
|
|
648
749
|
continue;
|
|
649
750
|
}
|
|
650
751
|
if (code === 0x1c) {
|
|
752
|
+
bumpHistogram(code);
|
|
651
753
|
flushCell();
|
|
652
754
|
if (!row) {
|
|
653
755
|
row = { cells: [] };
|
|
@@ -656,14 +758,24 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
656
758
|
continue;
|
|
657
759
|
}
|
|
658
760
|
if (code === 0x0a) {
|
|
761
|
+
bumpHistogram(code);
|
|
659
762
|
if (table) {
|
|
660
|
-
|
|
763
|
+
// Inside a Google Docs table, `\n` (0x0a) separates CELLS, not rows.
|
|
764
|
+
// Row boundaries are communicated via the explicit `0x12` marker and
|
|
765
|
+
// the closing `0x11` marker. Earlier versions collapsed multi-column
|
|
766
|
+
// tables to one column because they flushed the row on `\n`.
|
|
767
|
+
flushCell();
|
|
768
|
+
if (!row) {
|
|
769
|
+
row = { cells: [] };
|
|
770
|
+
}
|
|
771
|
+
cell = { content: [] };
|
|
661
772
|
} else {
|
|
662
773
|
flushParagraph(i + 1);
|
|
663
774
|
}
|
|
664
775
|
continue;
|
|
665
776
|
}
|
|
666
777
|
if (code === 0x0b) {
|
|
778
|
+
bumpHistogram(code);
|
|
667
779
|
appendText(targetContent(), '\n');
|
|
668
780
|
continue;
|
|
669
781
|
}
|
|
@@ -684,6 +796,19 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
684
796
|
}
|
|
685
797
|
flushParagraph(fullText.length);
|
|
686
798
|
|
|
799
|
+
if (log?.debug && tableHistograms.length > 0) {
|
|
800
|
+
for (let t = 0; t < tableHistograms.length; t++) {
|
|
801
|
+
const tbl = tables[t];
|
|
802
|
+
log.debug(() => ({
|
|
803
|
+
event: 'gdocs.table.histogram',
|
|
804
|
+
tableIndex: t,
|
|
805
|
+
rows: tbl?.rows.length || 0,
|
|
806
|
+
columns: Math.max(...(tbl?.rows || []).map((r) => r.cells.length), 0),
|
|
807
|
+
controls: tableHistograms[t],
|
|
808
|
+
}));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
687
812
|
return {
|
|
688
813
|
blocks,
|
|
689
814
|
tables,
|
|
@@ -981,19 +1106,56 @@ function sameTextStyle(a, b) {
|
|
|
981
1106
|
}
|
|
982
1107
|
|
|
983
1108
|
function renderBlocksMarkdown(blocks) {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1109
|
+
const counters = new Map();
|
|
1110
|
+
const rendered = blocks.map((block) => {
|
|
1111
|
+
if (block.type === 'table') {
|
|
1112
|
+
counters.clear();
|
|
1113
|
+
return { text: renderTableMarkdown(block), list: null };
|
|
1114
|
+
}
|
|
1115
|
+
const counterKey = block.list
|
|
1116
|
+
? `${block.list.id || ''}\u0000${block.list.level || 0}`
|
|
1117
|
+
: null;
|
|
1118
|
+
if (block.list) {
|
|
1119
|
+
// Reset deeper siblings when we re-enter a shallower level so
|
|
1120
|
+
// nested lists restart from 1 on each new parent.
|
|
1121
|
+
for (const key of [...counters.keys()]) {
|
|
1122
|
+
const level = Number(key.split('\u0000')[1]);
|
|
1123
|
+
if (level > (block.list.level || 0)) {
|
|
1124
|
+
counters.delete(key);
|
|
1125
|
+
}
|
|
988
1126
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1127
|
+
const next = (counters.get(counterKey) || 0) + 1;
|
|
1128
|
+
counters.set(counterKey, next);
|
|
1129
|
+
return {
|
|
1130
|
+
text: renderParagraphMarkdown(block, { orderedIndex: next }),
|
|
1131
|
+
list: block.list,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
counters.clear();
|
|
1135
|
+
return { text: renderParagraphMarkdown(block), list: null };
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
const joined = [];
|
|
1139
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
1140
|
+
const cur = rendered[i];
|
|
1141
|
+
if (!cur.text) {
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
if (joined.length > 0) {
|
|
1145
|
+
const prev = rendered
|
|
1146
|
+
.slice(0, i)
|
|
1147
|
+
.reverse()
|
|
1148
|
+
.find((entry) => entry.text);
|
|
1149
|
+
const sameList =
|
|
1150
|
+
cur.list && prev?.list && (cur.list.id || '') === (prev.list.id || '');
|
|
1151
|
+
joined.push(sameList ? '\n' : '\n\n');
|
|
1152
|
+
}
|
|
1153
|
+
joined.push(cur.text);
|
|
1154
|
+
}
|
|
1155
|
+
return joined.join('').replace(/\s+$/u, '');
|
|
994
1156
|
}
|
|
995
1157
|
|
|
996
|
-
function renderParagraphMarkdown(block) {
|
|
1158
|
+
function renderParagraphMarkdown(block, context = {}) {
|
|
997
1159
|
const text = renderContentMarkdown(block.content).trim();
|
|
998
1160
|
if (block.horizontalRule) {
|
|
999
1161
|
return '---';
|
|
@@ -1011,7 +1173,8 @@ function renderParagraphMarkdown(block) {
|
|
|
1011
1173
|
}
|
|
1012
1174
|
if (block.list) {
|
|
1013
1175
|
const indent = ' '.repeat(Math.max(0, block.list.level || 0));
|
|
1014
|
-
const
|
|
1176
|
+
const orderedIndex = Math.max(1, Number(context.orderedIndex) || 1);
|
|
1177
|
+
const marker = block.list.ordered ? `${orderedIndex}.` : '-';
|
|
1015
1178
|
return `${indent}${marker} ${text}`;
|
|
1016
1179
|
}
|
|
1017
1180
|
if (block.quote) {
|
|
@@ -1277,8 +1440,16 @@ export async function fetchGoogleDocAsArchive(url, options = {}) {
|
|
|
1277
1440
|
// Fetch HTML with embedded base64 images
|
|
1278
1441
|
const result = await fetchGoogleDoc(url, { format: 'html', apiToken, log });
|
|
1279
1442
|
|
|
1443
|
+
const preprocessed = preprocessGoogleDocsExportHtml(result.content);
|
|
1444
|
+
log?.debug?.(() => ({
|
|
1445
|
+
event: 'gdocs.export.style-hoist',
|
|
1446
|
+
documentId: result.documentId,
|
|
1447
|
+
hoisted: preprocessed.hoisted,
|
|
1448
|
+
unwrappedLinks: preprocessed.unwrappedLinks,
|
|
1449
|
+
}));
|
|
1450
|
+
|
|
1280
1451
|
// Extract base64 images from HTML
|
|
1281
|
-
const { html: localHtml, images } = extractBase64Images(
|
|
1452
|
+
const { html: localHtml, images } = extractBase64Images(preprocessed.html);
|
|
1282
1453
|
|
|
1283
1454
|
// Convert the localized HTML to Markdown
|
|
1284
1455
|
const markdown = convertHtmlToMarkdown(localHtml);
|
package/tests/unit/gdocs.test.js
CHANGED
|
@@ -6,10 +6,14 @@ import {
|
|
|
6
6
|
buildEditUrl,
|
|
7
7
|
buildDocsApiUrl,
|
|
8
8
|
extractBase64Images,
|
|
9
|
+
captureGoogleDocWithBrowserOrFallback,
|
|
10
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
9
11
|
parseGoogleDocsModelChunks,
|
|
10
12
|
renderGoogleDocsCapture,
|
|
11
13
|
renderDocsApiDocument,
|
|
12
14
|
selectGoogleDocsCaptureMethod,
|
|
15
|
+
localizeGoogleDocsModelImages,
|
|
16
|
+
preprocessGoogleDocsExportHtml,
|
|
13
17
|
GDOCS_EXPORT_FORMATS,
|
|
14
18
|
} from '../../src/gdocs.js';
|
|
15
19
|
import {
|
|
@@ -20,6 +24,7 @@ import {
|
|
|
20
24
|
import fs from 'fs';
|
|
21
25
|
import os from 'os';
|
|
22
26
|
import path from 'path';
|
|
27
|
+
import nock from 'nock';
|
|
23
28
|
|
|
24
29
|
jest.setTimeout(30000);
|
|
25
30
|
|
|
@@ -250,6 +255,115 @@ describe('gdocs', () => {
|
|
|
250
255
|
);
|
|
251
256
|
});
|
|
252
257
|
|
|
258
|
+
it('parses multi-column tables when rows are separated by 0x0a (issue #92 R2)', () => {
|
|
259
|
+
// Google Docs sometimes emits cell boundaries as `\n` (0x0a) alongside
|
|
260
|
+
// the 0x12 new-row marker. The parser must keep all cells on the same
|
|
261
|
+
// row rather than collapsing the table to a single column.
|
|
262
|
+
const chunks = [
|
|
263
|
+
{
|
|
264
|
+
chunk: [
|
|
265
|
+
{
|
|
266
|
+
ty: 'is',
|
|
267
|
+
s: `${
|
|
268
|
+
String.fromCharCode(0x10) + String.fromCharCode(0x12)
|
|
269
|
+
}A\nB\nC\n${String.fromCharCode(
|
|
270
|
+
0x12
|
|
271
|
+
)}D\nE\nF\n${String.fromCharCode(0x11)}`,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
const capture = parseGoogleDocsModelChunks(chunks);
|
|
277
|
+
|
|
278
|
+
expect(capture.tables).toHaveLength(1);
|
|
279
|
+
expect(capture.tables[0].rows).toHaveLength(2);
|
|
280
|
+
expect(capture.tables[0].rows[0].cells).toHaveLength(3);
|
|
281
|
+
expect(capture.tables[0].rows[1].cells).toHaveLength(3);
|
|
282
|
+
|
|
283
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
284
|
+
expect(markdown).toContain('| A | B | C |');
|
|
285
|
+
expect(markdown).toContain('| D | E | F |');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('numbers ordered list items sequentially (issue #92 R3)', () => {
|
|
289
|
+
// Three list items sharing the same list id should render as 1. 2. 3.
|
|
290
|
+
const text = 'First item\nSecond item\nThird item\n';
|
|
291
|
+
const endOfLine = (n) => {
|
|
292
|
+
let idx = -1;
|
|
293
|
+
for (let k = 0; k < n; k++) {
|
|
294
|
+
idx = text.indexOf('\n', idx + 1);
|
|
295
|
+
}
|
|
296
|
+
return idx + 1;
|
|
297
|
+
};
|
|
298
|
+
const chunks = [
|
|
299
|
+
{
|
|
300
|
+
chunk: [
|
|
301
|
+
{ ty: 'is', s: text },
|
|
302
|
+
{
|
|
303
|
+
ty: 'as',
|
|
304
|
+
st: 'list',
|
|
305
|
+
si: endOfLine(1),
|
|
306
|
+
ei: endOfLine(1),
|
|
307
|
+
sm: { ls_id: 'kix.list.7' },
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
ty: 'as',
|
|
311
|
+
st: 'list',
|
|
312
|
+
si: endOfLine(2),
|
|
313
|
+
ei: endOfLine(2),
|
|
314
|
+
sm: { ls_id: 'kix.list.7' },
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
ty: 'as',
|
|
318
|
+
st: 'list',
|
|
319
|
+
si: endOfLine(3),
|
|
320
|
+
ei: endOfLine(3),
|
|
321
|
+
sm: { ls_id: 'kix.list.7' },
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
const capture = parseGoogleDocsModelChunks(chunks);
|
|
327
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
328
|
+
const lines = markdown.split('\n');
|
|
329
|
+
|
|
330
|
+
expect(lines[0]).toBe('1. First item');
|
|
331
|
+
expect(lines[1]).toBe('2. Second item');
|
|
332
|
+
expect(lines[2]).toBe('3. Third item');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('joins consecutive list items with a single newline (issue #92 R4)', () => {
|
|
336
|
+
const text = 'First item\nSecond item\n';
|
|
337
|
+
const endFirst = text.indexOf('\n') + 1;
|
|
338
|
+
const endSecond = text.length;
|
|
339
|
+
const chunks = [
|
|
340
|
+
{
|
|
341
|
+
chunk: [
|
|
342
|
+
{ ty: 'is', s: text },
|
|
343
|
+
{
|
|
344
|
+
ty: 'as',
|
|
345
|
+
st: 'list',
|
|
346
|
+
si: endFirst,
|
|
347
|
+
ei: endFirst,
|
|
348
|
+
sm: { ls_id: 'kix.list.7' },
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
ty: 'as',
|
|
352
|
+
st: 'list',
|
|
353
|
+
si: endSecond,
|
|
354
|
+
ei: endSecond,
|
|
355
|
+
sm: { ls_id: 'kix.list.7' },
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
const capture = parseGoogleDocsModelChunks(chunks);
|
|
361
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
362
|
+
|
|
363
|
+
expect(markdown).not.toMatch(/First item\n\n2\. Second item/u);
|
|
364
|
+
expect(markdown).toMatch(/First item\n2\. Second item/u);
|
|
365
|
+
});
|
|
366
|
+
|
|
253
367
|
it('renders model style records for headings, inline formatting, links, lists, blockquotes, rules, and images', () => {
|
|
254
368
|
const text = [
|
|
255
369
|
'Title',
|
|
@@ -428,6 +542,49 @@ describe('gdocs', () => {
|
|
|
428
542
|
});
|
|
429
543
|
});
|
|
430
544
|
|
|
545
|
+
describe('captureGoogleDocWithBrowserOrFallback (issue #81)', () => {
|
|
546
|
+
afterEach(() => {
|
|
547
|
+
nock.cleanAll();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('falls back to public export archive when the editor exposes no model chunks', async () => {
|
|
551
|
+
const tinyPng =
|
|
552
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
|
|
553
|
+
nock('https://docs.google.com')
|
|
554
|
+
.get('/document/d/fallback-doc/export')
|
|
555
|
+
.query({ format: 'html' })
|
|
556
|
+
.reply(
|
|
557
|
+
200,
|
|
558
|
+
`<html><body><h1>Fallback Doc</h1><p>Complete text</p><img src="data:image/png;base64,${tinyPng}" alt="diagram"></body></html>`,
|
|
559
|
+
{ 'content-type': 'text/html; charset=utf-8' }
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const fallbackErrors = [];
|
|
563
|
+
const result = await captureGoogleDocWithBrowserOrFallback(
|
|
564
|
+
'https://docs.google.com/document/d/fallback-doc/edit',
|
|
565
|
+
{
|
|
566
|
+
format: 'archive',
|
|
567
|
+
waitMs: 0,
|
|
568
|
+
createBrowser: async () =>
|
|
569
|
+
fakeBrowserReturningModel({ chunks: [], cidUrlMap: {} }),
|
|
570
|
+
onFallback: (err) => fallbackErrors.push(err),
|
|
571
|
+
}
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
expect(result.method).toBe('public-export');
|
|
575
|
+
expect(result.fallback).toBe(true);
|
|
576
|
+
expect(result.markdown).toContain('Fallback Doc');
|
|
577
|
+
expect(result.markdown).toContain('Complete text');
|
|
578
|
+
expect(result.images).toHaveLength(1);
|
|
579
|
+
expect(result.html).toContain('images/image-01.png');
|
|
580
|
+
expect(fallbackErrors).toHaveLength(1);
|
|
581
|
+
expect(isGoogleDocsBrowserModelUnavailableError(fallbackErrors[0])).toBe(
|
|
582
|
+
true
|
|
583
|
+
);
|
|
584
|
+
expect(nock.isDone()).toBe(true);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
431
588
|
describe('GDOCS_EXPORT_FORMATS', () => {
|
|
432
589
|
it('contains all expected formats', () => {
|
|
433
590
|
expect(GDOCS_EXPORT_FORMATS).toEqual({
|
|
@@ -442,6 +599,146 @@ describe('gdocs', () => {
|
|
|
442
599
|
});
|
|
443
600
|
});
|
|
444
601
|
|
|
602
|
+
describe('localizeGoogleDocsModelImages (issue #92 R5)', () => {
|
|
603
|
+
it('downloads docs-images-rt URLs and rewrites markdown/html', async () => {
|
|
604
|
+
const url =
|
|
605
|
+
'https://docs.google.com/docs-images-rt/doc-id/cid/example.png';
|
|
606
|
+
const modelResult = {
|
|
607
|
+
markdown: ``,
|
|
608
|
+
html: `<img src="${url}" alt="pic">`,
|
|
609
|
+
capture: {
|
|
610
|
+
images: [{ type: 'image', url, alt: 'pic', cid: 'cid-abc' }],
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
const fakeFetch = jest.fn().mockResolvedValue({
|
|
614
|
+
ok: true,
|
|
615
|
+
status: 200,
|
|
616
|
+
headers: new Map([['content-type', 'image/png']]),
|
|
617
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer,
|
|
618
|
+
});
|
|
619
|
+
// The map-based headers need a `get` method in the correct shape:
|
|
620
|
+
fakeFetch.mockResolvedValue({
|
|
621
|
+
ok: true,
|
|
622
|
+
status: 200,
|
|
623
|
+
headers: { get: () => 'image/png' },
|
|
624
|
+
arrayBuffer: async () =>
|
|
625
|
+
new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const localized = await localizeGoogleDocsModelImages(modelResult, {
|
|
629
|
+
fetchImpl: fakeFetch,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
expect(fakeFetch).toHaveBeenCalledTimes(1);
|
|
633
|
+
expect(localized.images).toHaveLength(1);
|
|
634
|
+
expect(localized.images[0].filename).toBe('image-01.png');
|
|
635
|
+
expect(localized.images[0].mimeType).toBe('image/png');
|
|
636
|
+
expect(localized.images[0].data).toBeInstanceOf(Buffer);
|
|
637
|
+
expect(localized.markdown).toBe('');
|
|
638
|
+
expect(localized.html).toContain('images/image-01.png');
|
|
639
|
+
expect(localized.html).not.toContain('docs-images-rt');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('keeps original URL when download fails', async () => {
|
|
643
|
+
const url =
|
|
644
|
+
'https://docs.google.com/docs-images-rt/doc-id/cid/missing.png';
|
|
645
|
+
const modelResult = {
|
|
646
|
+
markdown: ``,
|
|
647
|
+
html: `<img src="${url}" alt="pic">`,
|
|
648
|
+
capture: { images: [{ url, alt: 'pic' }] },
|
|
649
|
+
};
|
|
650
|
+
const fakeFetch = jest.fn().mockResolvedValue({
|
|
651
|
+
ok: false,
|
|
652
|
+
status: 404,
|
|
653
|
+
headers: { get: () => null },
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const localized = await localizeGoogleDocsModelImages(modelResult, {
|
|
657
|
+
fetchImpl: fakeFetch,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
expect(localized.images).toHaveLength(0);
|
|
661
|
+
// Markdown still gets rewritten (local path) even if download fails —
|
|
662
|
+
// callers may choose to ignore this; the key guarantee is that no
|
|
663
|
+
// exception leaks out.
|
|
664
|
+
expect(localized.markdown).toBe('');
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe('preprocessGoogleDocsExportHtml (issue #92 R6)', () => {
|
|
669
|
+
it('hoists font-weight:700 spans into <strong>', () => {
|
|
670
|
+
const html = '<p><span style="font-weight:700">Bold text</span></p>';
|
|
671
|
+
const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
|
|
672
|
+
expect(hoisted).toBe(1);
|
|
673
|
+
expect(out).toContain('<strong>');
|
|
674
|
+
expect(out).toContain('Bold text');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('hoists font-style:italic spans into <em>', () => {
|
|
678
|
+
const html = '<p><span style="font-style:italic">Italic text</span></p>';
|
|
679
|
+
const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
|
|
680
|
+
expect(hoisted).toBe(1);
|
|
681
|
+
expect(out).toContain('<em>');
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('hoists text-decoration:line-through spans into <del>', () => {
|
|
685
|
+
const html =
|
|
686
|
+
'<p><span style="text-decoration:line-through">Strike</span></p>';
|
|
687
|
+
const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
|
|
688
|
+
expect(hoisted).toBe(1);
|
|
689
|
+
expect(out).toContain('<del>');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('combines multiple styles on a single span', () => {
|
|
693
|
+
const html =
|
|
694
|
+
'<p><span style="font-weight:700;font-style:italic;text-decoration:line-through">Mixed</span></p>';
|
|
695
|
+
const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
|
|
696
|
+
expect(hoisted).toBe(1);
|
|
697
|
+
// Wrappers appear in <strong><em><del>... order (outer-to-inner).
|
|
698
|
+
expect(out).toContain('<strong>');
|
|
699
|
+
expect(out).toContain('<em>');
|
|
700
|
+
expect(out).toContain('<del>');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('unwraps google.com/url?q= redirect links', () => {
|
|
704
|
+
const html =
|
|
705
|
+
'<p><a href="https://www.google.com/url?q=https://example.com&sa=D&source=editors&usg=ABC">Link</a></p>';
|
|
706
|
+
const { html: out, unwrappedLinks } =
|
|
707
|
+
preprocessGoogleDocsExportHtml(html);
|
|
708
|
+
expect(unwrappedLinks).toBe(1);
|
|
709
|
+
expect(out).toContain('href="https://example.com"');
|
|
710
|
+
expect(out).not.toContain('google.com/url?q=');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('strips leading empty anchors and numbering span inside headings', () => {
|
|
714
|
+
const html = '<h1><a id="anchor-1"></a><span>1. </span>Headings</h1>';
|
|
715
|
+
const { html: out } = preprocessGoogleDocsExportHtml(html);
|
|
716
|
+
expect(out).toContain('<h1>Headings</h1>');
|
|
717
|
+
expect(out).not.toContain('1. ');
|
|
718
|
+
expect(out).not.toContain('<a id=');
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('replaces entity and U+00A0 with regular spaces', () => {
|
|
722
|
+
const html = '<p>A\u00A0B C</p>';
|
|
723
|
+
const { html: out } = preprocessGoogleDocsExportHtml(html);
|
|
724
|
+
expect(out).toContain('A B C');
|
|
725
|
+
expect(out).not.toMatch(/\u00A0/);
|
|
726
|
+
expect(out).not.toContain(' ');
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('is a no-op for HTML without Google Docs markers', () => {
|
|
730
|
+
const html = '<p>Plain text with <strong>bold</strong>.</p>';
|
|
731
|
+
const {
|
|
732
|
+
html: out,
|
|
733
|
+
hoisted,
|
|
734
|
+
unwrappedLinks,
|
|
735
|
+
} = preprocessGoogleDocsExportHtml(html);
|
|
736
|
+
expect(hoisted).toBe(0);
|
|
737
|
+
expect(unwrappedLinks).toBe(0);
|
|
738
|
+
expect(out).toContain('<p>Plain text with <strong>bold</strong>.</p>');
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
445
742
|
describe('extractBase64Images', () => {
|
|
446
743
|
it('extracts a single PNG data URI image', () => {
|
|
447
744
|
const html = '<img src="data:image/png;base64,iVBORw0KGgo=" alt="test">';
|
|
@@ -555,3 +852,23 @@ describe('gdocs', () => {
|
|
|
555
852
|
});
|
|
556
853
|
});
|
|
557
854
|
});
|
|
855
|
+
|
|
856
|
+
function fakeBrowserReturningModel(modelData) {
|
|
857
|
+
return {
|
|
858
|
+
async newPage() {
|
|
859
|
+
return {
|
|
860
|
+
async addInitScript() {},
|
|
861
|
+
async setUserAgent() {},
|
|
862
|
+
async setExtraHTTPHeaders() {},
|
|
863
|
+
async setViewport() {},
|
|
864
|
+
async goto() {},
|
|
865
|
+
async waitForTimeout() {},
|
|
866
|
+
async evaluate() {
|
|
867
|
+
return modelData;
|
|
868
|
+
},
|
|
869
|
+
async close() {},
|
|
870
|
+
};
|
|
871
|
+
},
|
|
872
|
+
async close() {},
|
|
873
|
+
};
|
|
874
|
+
}
|