@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
@@ -0,0 +1,265 @@
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
+ // Replace non-breaking spaces (U+00A0) with regular spaces.
75
+ // GitHub's math renderer doesn't recognize \xa0 as a word boundary
76
+ // for inline math $...$ delimiters.
77
+ result = result.replace(/\u00A0/g, ' ');
78
+
79
+ // Normalize curly quotes to straight quotes
80
+ result = result.replace(/[\u2018\u2019]/g, "'");
81
+ result = result.replace(/[\u201C\u201D]/g, '"');
82
+
83
+ // Normalize em-dash and en-dash
84
+ result = result.replace(/\u2014/g, ' \u2014 '); // em-dash with spaces
85
+ result = result.replace(/\u2013/g, '-'); // en-dash to hyphen
86
+
87
+ // Normalize ellipsis
88
+ result = result.replace(/\u2026/g, '...');
89
+
90
+ return result;
91
+ }
92
+
93
+ /**
94
+ * Fix spacing around inline LaTeX formulas for GitHub rendering.
95
+ * Uses a line-by-line token-based approach to correctly identify
96
+ * opening/closing $ delimiters.
97
+ *
98
+ * @param {string} text - Input text
99
+ * @returns {string} Text with fixed formula spacing
100
+ */
101
+ export function applyLatexSpacingFixes(text) {
102
+ return text
103
+ .split('\n')
104
+ .map((line) => {
105
+ // Skip block formula lines ($$...$$) and blockquote block formulas
106
+ const trimmedLine = line.replace(/^>\s*/, '');
107
+ if (trimmedLine.startsWith('$$') && trimmedLine.endsWith('$$')) {
108
+ return line;
109
+ }
110
+
111
+ // Find all inline formula spans by tracking $ delimiters
112
+ const formulas = [];
113
+ let i = 0;
114
+ while (i < line.length) {
115
+ if (line[i] === '$' && (i === 0 || line[i - 1] !== '\\')) {
116
+ // Skip $$ block delimiters
117
+ if (line[i + 1] === '$') {
118
+ i += 2;
119
+ continue;
120
+ }
121
+ // Found opening $, find closing $
122
+ const start = i;
123
+ i++;
124
+ while (i < line.length && (line[i] !== '$' || line[i - 1] === '\\')) {
125
+ i++;
126
+ }
127
+ if (i < line.length) {
128
+ formulas.push({ start, end: i });
129
+ i++;
130
+ }
131
+ } else {
132
+ i++;
133
+ }
134
+ }
135
+
136
+ if (formulas.length === 0) {
137
+ return line;
138
+ }
139
+
140
+ // Build the line with fixes applied
141
+ let fixed = '';
142
+ let pos = 0;
143
+ for (const f of formulas) {
144
+ fixed += line.substring(pos, f.start);
145
+
146
+ const rawInner = line.substring(f.start + 1, f.end);
147
+ const inner = rawInner.trim();
148
+
149
+ // Add space before formula if preceded by word char, comma, colon, etc.
150
+ if (
151
+ fixed.length > 0 &&
152
+ /[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u0401,:;\u00BB)\]]$/.test(
153
+ fixed
154
+ )
155
+ ) {
156
+ fixed += ' ';
157
+ }
158
+
159
+ fixed += `$${inner}$`;
160
+
161
+ // Add space after formula if followed by word character
162
+ const afterPos = f.end + 1;
163
+ if (
164
+ afterPos < line.length &&
165
+ /^[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u0401]/.test(line[afterPos])
166
+ ) {
167
+ fixed += ' ';
168
+ }
169
+
170
+ pos = f.end + 1;
171
+ }
172
+ fixed += line.substring(pos);
173
+
174
+ return fixed;
175
+ })
176
+ .join('\n');
177
+ }
178
+
179
+ /**
180
+ * Fix percent sign in inline formulas for GitHub KaTeX rendering.
181
+ * GitHub's KaTeX treats % as a LaTeX comment character.
182
+ * Workaround: use \\% which GitHub's preprocessor converts to \%.
183
+ *
184
+ * @param {string} text - Input text
185
+ * @returns {string} Text with fixed percent signs
186
+ */
187
+ export function applyPercentSignFix(text) {
188
+ let result = text;
189
+ result = result.replace(/\$(\d+)\\+%\$/g, '$$$1\\\\%$$');
190
+ result = result.replace(/\$(\d+)\\text\{%\}\$/g, '$$$1\\\\%$$');
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * Clean up bold formatting artifacts from HTML-to-markdown conversion.
196
+ *
197
+ * @param {string} text - Input text
198
+ * @returns {string} Text with cleaned bold formatting
199
+ */
200
+ export function applyBoldFormattingFixes(text) {
201
+ let result = text;
202
+
203
+ // Remove empty bold markers
204
+ result = result.replace(/(\S)\*\*[^\S\n]*\*\*(\S)/g, '$1 $2');
205
+ result = result.replace(/\*\*[^\S\n]*\*\*/g, '');
206
+
207
+ // Fix bold marker spacing: trim content inside **...**
208
+ result = result
209
+ .split('\n')
210
+ .map((line) => {
211
+ const parts = [];
212
+ let lastIndex = 0;
213
+ const boldRegex = /\*\*(.+?)\*\*/g;
214
+ let m;
215
+ while ((m = boldRegex.exec(line)) !== null) {
216
+ parts.push({
217
+ type: 'text',
218
+ content: line.substring(lastIndex, m.index),
219
+ });
220
+ parts.push({ type: 'bold', content: m[1].trim() });
221
+ lastIndex = m.index + m[0].length;
222
+ }
223
+ parts.push({
224
+ type: 'text',
225
+ content: line.substring(lastIndex),
226
+ });
227
+
228
+ if (parts.filter((p) => p.type === 'bold').length === 0) {
229
+ return line;
230
+ }
231
+
232
+ let rebuilt = '';
233
+ for (let i = 0; i < parts.length; i++) {
234
+ const part = parts[i];
235
+ if (part.type === 'bold') {
236
+ if (!part.content) {
237
+ continue;
238
+ }
239
+ if (
240
+ rebuilt.length > 0 &&
241
+ /[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u04010-9).]$/.test(rebuilt)
242
+ ) {
243
+ rebuilt += ' ';
244
+ }
245
+ rebuilt += `**${part.content}**`;
246
+ const nextPart = parts[i + 1];
247
+ if (
248
+ nextPart &&
249
+ nextPart.content &&
250
+ /^[a-zA-Z\u0430-\u044F\u0410-\u042F\u0451\u0401[(]/.test(
251
+ nextPart.content
252
+ )
253
+ ) {
254
+ rebuilt += ' ';
255
+ }
256
+ } else {
257
+ rebuilt += part.content;
258
+ }
259
+ }
260
+ return rebuilt;
261
+ })
262
+ .join('\n');
263
+
264
+ return result;
265
+ }
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
+ }