@link-assistant/web-capture 1.1.2 → 1.4.2
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 +17 -14
- package/README.md +256 -146
- package/bin/web-capture.js +403 -6
- package/package.json +11 -4
- package/src/animation.js +283 -0
- package/src/archive.js +212 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +333 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +226 -1
- package/src/localize-images.js +202 -0
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +265 -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/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +203 -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 +108 -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
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,333 @@
|
|
|
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 } 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
|
+
* - article.md (Markdown version)
|
|
198
|
+
* - article.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, result.exportUrl);
|
|
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: 'article.md' });
|
|
290
|
+
archive.append(archiveResult.html, { name: 'article.html' });
|
|
291
|
+
for (const img of archiveResult.images) {
|
|
292
|
+
archive.append(img.data, { name: `images/${img.filename}` });
|
|
293
|
+
}
|
|
294
|
+
await archive.finalize();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const FORMAT_CONTENT_TYPES = {
|
|
298
|
+
html: 'text/html',
|
|
299
|
+
txt: 'text/plain',
|
|
300
|
+
pdf: 'application/pdf',
|
|
301
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const FORMAT_DISPOSITIONS = {
|
|
305
|
+
pdf: 'inline; filename="document.pdf"',
|
|
306
|
+
docx: 'inline; filename="document.docx"',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
async function sendGDocsExport(res, url, format, apiToken) {
|
|
310
|
+
const result = await fetchGoogleDoc(url, { format, apiToken });
|
|
311
|
+
const contentType = FORMAT_CONTENT_TYPES[format] || 'text/html';
|
|
312
|
+
const disposition = FORMAT_DISPOSITIONS[format];
|
|
313
|
+
|
|
314
|
+
res.type(contentType);
|
|
315
|
+
if (disposition) {
|
|
316
|
+
res.set('Content-Disposition', disposition);
|
|
317
|
+
}
|
|
318
|
+
return res.send(result.content);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Extract Bearer token from Authorization header value.
|
|
323
|
+
*
|
|
324
|
+
* @param {string|undefined} authHeader - Authorization header value
|
|
325
|
+
* @returns {string|undefined} Token or undefined
|
|
326
|
+
*/
|
|
327
|
+
function extractBearerToken(authHeader) {
|
|
328
|
+
if (!authHeader || typeof authHeader !== 'string') {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
332
|
+
return match ? match[1] : undefined;
|
|
333
|
+
}
|
package/src/image.js
CHANGED
|
@@ -1,15 +1,46 @@
|
|
|
1
1
|
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
2
|
+
import { dismissPopups, scrollToLoadContent } from './popups.js';
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Image screenshot handler.
|
|
6
|
+
*
|
|
7
|
+
* Query parameters:
|
|
8
|
+
* url (required) - URL to capture
|
|
9
|
+
* engine - 'puppeteer' or 'playwright'
|
|
10
|
+
* format - 'png' (default, lossless) or 'jpeg'
|
|
11
|
+
* quality - JPEG quality 0-100 (default 80, ignored for PNG)
|
|
12
|
+
* width - viewport width in px (default 1280)
|
|
13
|
+
* height - viewport height in px (default 800)
|
|
14
|
+
* fullPage - 'true' to capture full scrollable page (default 'false')
|
|
15
|
+
* theme - 'light', 'dark', or 'no-preference' (default: browser default)
|
|
16
|
+
* dismissPopups - 'true' to auto-close cookie/consent popups before capture (default 'true')
|
|
17
|
+
*/
|
|
3
18
|
export async function imageHandler(req, res) {
|
|
4
19
|
const url = req.query.url;
|
|
5
20
|
if (!url) {
|
|
6
21
|
return res.status(400).send('Missing `url` parameter');
|
|
7
22
|
}
|
|
23
|
+
|
|
24
|
+
// Parse options from query parameters
|
|
25
|
+
const format = (req.query.format || 'png').toLowerCase();
|
|
26
|
+
if (format !== 'png' && format !== 'jpeg') {
|
|
27
|
+
return res.status(400).send('Invalid `format`: must be "png" or "jpeg"');
|
|
28
|
+
}
|
|
29
|
+
const quality =
|
|
30
|
+
format === 'jpeg'
|
|
31
|
+
? Math.min(100, Math.max(0, parseInt(req.query.quality, 10) || 80))
|
|
32
|
+
: undefined;
|
|
33
|
+
const width = parseInt(req.query.width, 10) || 1280;
|
|
34
|
+
const height = parseInt(req.query.height, 10) || 800;
|
|
35
|
+
const fullPage = req.query.fullPage === 'true';
|
|
36
|
+
const theme = req.query.theme; // 'light', 'dark', or undefined
|
|
37
|
+
const shouldDismissPopups = req.query.dismissPopups !== 'false'; // default true
|
|
38
|
+
|
|
8
39
|
try {
|
|
9
|
-
// Ensure URL is absolute
|
|
10
40
|
const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
11
41
|
const engine = getBrowserEngine(req);
|
|
12
|
-
const
|
|
42
|
+
const browserOpts = theme ? { colorScheme: theme } : {};
|
|
43
|
+
const browser = await createBrowser(engine, browserOpts);
|
|
13
44
|
try {
|
|
14
45
|
const page = await browser.newPage();
|
|
15
46
|
await page.setExtraHTTPHeaders({
|
|
@@ -19,17 +50,43 @@ export async function imageHandler(req, res) {
|
|
|
19
50
|
await page.setUserAgent(
|
|
20
51
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
21
52
|
);
|
|
22
|
-
await page.setViewport({ width
|
|
53
|
+
await page.setViewport({ width, height });
|
|
23
54
|
await page.goto(absoluteUrl, {
|
|
24
55
|
waitUntil: 'networkidle0',
|
|
25
56
|
timeout: 30000,
|
|
26
57
|
});
|
|
27
|
-
|
|
58
|
+
|
|
59
|
+
// Wait for dynamic content
|
|
28
60
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
61
|
+
|
|
62
|
+
if (fullPage) {
|
|
63
|
+
// Scroll to trigger lazy-loaded images
|
|
64
|
+
await scrollToLoadContent(page);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (shouldDismissPopups) {
|
|
68
|
+
await dismissPopups(page);
|
|
69
|
+
// Scroll back to top after dismissing popups using browser-commander's evaluate API
|
|
70
|
+
// eslint-disable-next-line no-undef
|
|
71
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Build screenshot options
|
|
76
|
+
const screenshotOpts = {
|
|
77
|
+
type: format,
|
|
78
|
+
fullPage,
|
|
79
|
+
};
|
|
80
|
+
if (format === 'jpeg') {
|
|
81
|
+
screenshotOpts.quality = quality;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const buffer = await page.screenshot(screenshotOpts);
|
|
85
|
+
|
|
86
|
+
const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
87
|
+
const ext = format === 'jpeg' ? 'jpg' : 'png';
|
|
88
|
+
res.set('Content-Type', contentType);
|
|
89
|
+
res.set('Content-Disposition', `inline; filename="screenshot.${ext}"`);
|
|
33
90
|
res.end(buffer);
|
|
34
91
|
} finally {
|
|
35
92
|
await browser.close();
|
package/src/index.js
CHANGED
|
@@ -5,15 +5,30 @@ import { markdownHandler } from './markdown.js';
|
|
|
5
5
|
import { imageHandler } from './image.js';
|
|
6
6
|
import { streamHandler } from './stream.js';
|
|
7
7
|
import { fetchHandler } from './fetch.js';
|
|
8
|
+
import { archiveHandler } from './archive.js';
|
|
9
|
+
import { pdfHandler } from './pdf.js';
|
|
10
|
+
import { docxHandler } from './docx.js';
|
|
11
|
+
import { animationHandler } from './animation.js';
|
|
12
|
+
import { figuresHandler } from './figures.js';
|
|
13
|
+
import { themedImageHandler } from './themed-image.js';
|
|
14
|
+
import { gdocsHandler } from './gdocs.js';
|
|
8
15
|
|
|
9
16
|
const app = express();
|
|
10
17
|
const port = process.env.PORT || 3000;
|
|
11
18
|
|
|
19
|
+
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
|
12
20
|
app.get('/html', htmlHandler);
|
|
13
21
|
app.get('/markdown', markdownHandler);
|
|
14
22
|
app.get('/image', imageHandler);
|
|
15
23
|
app.get('/stream', streamHandler);
|
|
16
24
|
app.get('/fetch', fetchHandler);
|
|
25
|
+
app.get('/archive', archiveHandler);
|
|
26
|
+
app.get('/pdf', pdfHandler);
|
|
27
|
+
app.get('/docx', docxHandler);
|
|
28
|
+
app.get('/animation', animationHandler);
|
|
29
|
+
app.get('/figures', figuresHandler);
|
|
30
|
+
app.get('/themed-image', themedImageHandler);
|
|
31
|
+
app.get('/gdocs', gdocsHandler);
|
|
17
32
|
|
|
18
33
|
// Start the server if this is the main module
|
|
19
34
|
const isMainModule =
|