@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 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
@@ -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: { ...result, images: [] },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.8",
3
+ "version": "1.7.10",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,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(/&nbsp;/g, ' ');
103
+
104
+ return { html: rewritten, hoisted, unwrappedLinks };
105
+ }