@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
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 entities for clear marking
|
|
75
|
+
result = result.replace(/\u00A0/g, ' ');
|
|
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
|
+
}
|