@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +57 -13
  2. package/Dockerfile +26 -21
  3. package/README.md +290 -160
  4. package/bin/web-capture.js +622 -17
  5. package/package.json +15 -4
  6. package/src/animation.js +283 -0
  7. package/src/archive.js +225 -0
  8. package/src/batch.js +180 -0
  9. package/src/browser.js +150 -111
  10. package/src/docx.js +182 -0
  11. package/src/extract-images.js +120 -0
  12. package/src/figures.js +208 -0
  13. package/src/gdocs.js +335 -0
  14. package/src/image.js +65 -8
  15. package/src/index.js +15 -0
  16. package/src/latex.js +127 -0
  17. package/src/lib.js +281 -1
  18. package/src/localize-images.js +202 -0
  19. package/src/markdown.js +7 -2
  20. package/src/metadata.js +333 -0
  21. package/src/pdf.js +76 -0
  22. package/src/popups.js +171 -0
  23. package/src/postprocess.js +263 -0
  24. package/src/retry.js +48 -0
  25. package/src/themed-image.js +167 -0
  26. package/src/verify.js +312 -0
  27. package/tests/e2e/docker.test.js +51 -12
  28. package/tests/e2e/process.test.js +34 -10
  29. package/tests/fixtures/habr-articles.js +45 -0
  30. package/tests/integration/api-endpoints.test.js +231 -0
  31. package/tests/integration/habr-article.test.js +337 -0
  32. package/tests/mock/index.test.js +5 -3
  33. package/tests/unit/animation.test.js +33 -0
  34. package/tests/unit/batch.test.js +114 -0
  35. package/tests/unit/browser.test.js +38 -2
  36. package/tests/unit/extract-images.test.js +233 -0
  37. package/tests/unit/figures.test.js +74 -0
  38. package/tests/unit/gdocs.test.js +278 -0
  39. package/tests/unit/heading-image.test.js +49 -0
  40. package/tests/unit/latex.test.js +123 -0
  41. package/tests/unit/localize-images.test.js +78 -0
  42. package/tests/unit/metadata.test.js +140 -0
  43. package/tests/unit/postprocess.test.js +110 -0
  44. package/tests/unit/retry.test.js +111 -0
  45. package/tests/unit/verify.test.js +182 -0
  46. package/.github/workflows/release.yml +0 -294
  47. package/ARCHITECTURE.md +0 -982
  48. package/LICENSE +0 -24
  49. package/apply.sh +0 -166
  50. package/docs/market-analysis-2025-05-13.md +0 -64
  51. package/scripts/changeset-version.mjs +0 -38
  52. package/scripts/create-github-release.mjs +0 -93
  53. package/scripts/create-manual-changeset.mjs +0 -88
  54. package/scripts/format-github-release.mjs +0 -83
  55. package/scripts/format-release-notes.mjs +0 -218
  56. package/scripts/instant-version-bump.mjs +0 -121
  57. package/scripts/publish-to-npm.mjs +0 -128
  58. package/scripts/setup-npm.mjs +0 -37
  59. package/scripts/validate-changeset.mjs +0 -106
  60. package/scripts/version-and-commit.mjs +0 -237
package/src/popups.js ADDED
@@ -0,0 +1,171 @@
1
+ /* eslint-disable no-undef */
2
+ /**
3
+ * Popup/modal/cookie banner detection and auto-close utilities.
4
+ *
5
+ * Handles multiple types of overlays including:
6
+ * - Google Funding Choices (FC) consent dialogs
7
+ * - Habr cookie banners (tm-cookie-banner)
8
+ * - Generic modals, overlays, and notification popups
9
+ *
10
+ * Based on best practices from https://github.com/link-foundation/meta-theory
11
+ */
12
+
13
+ /**
14
+ * Close any popup overlays/modals on a page adapter (from browser.js).
15
+ * Should be called after page content has loaded and before taking screenshots.
16
+ *
17
+ * Uses browser-commander APIs:
18
+ * - page.onDialog() – dismiss browser-level alert/confirm dialogs (v0.7.0+)
19
+ * - page.keyboard – press Escape to dismiss remaining popups (v0.7.0+)
20
+ * - page.evaluate() – run JS in the browser context to click close buttons
21
+ *
22
+ * @param {Object} page - A PageAdapter from browser.js
23
+ */
24
+ export async function dismissPopups(page) {
25
+ // Handle browser-level dialog events (alert, confirm, prompt) using
26
+ // browser-commander's dialog API (v0.7.0+)
27
+ page.onDialog(async (dialog) => {
28
+ try {
29
+ await dialog.dismiss();
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ });
34
+
35
+ // Try to click the Google Funding Choices "Consent" button first
36
+ const fcConsentClicked = await page.evaluate(() => {
37
+ const consentBtn = document.querySelector('.fc-cta-consent');
38
+ if (consentBtn) {
39
+ try {
40
+ consentBtn.click();
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ return false;
47
+ });
48
+
49
+ if (fcConsentClicked) {
50
+ await sleep(page, 1000);
51
+ }
52
+
53
+ // Click all known close/dismiss buttons and remove overlay elements
54
+ await page.evaluate(() => {
55
+ const closeSelectors = [
56
+ // Google Funding Choices (FC) consent dialog
57
+ '.fc-cta-consent',
58
+ '.fc-close',
59
+ '.fc-dismiss-button',
60
+ // Cookie consent
61
+ '.tm-cookie-banner__close',
62
+ '.cookie-banner__close',
63
+ '[data-test-id="cookie-banner-close"]',
64
+ // Consent popup buttons
65
+ '.consent-popup__close',
66
+ '.consent__close',
67
+ 'button[data-testid="consent-close"]',
68
+ 'button[data-testid="consent-reject"]',
69
+ // Generic close buttons
70
+ '.tm-popup__close',
71
+ '.popup__close',
72
+ '.modal__close',
73
+ '.overlay__close',
74
+ '[aria-label="Close"]',
75
+ '[aria-label="Закрыть"]',
76
+ '.tm-base-modal__close',
77
+ '.tm-notification__close',
78
+ ];
79
+
80
+ for (const selector of closeSelectors) {
81
+ const els = document.querySelectorAll(selector);
82
+ for (const el of els) {
83
+ try {
84
+ el.click();
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ }
90
+
91
+ // Remove Google FC consent dialog elements from DOM
92
+ for (const sel of ['.fc-consent-root', '.fc-dialog-overlay']) {
93
+ const el = document.querySelector(sel);
94
+ if (el) {
95
+ try {
96
+ el.remove();
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+ }
102
+
103
+ // Hide fixed-position overlays that cover content
104
+ const allEls = document.querySelectorAll('*');
105
+ for (const el of allEls) {
106
+ const style = getComputedStyle(el);
107
+ if (style.position === 'fixed' && el.offsetParent !== null) {
108
+ const rect = el.getBoundingClientRect();
109
+ if (
110
+ rect.height > 200 ||
111
+ (el.className &&
112
+ el.className.match &&
113
+ el.className.match(
114
+ /popup|modal|overlay|banner|cookie|notification|consent|fc-/i
115
+ ))
116
+ ) {
117
+ el.style.display = 'none';
118
+ }
119
+ }
120
+ }
121
+ });
122
+
123
+ await sleep(page, 500);
124
+
125
+ // Press Escape to dismiss remaining popups using browser-commander's keyboard API (v0.7.0+)
126
+ try {
127
+ await page.keyboard.press('Escape');
128
+ await sleep(page, 300);
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Scroll through the page to trigger lazy-loaded content.
136
+ * Makes multiple passes for media-heavy pages.
137
+ *
138
+ * @param {Object} page - PageAdapter from browser.js
139
+ * @param {Object} options
140
+ * @param {number} options.passes - Number of scroll passes (default: 2)
141
+ * @param {number} options.stepDelay - Delay between scroll steps in ms (default: 100)
142
+ * @param {number} options.passDelay - Delay between passes in ms (default: 1000)
143
+ */
144
+ export async function scrollToLoadContent(page, options = {}) {
145
+ const { passes = 2, stepDelay = 100, passDelay = 1000 } = options;
146
+
147
+ for (let pass = 0; pass < passes; pass++) {
148
+ await page.evaluate(
149
+ async ({ delay }) => {
150
+ const scrollHeight = document.documentElement.scrollHeight;
151
+ const viewportHeight = window.innerHeight;
152
+ const scrollSteps = Math.ceil(scrollHeight / viewportHeight);
153
+ for (let i = 0; i < scrollSteps; i++) {
154
+ window.scrollTo(0, i * viewportHeight);
155
+ await new Promise((resolve) => setTimeout(resolve, delay));
156
+ }
157
+ window.scrollTo(0, 0);
158
+ },
159
+ { delay: stepDelay }
160
+ );
161
+ await sleep(page, passDelay);
162
+ }
163
+
164
+ // Extra wait for images to finish loading
165
+ await sleep(page, 2000);
166
+ }
167
+
168
+ /** Portable sleep using standard Promise-based timeout */
169
+ async function sleep(_page, ms) {
170
+ await new Promise((resolve) => setTimeout(resolve, ms));
171
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Markdown post-processing pipeline.
3
+ *
4
+ * Applies a series of text transformations to improve markdown quality:
5
+ * - Unicode normalization (non-breaking spaces, curly quotes, dashes)
6
+ * - LaTeX formula spacing fixes for GitHub rendering
7
+ * - Bold formatting cleanup
8
+ * - Percent sign fix for GitHub KaTeX
9
+ *
10
+ * Based on reference implementation from:
11
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/download-article.mjs
12
+ *
13
+ * @module postprocess
14
+ */
15
+
16
+ /**
17
+ * Apply all post-processing transformations to markdown text.
18
+ *
19
+ * @param {string} markdown - Raw markdown text
20
+ * @param {Object} [options] - Processing options
21
+ * @param {boolean} [options.normalizeUnicode=true] - Normalize unicode characters
22
+ * @param {boolean} [options.fixLatexSpacing=true] - Fix spacing around LaTeX formulas
23
+ * @param {boolean} [options.fixBoldFormatting=true] - Clean up bold formatting artifacts
24
+ * @param {boolean} [options.fixPercentSign=true] - Fix percent signs in formulas
25
+ * @returns {string} Post-processed markdown
26
+ */
27
+ export function postProcessMarkdown(markdown, options = {}) {
28
+ const {
29
+ normalizeUnicode = true,
30
+ fixLatexSpacing = true,
31
+ fixBoldFormatting = true,
32
+ fixPercentSign = true,
33
+ } = options;
34
+
35
+ let result = markdown;
36
+
37
+ if (normalizeUnicode) {
38
+ result = applyUnicodeNormalization(result);
39
+ }
40
+
41
+ if (fixLatexSpacing) {
42
+ result = applyLatexSpacingFixes(result);
43
+ }
44
+
45
+ if (fixPercentSign) {
46
+ result = applyPercentSignFix(result);
47
+ }
48
+
49
+ if (fixBoldFormatting) {
50
+ result = applyBoldFormattingFixes(result);
51
+ }
52
+
53
+ // Fix double spaces (but not in code blocks)
54
+ result = result.replace(/([^\n`]) +/g, (match, char) => `${char} `);
55
+
56
+ // Clean up extra spaces around em-dashes
57
+ result = result.replace(/\s+—\s+/g, ' — ');
58
+
59
+ // Fix stray standalone $ signs on their own line
60
+ result = result.replace(/^\$\s*$/gm, '');
61
+
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * Normalize unicode characters for consistent rendering.
67
+ *
68
+ * @param {string} text - Input text
69
+ * @returns {string} Normalized text
70
+ */
71
+ export function applyUnicodeNormalization(text) {
72
+ let result = text;
73
+
74
+ // Preserve non-breaking spaces as &nbsp; entities for clear marking
75
+ result = result.replace(/\u00A0/g, '&nbsp;');
76
+
77
+ // Normalize curly quotes to straight quotes
78
+ result = result.replace(/[\u2018\u2019]/g, "'");
79
+ result = result.replace(/[\u201C\u201D]/g, '"');
80
+
81
+ // Normalize em-dash and en-dash
82
+ result = result.replace(/\u2014/g, ' \u2014 '); // em-dash with spaces
83
+ result = result.replace(/\u2013/g, '-'); // en-dash to hyphen
84
+
85
+ // Normalize ellipsis
86
+ result = result.replace(/\u2026/g, '...');
87
+
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Fix spacing around inline LaTeX formulas for GitHub rendering.
93
+ * Uses a line-by-line token-based approach to correctly identify
94
+ * opening/closing $ delimiters.
95
+ *
96
+ * @param {string} text - Input text
97
+ * @returns {string} Text with fixed formula spacing
98
+ */
99
+ export function applyLatexSpacingFixes(text) {
100
+ return text
101
+ .split('\n')
102
+ .map((line) => {
103
+ // Skip block formula lines ($$...$$) and blockquote block formulas
104
+ const trimmedLine = line.replace(/^>\s*/, '');
105
+ if (trimmedLine.startsWith('$$') && trimmedLine.endsWith('$$')) {
106
+ return line;
107
+ }
108
+
109
+ // Find all inline formula spans by tracking $ delimiters
110
+ const formulas = [];
111
+ let i = 0;
112
+ while (i < line.length) {
113
+ if (line[i] === '$' && (i === 0 || line[i - 1] !== '\\')) {
114
+ // Skip $$ block delimiters
115
+ if (line[i + 1] === '$') {
116
+ i += 2;
117
+ continue;
118
+ }
119
+ // Found opening $, find closing $
120
+ const start = i;
121
+ i++;
122
+ while (i < line.length && (line[i] !== '$' || line[i - 1] === '\\')) {
123
+ i++;
124
+ }
125
+ if (i < line.length) {
126
+ formulas.push({ start, end: i });
127
+ i++;
128
+ }
129
+ } else {
130
+ i++;
131
+ }
132
+ }
133
+
134
+ if (formulas.length === 0) {
135
+ return line;
136
+ }
137
+
138
+ // Build the line with fixes applied
139
+ let fixed = '';
140
+ let pos = 0;
141
+ for (const f of formulas) {
142
+ fixed += line.substring(pos, f.start);
143
+
144
+ const rawInner = line.substring(f.start + 1, f.end);
145
+ const inner = rawInner.trim();
146
+
147
+ // Add space before formula if preceded by word char, comma, colon, etc.
148
+ if (
149
+ fixed.length > 0 &&
150
+ /[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u0401,:;\u00BB)\]]$/.test(
151
+ fixed
152
+ )
153
+ ) {
154
+ fixed += ' ';
155
+ }
156
+
157
+ fixed += `$${inner}$`;
158
+
159
+ // Add space after formula if followed by word character
160
+ const afterPos = f.end + 1;
161
+ if (
162
+ afterPos < line.length &&
163
+ /^[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u0401]/.test(line[afterPos])
164
+ ) {
165
+ fixed += ' ';
166
+ }
167
+
168
+ pos = f.end + 1;
169
+ }
170
+ fixed += line.substring(pos);
171
+
172
+ return fixed;
173
+ })
174
+ .join('\n');
175
+ }
176
+
177
+ /**
178
+ * Fix percent sign in inline formulas for GitHub KaTeX rendering.
179
+ * GitHub's KaTeX treats % as a LaTeX comment character.
180
+ * Workaround: use \\% which GitHub's preprocessor converts to \%.
181
+ *
182
+ * @param {string} text - Input text
183
+ * @returns {string} Text with fixed percent signs
184
+ */
185
+ export function applyPercentSignFix(text) {
186
+ let result = text;
187
+ result = result.replace(/\$(\d+)\\+%\$/g, '$$$1\\\\%$$');
188
+ result = result.replace(/\$(\d+)\\text\{%\}\$/g, '$$$1\\\\%$$');
189
+ return result;
190
+ }
191
+
192
+ /**
193
+ * Clean up bold formatting artifacts from HTML-to-markdown conversion.
194
+ *
195
+ * @param {string} text - Input text
196
+ * @returns {string} Text with cleaned bold formatting
197
+ */
198
+ export function applyBoldFormattingFixes(text) {
199
+ let result = text;
200
+
201
+ // Remove empty bold markers
202
+ result = result.replace(/(\S)\*\*[^\S\n]*\*\*(\S)/g, '$1 $2');
203
+ result = result.replace(/\*\*[^\S\n]*\*\*/g, '');
204
+
205
+ // Fix bold marker spacing: trim content inside **...**
206
+ result = result
207
+ .split('\n')
208
+ .map((line) => {
209
+ const parts = [];
210
+ let lastIndex = 0;
211
+ const boldRegex = /\*\*(.+?)\*\*/g;
212
+ let m;
213
+ while ((m = boldRegex.exec(line)) !== null) {
214
+ parts.push({
215
+ type: 'text',
216
+ content: line.substring(lastIndex, m.index),
217
+ });
218
+ parts.push({ type: 'bold', content: m[1].trim() });
219
+ lastIndex = m.index + m[0].length;
220
+ }
221
+ parts.push({
222
+ type: 'text',
223
+ content: line.substring(lastIndex),
224
+ });
225
+
226
+ if (parts.filter((p) => p.type === 'bold').length === 0) {
227
+ return line;
228
+ }
229
+
230
+ let rebuilt = '';
231
+ for (let i = 0; i < parts.length; i++) {
232
+ const part = parts[i];
233
+ if (part.type === 'bold') {
234
+ if (!part.content) {
235
+ continue;
236
+ }
237
+ if (
238
+ rebuilt.length > 0 &&
239
+ /[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u04010-9).]$/.test(rebuilt)
240
+ ) {
241
+ rebuilt += ' ';
242
+ }
243
+ rebuilt += `**${part.content}**`;
244
+ const nextPart = parts[i + 1];
245
+ if (
246
+ nextPart &&
247
+ nextPart.content &&
248
+ /^[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u0401[(]/.test(
249
+ nextPart.content
250
+ )
251
+ ) {
252
+ rebuilt += ' ';
253
+ }
254
+ } else {
255
+ rebuilt += part.content;
256
+ }
257
+ }
258
+ return rebuilt;
259
+ })
260
+ .join('\n');
261
+
262
+ return result;
263
+ }
package/src/retry.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Retry utility with exponential backoff.
3
+ *
4
+ * Used for network-dependent operations (fetching HTML, downloading images, etc.)
5
+ * to handle transient failures gracefully.
6
+ *
7
+ * @param {Function} fn - Async function to retry
8
+ * @param {Object} options
9
+ * @param {number} options.retries - Maximum number of retries (default: 3)
10
+ * @param {number} options.baseDelay - Initial delay in ms (default: 1000)
11
+ * @param {number} options.maxDelay - Maximum delay in ms (default: 30000)
12
+ * @param {number} options.factor - Exponential backoff factor (default: 2)
13
+ * @param {Function} options.onRetry - Optional callback(error, attempt) called before each retry
14
+ * @returns {Promise} Result of the function call
15
+ */
16
+ export async function retry(fn, options = {}) {
17
+ const {
18
+ retries = 3,
19
+ baseDelay = 1000,
20
+ maxDelay = 30000,
21
+ factor = 2,
22
+ onRetry,
23
+ } = options;
24
+
25
+ let lastError;
26
+
27
+ for (let attempt = 0; attempt <= retries; attempt++) {
28
+ try {
29
+ return await fn(attempt);
30
+ } catch (error) {
31
+ lastError = error;
32
+
33
+ if (attempt >= retries) {
34
+ break;
35
+ }
36
+
37
+ const delay = Math.min(baseDelay * Math.pow(factor, attempt), maxDelay);
38
+
39
+ if (onRetry) {
40
+ onRetry(error, attempt + 1, delay);
41
+ }
42
+
43
+ await new Promise((resolve) => setTimeout(resolve, delay));
44
+ }
45
+ }
46
+
47
+ throw lastError;
48
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Dual-themed screenshot capture module (R3).
3
+ *
4
+ * Captures screenshots in both light and dark themes in a single operation.
5
+ * Uses separate browser contexts for reliable colorScheme application.
6
+ *
7
+ * Based on reference implementation from:
8
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/download.mjs
9
+ *
10
+ * @module themed-image
11
+ */
12
+
13
+ import { createBrowser, getBrowserEngine } from './browser.js';
14
+ import { dismissPopups, scrollToLoadContent } from './popups.js';
15
+
16
+ /**
17
+ * Capture a single themed screenshot.
18
+ *
19
+ * @param {Object} browser - Browser instance from createBrowser
20
+ * @param {string} url - URL to capture
21
+ * @param {string} theme - 'light' or 'dark'
22
+ * @param {Object} [options] - Screenshot options
23
+ * @param {number} [options.width=1920] - Viewport width
24
+ * @param {number} [options.height=1080] - Viewport height
25
+ * @param {boolean} [options.fullPage=true] - Full page capture
26
+ * @param {boolean} [options.dismissPopups=true] - Dismiss popups
27
+ * @returns {Promise<Buffer>} Screenshot buffer
28
+ */
29
+ async function captureThemedScreenshot(browser, url, theme, options = {}) {
30
+ const {
31
+ width = 1920,
32
+ height = 1080,
33
+ fullPage = true,
34
+ dismissPopups: shouldDismissPopups = true,
35
+ } = options;
36
+
37
+ // Create browser with theme-specific color scheme
38
+ const themeOpts = { colorScheme: theme };
39
+ const themedBrowser = await createBrowser(
40
+ browser.type || 'puppeteer',
41
+ themeOpts
42
+ );
43
+
44
+ try {
45
+ const page = await themedBrowser.newPage();
46
+ await page.setViewport({ width, height });
47
+ await page.setUserAgent(
48
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
49
+ );
50
+
51
+ await page.goto(url, {
52
+ waitUntil: 'networkidle0',
53
+ timeout: 30000,
54
+ });
55
+
56
+ await new Promise((resolve) => setTimeout(resolve, 5000));
57
+
58
+ if (fullPage) {
59
+ await scrollToLoadContent(page);
60
+ }
61
+
62
+ if (shouldDismissPopups) {
63
+ await dismissPopups(page);
64
+ // eslint-disable-next-line no-undef
65
+ await page.evaluate(() => window.scrollTo(0, 0));
66
+ await new Promise((resolve) => setTimeout(resolve, 500));
67
+ }
68
+
69
+ const buffer = await page.screenshot({
70
+ type: 'png',
71
+ fullPage,
72
+ });
73
+
74
+ return buffer;
75
+ } finally {
76
+ await themedBrowser.close();
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Capture screenshots in both light and dark themes.
82
+ *
83
+ * @param {string} url - URL to capture
84
+ * @param {Object} [options] - Options
85
+ * @param {string} [options.engine='puppeteer'] - Browser engine
86
+ * @param {number} [options.width=1920] - Viewport width
87
+ * @param {number} [options.height=1080] - Viewport height
88
+ * @param {boolean} [options.fullPage=true] - Full page capture
89
+ * @param {boolean} [options.dismissPopups=true] - Dismiss popups
90
+ * @returns {Promise<Object>} Result with light and dark screenshot buffers
91
+ */
92
+ export async function captureDualThemeScreenshots(url, options = {}) {
93
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
94
+ const engine = options.engine || 'puppeteer';
95
+
96
+ // Capture light theme
97
+ const lightBuffer = await captureThemedScreenshot(
98
+ { type: engine },
99
+ absoluteUrl,
100
+ 'light',
101
+ options
102
+ );
103
+
104
+ // Capture dark theme
105
+ const darkBuffer = await captureThemedScreenshot(
106
+ { type: engine },
107
+ absoluteUrl,
108
+ 'dark',
109
+ options
110
+ );
111
+
112
+ return {
113
+ light: lightBuffer,
114
+ dark: darkBuffer,
115
+ url: absoluteUrl,
116
+ width: options.width || 1920,
117
+ height: options.height || 1080,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Dual-themed screenshot handler for Express API.
123
+ *
124
+ * Query parameters:
125
+ * url (required) - URL to capture
126
+ * engine - 'puppeteer' or 'playwright'
127
+ * width - viewport width (default 1920)
128
+ * height - viewport height (default 1080)
129
+ * fullPage - 'true' (default) to capture full page
130
+ * dismissPopups - 'true' (default) to dismiss popups
131
+ */
132
+ export async function themedImageHandler(req, res) {
133
+ const url = req.query.url;
134
+ if (!url) {
135
+ return res.status(400).send('Missing `url` parameter');
136
+ }
137
+
138
+ try {
139
+ const engine = getBrowserEngine(req);
140
+ const width = parseInt(req.query.width, 10) || 1920;
141
+ const height = parseInt(req.query.height, 10) || 1080;
142
+ const fullPage = req.query.fullPage !== 'false';
143
+ const shouldDismissPopups = req.query.dismissPopups !== 'false';
144
+
145
+ const result = await captureDualThemeScreenshots(url, {
146
+ engine,
147
+ width,
148
+ height,
149
+ fullPage,
150
+ dismissPopups: shouldDismissPopups,
151
+ });
152
+
153
+ // Return as JSON with base64-encoded images
154
+ res.json({
155
+ url: result.url,
156
+ width: result.width,
157
+ height: result.height,
158
+ light: result.light.toString('base64'),
159
+ dark: result.dark.toString('base64'),
160
+ lightSize: result.light.length,
161
+ darkSize: result.dark.length,
162
+ });
163
+ } catch (err) {
164
+ console.error(err);
165
+ res.status(500).send('Error capturing themed screenshots');
166
+ }
167
+ }