@link-assistant/web-capture 1.1.2 → 1.7.1
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 +57 -13
- package/Dockerfile +26 -21
- package/README.md +290 -160
- package/bin/web-capture.js +622 -17
- package/package.json +15 -4
- package/src/animation.js +283 -0
- package/src/archive.js +225 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/extract-images.js +120 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +335 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +281 -1
- package/src/localize-images.js +202 -0
- package/src/markdown.js +7 -2
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +263 -0
- package/src/retry.js +48 -0
- package/src/themed-image.js +167 -0
- package/src/verify.js +312 -0
- package/tests/e2e/docker.test.js +51 -12
- package/tests/e2e/process.test.js +34 -10
- package/tests/fixtures/habr-articles.js +45 -0
- package/tests/integration/api-endpoints.test.js +231 -0
- package/tests/integration/habr-article.test.js +337 -0
- package/tests/mock/index.test.js +5 -3
- package/tests/unit/animation.test.js +33 -0
- package/tests/unit/batch.test.js +114 -0
- package/tests/unit/browser.test.js +38 -2
- package/tests/unit/extract-images.test.js +233 -0
- package/tests/unit/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +278 -0
- package/tests/unit/heading-image.test.js +49 -0
- package/tests/unit/latex.test.js +123 -0
- package/tests/unit/localize-images.test.js +78 -0
- package/tests/unit/metadata.test.js +140 -0
- package/tests/unit/postprocess.test.js +110 -0
- package/tests/unit/retry.test.js +111 -0
- package/tests/unit/verify.test.js +182 -0
- package/.github/workflows/release.yml +0 -294
- package/ARCHITECTURE.md +0 -982
- package/LICENSE +0 -24
- package/apply.sh +0 -166
- package/docs/market-analysis-2025-05-13.md +0 -64
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -88
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -218
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -128
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -106
- package/scripts/version-and-commit.mjs +0 -237
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const BASE64_IMAGE_REGEX =
|
|
6
|
+
/!\[([^\]]*)\]\(data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,([^)]+)\)/gi;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} markdown - Markdown content with data URI images
|
|
10
|
+
* @param {string} outputDir - Directory where the markdown file is being written
|
|
11
|
+
* @param {Object} [options]
|
|
12
|
+
* @param {string} [options.imagesDir='images'] - Subdirectory name for images
|
|
13
|
+
* @returns {{markdown: string, extracted: number}}
|
|
14
|
+
*/
|
|
15
|
+
export function extractAndSaveImages(markdown, outputDir, options = {}) {
|
|
16
|
+
const { imagesDir = 'images' } = options;
|
|
17
|
+
const imagesPath = path.resolve(outputDir, imagesDir);
|
|
18
|
+
|
|
19
|
+
const images = [];
|
|
20
|
+
|
|
21
|
+
const updatedMarkdown = markdown.replace(
|
|
22
|
+
BASE64_IMAGE_REGEX,
|
|
23
|
+
(match, altText, mimeExt, base64Data) => {
|
|
24
|
+
const ext =
|
|
25
|
+
mimeExt === 'jpeg' ? 'jpg' : mimeExt === 'svg+xml' ? 'svg' : mimeExt;
|
|
26
|
+
|
|
27
|
+
let buffer;
|
|
28
|
+
try {
|
|
29
|
+
buffer = Buffer.from(base64Data, 'base64');
|
|
30
|
+
} catch {
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const hash = createHash('sha256')
|
|
35
|
+
.update(buffer)
|
|
36
|
+
.digest('hex')
|
|
37
|
+
.slice(0, 8);
|
|
38
|
+
const filename = `image-${hash}.${ext}`;
|
|
39
|
+
const relativePath = `${imagesDir}/${filename}`;
|
|
40
|
+
|
|
41
|
+
images.push({ filename, buffer });
|
|
42
|
+
|
|
43
|
+
return ``;
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (images.length > 0) {
|
|
48
|
+
fs.mkdirSync(imagesPath, { recursive: true });
|
|
49
|
+
for (const img of images) {
|
|
50
|
+
fs.writeFileSync(path.join(imagesPath, img.filename), img.buffer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { markdown: updatedMarkdown, extracted: images.length };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract base64 images from markdown into memory buffers and rewrite paths.
|
|
59
|
+
* Does NOT write to disk — intended for streaming into archives.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} markdown - Markdown content with data URI images
|
|
62
|
+
* @param {string} [imagesDir='images'] - Directory prefix for image paths
|
|
63
|
+
* @returns {{markdown: string, images: Array<{filename: string, buffer: Buffer}>}}
|
|
64
|
+
*/
|
|
65
|
+
export function extractBase64ToBuffers(markdown, imagesDir = 'images') {
|
|
66
|
+
const images = [];
|
|
67
|
+
const updatedMarkdown = markdown.replace(
|
|
68
|
+
BASE64_IMAGE_REGEX,
|
|
69
|
+
(match, altText, mimeExt, base64Data) => {
|
|
70
|
+
const ext =
|
|
71
|
+
mimeExt === 'jpeg' ? 'jpg' : mimeExt === 'svg+xml' ? 'svg' : mimeExt;
|
|
72
|
+
|
|
73
|
+
let buffer;
|
|
74
|
+
try {
|
|
75
|
+
buffer = Buffer.from(base64Data, 'base64');
|
|
76
|
+
} catch {
|
|
77
|
+
return match;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hash = createHash('sha256')
|
|
81
|
+
.update(buffer)
|
|
82
|
+
.digest('hex')
|
|
83
|
+
.slice(0, 8);
|
|
84
|
+
const filename = `image-${hash}.${ext}`;
|
|
85
|
+
const relativePath = `${imagesDir}/${filename}`;
|
|
86
|
+
|
|
87
|
+
images.push({ filename, buffer });
|
|
88
|
+
return ``;
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
return { markdown: updatedMarkdown, images };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Strip base64 data URI images from markdown, leaving only the alt text
|
|
96
|
+
* as a placeholder. Used when keepOriginalLinks is enabled — base64 images
|
|
97
|
+
* have no original URL to restore, so we remove the heavy data URI.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} markdown - Markdown content with data URI images
|
|
100
|
+
* @returns {{markdown: string, stripped: number}}
|
|
101
|
+
*/
|
|
102
|
+
export function stripBase64Images(markdown) {
|
|
103
|
+
let stripped = 0;
|
|
104
|
+
const updatedMarkdown = markdown.replace(
|
|
105
|
+
BASE64_IMAGE_REGEX,
|
|
106
|
+
(_match, altText) => {
|
|
107
|
+
stripped++;
|
|
108
|
+
return altText ? `*[image: ${altText}]*` : '';
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
return { markdown: updatedMarkdown, stripped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} markdown - Markdown content
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function hasBase64Images(markdown) {
|
|
119
|
+
return BASE64_IMAGE_REGEX.test(markdown);
|
|
120
|
+
}
|
package/src/figures.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figure image extraction and download module (R4).
|
|
3
|
+
*
|
|
4
|
+
* Extracts figure images from web pages and downloads them locally.
|
|
5
|
+
* Supports multi-language figure detection (English/Russian).
|
|
6
|
+
*
|
|
7
|
+
* Based on reference implementation from:
|
|
8
|
+
* https://github.com/link-foundation/meta-theory/blob/main/scripts/download.mjs
|
|
9
|
+
*
|
|
10
|
+
* @module figures
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fetch from 'node-fetch';
|
|
14
|
+
import * as cheerio from 'cheerio';
|
|
15
|
+
import { URL } from 'url';
|
|
16
|
+
import { retry } from './retry.js';
|
|
17
|
+
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
18
|
+
import { dismissPopups, scrollToLoadContent } from './popups.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract figure elements from HTML content.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} html - HTML content
|
|
24
|
+
* @param {string} baseUrl - Base URL for resolving relative URLs
|
|
25
|
+
* @returns {Object[]} Array of figure objects with src, alt, caption, figureNum
|
|
26
|
+
*/
|
|
27
|
+
export function extractFigures(html, baseUrl) {
|
|
28
|
+
const $ = cheerio.load(html);
|
|
29
|
+
const figures = [];
|
|
30
|
+
let sequentialIndex = 0;
|
|
31
|
+
|
|
32
|
+
$('figure').each(function () {
|
|
33
|
+
const img = $(this).find('img').first();
|
|
34
|
+
if (!img.length) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const src = img.attr('src');
|
|
39
|
+
if (!src || src.startsWith('data:') || src.includes('.svg')) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
sequentialIndex++;
|
|
44
|
+
const captionEl = $(this).find('figcaption');
|
|
45
|
+
const captionText = captionEl.length ? captionEl.text().trim() : '';
|
|
46
|
+
|
|
47
|
+
// Match multi-language figure numbers: "Figure X", "Рис. X", "Рисунок X"
|
|
48
|
+
const figureMatch = captionText.match(/(?:Figure|Рис\.?|Рисунок)\s*(\d+)/i);
|
|
49
|
+
const figureNum = figureMatch ? parseInt(figureMatch[1]) : sequentialIndex;
|
|
50
|
+
|
|
51
|
+
let resolvedSrc;
|
|
52
|
+
try {
|
|
53
|
+
resolvedSrc = new URL(src, baseUrl).href;
|
|
54
|
+
} catch {
|
|
55
|
+
resolvedSrc = src;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
figures.push({
|
|
59
|
+
figureNum,
|
|
60
|
+
src: resolvedSrc,
|
|
61
|
+
alt: img.attr('alt') || '',
|
|
62
|
+
caption: captionText,
|
|
63
|
+
sequentialIndex,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return figures;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Download figure images to a local directory.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object[]} figures - Array of figure objects from extractFigures
|
|
74
|
+
* @param {Object} [options] - Download options
|
|
75
|
+
* @param {Function} [options.onProgress] - Callback(figureNum, status)
|
|
76
|
+
* @returns {Promise<Object[]>} Array of download results with metadata
|
|
77
|
+
*/
|
|
78
|
+
export async function downloadFigures(figures, options = {}) {
|
|
79
|
+
const results = [];
|
|
80
|
+
|
|
81
|
+
for (const figure of figures) {
|
|
82
|
+
const ext =
|
|
83
|
+
figure.src.includes('.jpeg') || figure.src.includes('.jpg')
|
|
84
|
+
? 'jpg'
|
|
85
|
+
: 'png';
|
|
86
|
+
const filename = `figure-${figure.figureNum}.${ext}`;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const resp = await retry(() => fetch(figure.src), {
|
|
90
|
+
retries: 3,
|
|
91
|
+
baseDelay: 1000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!resp.ok) {
|
|
95
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const buffer = await resp.buffer();
|
|
99
|
+
|
|
100
|
+
if (options.onProgress) {
|
|
101
|
+
options.onProgress(figure.figureNum, 'downloaded');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
results.push({
|
|
105
|
+
figureNum: figure.figureNum,
|
|
106
|
+
filename,
|
|
107
|
+
buffer,
|
|
108
|
+
caption: figure.caption,
|
|
109
|
+
originalUrl: figure.src,
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (options.onProgress) {
|
|
113
|
+
options.onProgress(figure.figureNum, 'failed');
|
|
114
|
+
}
|
|
115
|
+
results.push({
|
|
116
|
+
figureNum: figure.figureNum,
|
|
117
|
+
filename,
|
|
118
|
+
buffer: null,
|
|
119
|
+
caption: figure.caption,
|
|
120
|
+
originalUrl: figure.src,
|
|
121
|
+
error: err.message,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Extract and download figures from a URL using browser rendering.
|
|
131
|
+
* This handles JavaScript-rendered pages that require a browser.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} url - URL to extract figures from
|
|
134
|
+
* @param {Object} [options] - Options
|
|
135
|
+
* @param {string} [options.engine] - Browser engine
|
|
136
|
+
* @returns {Promise<Object>} Result with figures and metadata
|
|
137
|
+
*/
|
|
138
|
+
export async function extractFiguresFromUrl(url, options = {}) {
|
|
139
|
+
const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
140
|
+
const engine = options.engine || 'puppeteer';
|
|
141
|
+
|
|
142
|
+
const browser = await createBrowser(engine);
|
|
143
|
+
try {
|
|
144
|
+
const page = await browser.newPage();
|
|
145
|
+
await page.setUserAgent(
|
|
146
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
147
|
+
);
|
|
148
|
+
await page.setViewport({ width: 1920, height: 1080 });
|
|
149
|
+
await page.goto(absoluteUrl, {
|
|
150
|
+
waitUntil: 'networkidle0',
|
|
151
|
+
timeout: 30000,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
155
|
+
await scrollToLoadContent(page);
|
|
156
|
+
await dismissPopups(page);
|
|
157
|
+
|
|
158
|
+
const html = await page.content();
|
|
159
|
+
const figures = extractFigures(html, absoluteUrl);
|
|
160
|
+
const downloaded = await downloadFigures(figures, options);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
url: absoluteUrl,
|
|
164
|
+
figures: downloaded,
|
|
165
|
+
totalFound: figures.length,
|
|
166
|
+
totalDownloaded: downloaded.filter((d) => d.buffer).length,
|
|
167
|
+
};
|
|
168
|
+
} finally {
|
|
169
|
+
await browser.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Figures extraction handler for Express API.
|
|
175
|
+
*
|
|
176
|
+
* Query parameters:
|
|
177
|
+
* url (required) - URL to extract figures from
|
|
178
|
+
* engine - 'puppeteer' or 'playwright'
|
|
179
|
+
*/
|
|
180
|
+
export async function figuresHandler(req, res) {
|
|
181
|
+
const url = req.query.url;
|
|
182
|
+
if (!url) {
|
|
183
|
+
return res.status(400).send('Missing `url` parameter');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const engine = getBrowserEngine(req);
|
|
188
|
+
const result = await extractFiguresFromUrl(url, { engine });
|
|
189
|
+
|
|
190
|
+
// Return metadata (not the actual image buffers via JSON)
|
|
191
|
+
res.json({
|
|
192
|
+
url: result.url,
|
|
193
|
+
totalFound: result.totalFound,
|
|
194
|
+
totalDownloaded: result.totalDownloaded,
|
|
195
|
+
figures: result.figures.map((f) => ({
|
|
196
|
+
figureNum: f.figureNum,
|
|
197
|
+
filename: f.filename,
|
|
198
|
+
caption: f.caption,
|
|
199
|
+
originalUrl: f.originalUrl,
|
|
200
|
+
downloaded: !!f.buffer,
|
|
201
|
+
error: f.error || null,
|
|
202
|
+
})),
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(err);
|
|
206
|
+
res.status(500).send('Error extracting figures');
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/gdocs.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Google Docs capture module
|
|
2
|
+
// Supports both API-based and browser-based capture of Google Docs documents.
|
|
3
|
+
// API-based capture uses the Google Docs export URL pattern:
|
|
4
|
+
// https://docs.google.com/document/d/{DOCUMENT_ID}/export?format={FORMAT}
|
|
5
|
+
// Browser-based capture navigates to the export URL using Puppeteer or Playwright.
|
|
6
|
+
|
|
7
|
+
import fetch from 'node-fetch';
|
|
8
|
+
import he from 'he';
|
|
9
|
+
import archiver from 'archiver';
|
|
10
|
+
import { convertHtmlToMarkdown, prettyPrintHtml } from './lib.js';
|
|
11
|
+
|
|
12
|
+
const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
|
|
13
|
+
|
|
14
|
+
const GDOCS_EXPORT_BASE = 'https://docs.google.com/document/d';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Supported Google Docs export formats.
|
|
18
|
+
* These map to the `format` query parameter in the export URL.
|
|
19
|
+
*/
|
|
20
|
+
export const GDOCS_EXPORT_FORMATS = {
|
|
21
|
+
html: 'html',
|
|
22
|
+
txt: 'txt',
|
|
23
|
+
md: 'md',
|
|
24
|
+
pdf: 'pdf',
|
|
25
|
+
docx: 'docx',
|
|
26
|
+
epub: 'epub',
|
|
27
|
+
zip: 'zip',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a URL is a Google Docs document URL.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} url - URL to check
|
|
34
|
+
* @returns {boolean} True if the URL is a Google Docs document URL
|
|
35
|
+
*/
|
|
36
|
+
export function isGoogleDocsUrl(url) {
|
|
37
|
+
if (!url || typeof url !== 'string') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return GDOCS_URL_PATTERN.test(url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract the document ID from a Google Docs URL.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} url - Google Docs URL
|
|
47
|
+
* @returns {string|null} Document ID or null if not a valid Google Docs URL
|
|
48
|
+
*/
|
|
49
|
+
export function extractDocumentId(url) {
|
|
50
|
+
if (!url || typeof url !== 'string') {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const match = url.match(GDOCS_URL_PATTERN);
|
|
54
|
+
return match ? match[1] : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a Google Docs export URL.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} documentId - Google Docs document ID
|
|
61
|
+
* @param {string} [format='html'] - Export format (html, txt, md, pdf, docx, epub, zip)
|
|
62
|
+
* @returns {string} Export URL
|
|
63
|
+
*/
|
|
64
|
+
export function buildExportUrl(documentId, format = 'html') {
|
|
65
|
+
const exportFormat = GDOCS_EXPORT_FORMATS[format] || 'html';
|
|
66
|
+
return `${GDOCS_EXPORT_BASE}/${documentId}/export?format=${exportFormat}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fetch a Google Docs document via the export URL (API-based capture).
|
|
71
|
+
*
|
|
72
|
+
* For public documents, no authentication is needed.
|
|
73
|
+
* For private documents, pass an API token via the apiToken option.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} url - Google Docs URL (edit URL or any URL containing the document ID)
|
|
76
|
+
* @param {Object} [options] - Fetch options
|
|
77
|
+
* @param {string} [options.format='html'] - Export format
|
|
78
|
+
* @param {string} [options.apiToken] - API token for private documents (Bearer token)
|
|
79
|
+
* @returns {Promise<{content: string, format: string, documentId: string, exportUrl: string}>}
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchGoogleDoc(url, options = {}) {
|
|
82
|
+
const { format = 'html', apiToken } = options;
|
|
83
|
+
|
|
84
|
+
const documentId = extractDocumentId(url);
|
|
85
|
+
if (!documentId) {
|
|
86
|
+
throw new Error(`Not a valid Google Docs URL: ${url}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const exportUrl = buildExportUrl(documentId, format);
|
|
90
|
+
|
|
91
|
+
const headers = {
|
|
92
|
+
'User-Agent':
|
|
93
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
94
|
+
'Accept-Charset': 'utf-8',
|
|
95
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (apiToken) {
|
|
99
|
+
headers['Authorization'] = `Bearer ${apiToken}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const response = await fetch(exportUrl, {
|
|
103
|
+
headers,
|
|
104
|
+
redirect: 'follow',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const statusText = response.statusText || 'Unknown error';
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Failed to fetch Google Doc (${response.status} ${statusText}): ${exportUrl}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rawContent = await response.text();
|
|
115
|
+
|
|
116
|
+
// Decode HTML entities to unicode for text-based formats
|
|
117
|
+
const content =
|
|
118
|
+
format === 'html' || format === 'txt' || format === 'md'
|
|
119
|
+
? he.decode(rawContent)
|
|
120
|
+
: rawContent;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
content,
|
|
124
|
+
format,
|
|
125
|
+
documentId,
|
|
126
|
+
exportUrl,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fetch a Google Docs document and convert to Markdown.
|
|
132
|
+
*
|
|
133
|
+
* Uses the HTML export format internally, then converts to Markdown
|
|
134
|
+
* using the existing web-capture HTML-to-Markdown pipeline.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} url - Google Docs URL
|
|
137
|
+
* @param {Object} [options] - Options
|
|
138
|
+
* @param {string} [options.apiToken] - API token for private documents
|
|
139
|
+
* @returns {Promise<{markdown: string, documentId: string, exportUrl: string}>}
|
|
140
|
+
*/
|
|
141
|
+
export async function fetchGoogleDocAsMarkdown(url, options = {}) {
|
|
142
|
+
const { apiToken } = options;
|
|
143
|
+
|
|
144
|
+
// Always fetch as HTML first, then convert to Markdown
|
|
145
|
+
const result = await fetchGoogleDoc(url, {
|
|
146
|
+
format: 'html',
|
|
147
|
+
apiToken,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const markdown = convertHtmlToMarkdown(result.content, result.exportUrl);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
markdown,
|
|
154
|
+
documentId: result.documentId,
|
|
155
|
+
exportUrl: result.exportUrl,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Extract base64 data URI images from HTML content.
|
|
161
|
+
*
|
|
162
|
+
* Google Docs HTML exports embed images as base64 data URIs.
|
|
163
|
+
* This function extracts them and replaces with local file paths.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} html - HTML content with data URI images
|
|
166
|
+
* @returns {{html: string, images: Array<{filename: string, data: Buffer, mimeType: string}>}}
|
|
167
|
+
*/
|
|
168
|
+
export function extractBase64Images(html) {
|
|
169
|
+
const images = [];
|
|
170
|
+
let idx = 1;
|
|
171
|
+
|
|
172
|
+
const updatedHtml = html.replace(
|
|
173
|
+
/(<img\s[^>]*src=")data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,([^"]+)(")/gi,
|
|
174
|
+
(match, prefix, mimeExt, base64Data, suffix) => {
|
|
175
|
+
const ext =
|
|
176
|
+
mimeExt === 'jpeg' ? 'jpg' : mimeExt === 'svg+xml' ? 'svg' : mimeExt;
|
|
177
|
+
const filename = `image-${String(idx).padStart(2, '0')}.${ext}`;
|
|
178
|
+
const mimeType = `image/${mimeExt}`;
|
|
179
|
+
images.push({
|
|
180
|
+
filename,
|
|
181
|
+
data: Buffer.from(base64Data, 'base64'),
|
|
182
|
+
mimeType,
|
|
183
|
+
});
|
|
184
|
+
idx++;
|
|
185
|
+
return `${prefix}images/${filename}${suffix}`;
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return { html: updatedHtml, images };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Fetch a Google Docs document as a ZIP archive with images.
|
|
194
|
+
*
|
|
195
|
+
* Fetches the document as HTML, extracts embedded base64 images,
|
|
196
|
+
* and bundles everything into a ZIP archive containing:
|
|
197
|
+
* - document.md (Markdown version)
|
|
198
|
+
* - document.html (HTML version with local image paths)
|
|
199
|
+
* - images/ (extracted images)
|
|
200
|
+
*
|
|
201
|
+
* @param {string} url - Google Docs URL
|
|
202
|
+
* @param {Object} [options] - Options
|
|
203
|
+
* @param {string} [options.apiToken] - API token for private documents
|
|
204
|
+
* @returns {Promise<{documentId: string, exportUrl: string, createArchive: function}>}
|
|
205
|
+
*/
|
|
206
|
+
export async function fetchGoogleDocAsArchive(url, options = {}) {
|
|
207
|
+
const { apiToken } = options;
|
|
208
|
+
|
|
209
|
+
// Fetch HTML with embedded base64 images
|
|
210
|
+
const result = await fetchGoogleDoc(url, { format: 'html', apiToken });
|
|
211
|
+
|
|
212
|
+
// Extract base64 images from HTML
|
|
213
|
+
const { html: localHtml, images } = extractBase64Images(result.content);
|
|
214
|
+
|
|
215
|
+
// Convert the localized HTML to Markdown
|
|
216
|
+
const markdown = convertHtmlToMarkdown(localHtml);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
documentId: result.documentId,
|
|
220
|
+
exportUrl: result.exportUrl,
|
|
221
|
+
html: localHtml,
|
|
222
|
+
markdown,
|
|
223
|
+
images,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Express handler for the /gdocs API endpoint.
|
|
229
|
+
*
|
|
230
|
+
* Query parameters:
|
|
231
|
+
* - url (required): Google Docs URL
|
|
232
|
+
* - format (optional): Export format (html, markdown, md, txt, pdf, docx)
|
|
233
|
+
* - apiToken (optional): API token for private documents
|
|
234
|
+
*
|
|
235
|
+
* The API token can also be provided via:
|
|
236
|
+
* - Authorization header: Bearer <token>
|
|
237
|
+
* - X-Api-Token header: <token>
|
|
238
|
+
*
|
|
239
|
+
* @param {import('express').Request} req
|
|
240
|
+
* @param {import('express').Response} res
|
|
241
|
+
*/
|
|
242
|
+
export async function gdocsHandler(req, res) {
|
|
243
|
+
const url = req.query.url;
|
|
244
|
+
if (!url) {
|
|
245
|
+
return res.status(400).send('Missing `url` parameter');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!isGoogleDocsUrl(url)) {
|
|
249
|
+
return res.status(400).send('URL is not a Google Docs document URL');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Resolve API token from query, headers, or environment
|
|
253
|
+
const apiToken =
|
|
254
|
+
req.query.apiToken ||
|
|
255
|
+
extractBearerToken(req.headers.authorization) ||
|
|
256
|
+
req.headers['x-api-token'] ||
|
|
257
|
+
undefined;
|
|
258
|
+
|
|
259
|
+
const format = (req.query.format || 'markdown').toLowerCase();
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
if (format === 'archive' || format === 'zip') {
|
|
263
|
+
return await sendGDocsArchive(res, url, apiToken);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (format === 'markdown' || format === 'md') {
|
|
267
|
+
const result = await fetchGoogleDocAsMarkdown(url, { apiToken });
|
|
268
|
+
return res.type('text/markdown').send(result.markdown);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return await sendGDocsExport(res, url, format, apiToken);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('Google Docs capture error:', err.message);
|
|
274
|
+
return res.status(500).send(`Error capturing Google Doc: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function sendGDocsArchive(res, url, apiToken) {
|
|
279
|
+
const archiveResult = await fetchGoogleDocAsArchive(url, { apiToken });
|
|
280
|
+
|
|
281
|
+
res.set('Content-Type', 'application/zip');
|
|
282
|
+
res.set(
|
|
283
|
+
'Content-Disposition',
|
|
284
|
+
`attachment; filename="gdoc-${archiveResult.documentId}.zip"`
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
288
|
+
archive.pipe(res);
|
|
289
|
+
archive.append(archiveResult.markdown, { name: 'document.md' });
|
|
290
|
+
archive.append(prettyPrintHtml(archiveResult.html), {
|
|
291
|
+
name: 'document.html',
|
|
292
|
+
});
|
|
293
|
+
for (const img of archiveResult.images) {
|
|
294
|
+
archive.append(img.data, { name: `images/${img.filename}` });
|
|
295
|
+
}
|
|
296
|
+
await archive.finalize();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const FORMAT_CONTENT_TYPES = {
|
|
300
|
+
html: 'text/html',
|
|
301
|
+
txt: 'text/plain',
|
|
302
|
+
pdf: 'application/pdf',
|
|
303
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const FORMAT_DISPOSITIONS = {
|
|
307
|
+
pdf: 'inline; filename="document.pdf"',
|
|
308
|
+
docx: 'inline; filename="document.docx"',
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
async function sendGDocsExport(res, url, format, apiToken) {
|
|
312
|
+
const result = await fetchGoogleDoc(url, { format, apiToken });
|
|
313
|
+
const contentType = FORMAT_CONTENT_TYPES[format] || 'text/html';
|
|
314
|
+
const disposition = FORMAT_DISPOSITIONS[format];
|
|
315
|
+
|
|
316
|
+
res.type(contentType);
|
|
317
|
+
if (disposition) {
|
|
318
|
+
res.set('Content-Disposition', disposition);
|
|
319
|
+
}
|
|
320
|
+
return res.send(result.content);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Extract Bearer token from Authorization header value.
|
|
325
|
+
*
|
|
326
|
+
* @param {string|undefined} authHeader - Authorization header value
|
|
327
|
+
* @returns {string|undefined} Token or undefined
|
|
328
|
+
*/
|
|
329
|
+
function extractBearerToken(authHeader) {
|
|
330
|
+
if (!authHeader || typeof authHeader !== 'string') {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
334
|
+
return match ? match[1] : undefined;
|
|
335
|
+
}
|