@link-assistant/web-capture 1.7.8 → 1.7.10
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 +31 -0
- package/bin/web-capture.js +17 -1
- package/package.json +1 -1
- package/src/gdocs-images.js +130 -0
- package/src/gdocs-preprocess.js +105 -0
- package/src/gdocs.js +437 -52
- package/tests/integration/gdocs-public-doc.test.js +247 -0
- package/tests/unit/gdocs.test.js +370 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fcc031f: Fix remaining Google Docs capture gaps from issue #92 in both the JS and
|
|
8
|
+
Rust CLIs:
|
|
9
|
+
- Browser capture now keeps multi-column table rows intact, renders
|
|
10
|
+
ordered lists with sequential `1. 2. 3.` numbering, and joins same-list
|
|
11
|
+
items with a single newline so tight-list markdown matches the source
|
|
12
|
+
document.
|
|
13
|
+
- Archive mode downloads `docs-images-rt/...` image URLs into the
|
|
14
|
+
archive's `images/` directory and rewrites markdown/html references so
|
|
15
|
+
exports are self-contained.
|
|
16
|
+
- API mode (`--capture api`) runs the export HTML through a shared
|
|
17
|
+
preprocessor that hoists inline bold / italic / strikethrough spans to
|
|
18
|
+
semantic tags, strips Google Docs' heading-numbering spans and empty
|
|
19
|
+
anchor wrappers, unwraps `google.com/url?q=` redirects, and normalizes
|
|
20
|
+
non-breaking spaces.
|
|
21
|
+
|
|
22
|
+
## 1.7.9
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- cee7590: Add a live Google Docs integration test that captures the public markdown
|
|
27
|
+
round-trip reference document
|
|
28
|
+
(https://docs.google.com/document/d/1f5zI2xOFpKa90v0GjamO_t7lqSdzMlaM/edit)
|
|
29
|
+
and verifies every documented URL variation, capture-method selection path,
|
|
30
|
+
and feature-section heading. The test is gated behind `GDOCS_INTEGRATION=true`
|
|
31
|
+
and is wired into CI so regressions in the `--capture api` HTML-to-Markdown
|
|
32
|
+
pipeline surface against a real Google Doc. Addresses issue #90.
|
|
33
|
+
|
|
3
34
|
## 1.7.8
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/bin/web-capture.js
CHANGED
|
@@ -527,8 +527,24 @@ async function captureUrl(url, options) {
|
|
|
527
527
|
textBytes: Buffer.byteLength(result.text || ''),
|
|
528
528
|
}));
|
|
529
529
|
if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
|
|
530
|
+
const { localizeGoogleDocsModelImages } =
|
|
531
|
+
await import('../src/gdocs.js');
|
|
532
|
+
const localized = await localizeGoogleDocsModelImages(result, {
|
|
533
|
+
log,
|
|
534
|
+
});
|
|
535
|
+
log.debug(() => ({
|
|
536
|
+
event: 'gdocs.capture.browser-model.archive.localized',
|
|
537
|
+
images: localized.images.length,
|
|
538
|
+
markdownBytes: Buffer.byteLength(localized.markdown),
|
|
539
|
+
htmlBytes: Buffer.byteLength(localized.html),
|
|
540
|
+
}));
|
|
530
541
|
await writeGoogleDocsArchive({
|
|
531
|
-
archiveResult: {
|
|
542
|
+
archiveResult: {
|
|
543
|
+
...result,
|
|
544
|
+
markdown: localized.markdown,
|
|
545
|
+
html: localized.html,
|
|
546
|
+
images: localized.images,
|
|
547
|
+
},
|
|
532
548
|
absoluteUrl,
|
|
533
549
|
explicitOutput,
|
|
534
550
|
dataDir,
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|