@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +17 -14
  2. package/README.md +256 -146
  3. package/bin/web-capture.js +403 -6
  4. package/package.json +11 -4
  5. package/src/animation.js +283 -0
  6. package/src/archive.js +212 -0
  7. package/src/batch.js +180 -0
  8. package/src/browser.js +150 -111
  9. package/src/docx.js +182 -0
  10. package/src/figures.js +208 -0
  11. package/src/gdocs.js +333 -0
  12. package/src/image.js +65 -8
  13. package/src/index.js +15 -0
  14. package/src/latex.js +127 -0
  15. package/src/lib.js +226 -1
  16. package/src/localize-images.js +202 -0
  17. package/src/metadata.js +333 -0
  18. package/src/pdf.js +76 -0
  19. package/src/popups.js +171 -0
  20. package/src/postprocess.js +265 -0
  21. package/src/retry.js +48 -0
  22. package/src/themed-image.js +167 -0
  23. package/src/verify.js +312 -0
  24. package/tests/e2e/docker.test.js +51 -12
  25. package/tests/e2e/process.test.js +34 -10
  26. package/tests/fixtures/habr-articles.js +45 -0
  27. package/tests/integration/api-endpoints.test.js +231 -0
  28. package/tests/integration/habr-article.test.js +337 -0
  29. package/tests/mock/index.test.js +5 -3
  30. package/tests/unit/animation.test.js +33 -0
  31. package/tests/unit/batch.test.js +114 -0
  32. package/tests/unit/browser.test.js +38 -2
  33. package/tests/unit/figures.test.js +74 -0
  34. package/tests/unit/gdocs.test.js +203 -0
  35. package/tests/unit/latex.test.js +123 -0
  36. package/tests/unit/localize-images.test.js +78 -0
  37. package/tests/unit/metadata.test.js +140 -0
  38. package/tests/unit/postprocess.test.js +108 -0
  39. package/tests/unit/retry.test.js +111 -0
  40. package/tests/unit/verify.test.js +182 -0
  41. package/.github/workflows/release.yml +0 -294
  42. package/ARCHITECTURE.md +0 -982
  43. package/LICENSE +0 -24
  44. package/apply.sh +0 -166
  45. package/docs/market-analysis-2025-05-13.md +0 -64
  46. package/scripts/changeset-version.mjs +0 -38
  47. package/scripts/create-github-release.mjs +0 -93
  48. package/scripts/create-manual-changeset.mjs +0 -88
  49. package/scripts/format-github-release.mjs +0 -83
  50. package/scripts/format-release-notes.mjs +0 -218
  51. package/scripts/instant-version-bump.mjs +0 -121
  52. package/scripts/publish-to-npm.mjs +0 -128
  53. package/scripts/setup-npm.mjs +0 -37
  54. package/scripts/validate-changeset.mjs +0 -106
  55. 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 browser = await createBrowser(engine);
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: 1280, height: 800 });
53
+ await page.setViewport({ width, height });
23
54
  await page.goto(absoluteUrl, {
24
55
  waitUntil: 'networkidle0',
25
56
  timeout: 30000,
26
57
  });
27
- // Wait for 5 seconds after page load
58
+
59
+ // Wait for dynamic content
28
60
  await new Promise((resolve) => setTimeout(resolve, 5000));
29
- // Take a screenshot of just the viewport (not the full page)
30
- const buffer = await page.screenshot({ type: 'png' });
31
- res.set('Content-Type', 'image/png');
32
- res.set('Content-Disposition', 'inline; filename="screenshot.png"');
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 =