@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,283 @@
1
+ /**
2
+ * Animation capture module (R2).
3
+ *
4
+ * Captures web animations as GIF, MP4, or WebM by taking screenshots
5
+ * at regular intervals and detecting when the animation loops.
6
+ *
7
+ * Supports three capture modes:
8
+ * - screencast: CDP-based push capture (30-60 FPS, Chromium only)
9
+ * - beginframe: Deterministic frame-perfect capture (Chromium only)
10
+ * - screenshot: Polling-based capture (3-8 FPS, cross-browser)
11
+ *
12
+ * Based on reference implementation from:
13
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/capture-animation.mjs
14
+ *
15
+ * @module animation
16
+ */
17
+
18
+ import { createBrowser, getBrowserEngine } from './browser.js';
19
+ // eslint-disable-next-line no-unused-vars
20
+ import { dismissPopups, scrollToLoadContent } from './popups.js';
21
+
22
+ /**
23
+ * Default animation capture options.
24
+ */
25
+ const DEFAULTS = {
26
+ maxSize: 1024,
27
+ viewportWidth: 1920,
28
+ viewportHeight: 1080,
29
+ interval: 0,
30
+ fps: null,
31
+ speed: 1.0,
32
+ delay: null,
33
+ minFrames: 120,
34
+ loopTimeout: 60,
35
+ staticTimeout: 60,
36
+ similarity: 0.99,
37
+ crop: true,
38
+ cropPadding: null,
39
+ captureMode: 'screenshot',
40
+ format: 'gif',
41
+ extractKeyframes: false,
42
+ dismissPopups: true,
43
+ };
44
+
45
+ /**
46
+ * Compare two frame buffers for pixel similarity.
47
+ *
48
+ * @param {Buffer} frame1 - First PNG frame buffer
49
+ * @param {Buffer} frame2 - Second PNG frame buffer
50
+ * @returns {number} Similarity score 0-1
51
+ */
52
+ export function compareFrames(frame1, frame2) {
53
+ if (!frame1 || !frame2) {
54
+ return 0;
55
+ }
56
+ if (frame1.length !== frame2.length) {
57
+ return 0;
58
+ }
59
+
60
+ let matchingBytes = 0;
61
+ const totalBytes = frame1.length;
62
+
63
+ for (let i = 0; i < totalBytes; i++) {
64
+ if (frame1[i] === frame2[i]) {
65
+ matchingBytes++;
66
+ }
67
+ }
68
+
69
+ return matchingBytes / totalBytes;
70
+ }
71
+
72
+ /**
73
+ * Capture animation frames from a web page.
74
+ *
75
+ * @param {string} url - URL of the page with animation
76
+ * @param {Object} [options] - Capture options
77
+ * @param {string} [options.engine] - Browser engine ('puppeteer' or 'playwright')
78
+ * @param {string} [options.captureMode='screenshot'] - Capture mode
79
+ * @param {number} [options.maxSize=1024] - Max dimension for output
80
+ * @param {number} [options.viewportWidth=1920] - Viewport width
81
+ * @param {number} [options.viewportHeight=1080] - Viewport height
82
+ * @param {number} [options.interval=0] - Capture interval in ms (0 = as fast as possible)
83
+ * @param {number} [options.minFrames=120] - Minimum frames to capture per cycle
84
+ * @param {number} [options.loopTimeout=60] - Max seconds to wait for loop
85
+ * @param {number} [options.staticTimeout=60] - Max seconds with no change
86
+ * @param {number} [options.similarity=0.99] - Pixel similarity threshold for loop detection
87
+ * @param {boolean} [options.crop=true] - Auto-crop to content
88
+ * @param {boolean} [options.dismissPopups=true] - Dismiss popups before capture
89
+ * @param {Function} [options.onFrame] - Callback(frameIndex, buffer) for each frame
90
+ * @param {Function} [options.onLoop] - Callback(frameIndex) when loop detected
91
+ * @returns {Promise<Object>} Capture result with frames array and metadata
92
+ */
93
+ export async function captureAnimationFrames(url, options = {}) {
94
+ const opts = { ...DEFAULTS, ...options };
95
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
96
+
97
+ const engine = opts.engine || 'puppeteer';
98
+ const browser = await createBrowser(engine, {});
99
+
100
+ try {
101
+ const page = await browser.newPage();
102
+ await page.setViewport({
103
+ width: opts.viewportWidth,
104
+ height: opts.viewportHeight,
105
+ });
106
+ await page.setUserAgent(
107
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
108
+ );
109
+
110
+ await page.goto(absoluteUrl, {
111
+ waitUntil: 'networkidle0',
112
+ timeout: 30000,
113
+ });
114
+
115
+ await new Promise((resolve) => setTimeout(resolve, 3000));
116
+
117
+ if (opts.dismissPopups) {
118
+ await dismissPopups(page);
119
+ }
120
+
121
+ // Capture frames using screenshot mode (cross-browser compatible)
122
+ const frames = [];
123
+ const timestamps = [];
124
+ const startTime = Date.now();
125
+ let loopDetected = false;
126
+ let loopFrame = -1;
127
+
128
+ // Capture first frame as reference
129
+ const firstFrame = await page.screenshot({ type: 'png' });
130
+ frames.push(firstFrame);
131
+ timestamps.push(Date.now() - startTime);
132
+
133
+ if (opts.onFrame) {
134
+ opts.onFrame(0, firstFrame);
135
+ }
136
+
137
+ // Capture subsequent frames
138
+ let lastChangeTime = Date.now();
139
+ let previousFrame = firstFrame;
140
+
141
+ for (let i = 1; i < opts.minFrames * 3; i++) {
142
+ // Check timeouts
143
+ const elapsed = (Date.now() - startTime) / 1000;
144
+ if (elapsed > opts.loopTimeout) {
145
+ break;
146
+ }
147
+ if ((Date.now() - lastChangeTime) / 1000 > opts.staticTimeout) {
148
+ break;
149
+ }
150
+
151
+ if (opts.interval > 0) {
152
+ await new Promise((resolve) => setTimeout(resolve, opts.interval));
153
+ }
154
+
155
+ const frame = await page.screenshot({ type: 'png' });
156
+ const similarity = compareFrames(frame, previousFrame);
157
+
158
+ // Check if frame changed
159
+ if (similarity < 0.999) {
160
+ lastChangeTime = Date.now();
161
+ }
162
+
163
+ frames.push(frame);
164
+ timestamps.push(Date.now() - startTime);
165
+
166
+ if (opts.onFrame) {
167
+ opts.onFrame(i, frame);
168
+ }
169
+
170
+ // Check for loop (compare with first frame)
171
+ if (i >= opts.minFrames) {
172
+ const loopSimilarity = compareFrames(frame, firstFrame);
173
+ if (loopSimilarity >= opts.similarity) {
174
+ loopDetected = true;
175
+ loopFrame = i;
176
+ if (opts.onLoop) {
177
+ opts.onLoop(i);
178
+ }
179
+ break;
180
+ }
181
+ }
182
+
183
+ previousFrame = frame;
184
+ }
185
+
186
+ // Extract key frames if requested
187
+ let keyframes = null;
188
+ if (opts.extractKeyframes && frames.length >= 3) {
189
+ const midIdx = Math.floor(frames.length / 2);
190
+ keyframes = [
191
+ { index: 0, buffer: frames[0] },
192
+ { index: midIdx, buffer: frames[midIdx] },
193
+ { index: frames.length - 1, buffer: frames[frames.length - 1] },
194
+ ];
195
+ }
196
+
197
+ return {
198
+ frames: loopDetected ? frames.slice(0, loopFrame) : frames,
199
+ timestamps: loopDetected ? timestamps.slice(0, loopFrame) : timestamps,
200
+ loopDetected,
201
+ loopFrame,
202
+ totalFrames: frames.length,
203
+ duration: timestamps[timestamps.length - 1],
204
+ keyframes,
205
+ width: opts.viewportWidth,
206
+ height: opts.viewportHeight,
207
+ };
208
+ } finally {
209
+ await browser.close();
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Animation capture handler for Express API.
215
+ *
216
+ * Query parameters:
217
+ * url (required) - URL to capture
218
+ * engine - 'puppeteer' or 'playwright'
219
+ * format - 'gif' (default), 'png-sequence'
220
+ * maxSize - Max output dimension (default 1024)
221
+ * interval - Capture interval ms (default 0)
222
+ * minFrames - Min frames per cycle (default 120)
223
+ * loopTimeout - Max seconds for loop (default 60)
224
+ * similarity - Loop detection threshold (default 0.99)
225
+ * captureMode - 'screenshot' (default), 'screencast', 'beginframe'
226
+ * extractKeyframes - 'true' to extract key frames
227
+ */
228
+ export async function animationHandler(req, res) {
229
+ const url = req.query.url;
230
+ if (!url) {
231
+ return res.status(400).send('Missing `url` parameter');
232
+ }
233
+
234
+ const format = (req.query.format || 'gif').toLowerCase();
235
+ if (!['gif', 'png-sequence'].includes(format)) {
236
+ return res
237
+ .status(400)
238
+ .send('Invalid `format`: must be "gif" or "png-sequence"');
239
+ }
240
+
241
+ try {
242
+ const engine = getBrowserEngine(req);
243
+ const result = await captureAnimationFrames(url, {
244
+ engine,
245
+ format,
246
+ maxSize: parseInt(req.query.maxSize, 10) || DEFAULTS.maxSize,
247
+ interval: parseInt(req.query.interval, 10) || DEFAULTS.interval,
248
+ minFrames: parseInt(req.query.minFrames, 10) || DEFAULTS.minFrames,
249
+ loopTimeout: parseInt(req.query.loopTimeout, 10) || DEFAULTS.loopTimeout,
250
+ similarity: parseFloat(req.query.similarity) || DEFAULTS.similarity,
251
+ captureMode: req.query.captureMode || DEFAULTS.captureMode,
252
+ extractKeyframes: req.query.extractKeyframes === 'true',
253
+ });
254
+
255
+ if (format === 'png-sequence') {
256
+ // Return as JSON with base64-encoded frames
257
+ res.json({
258
+ frames: result.frames.map((f) => f.toString('base64')),
259
+ timestamps: result.timestamps,
260
+ loopDetected: result.loopDetected,
261
+ loopFrame: result.loopFrame,
262
+ totalFrames: result.totalFrames,
263
+ duration: result.duration,
264
+ });
265
+ } else {
266
+ // Return frames info as JSON (GIF encoding requires gif-encoder-2
267
+ // which is an optional dependency)
268
+ res.json({
269
+ frameCount: result.frames.length,
270
+ loopDetected: result.loopDetected,
271
+ loopFrame: result.loopFrame,
272
+ duration: result.duration,
273
+ message:
274
+ 'GIF encoding requires gif-encoder-2 package. ' +
275
+ 'Use CLI with --format gif for full GIF output, ' +
276
+ 'or use png-sequence format for raw frames.',
277
+ });
278
+ }
279
+ } catch (err) {
280
+ console.error(err);
281
+ res.status(500).send('Error capturing animation');
282
+ }
283
+ }
package/src/archive.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * ZIP archive handler.
3
+ *
4
+ * Downloads a web page as a self-contained ZIP archive containing:
5
+ * - article.md or article.html (document with either remote or local asset links)
6
+ * - images/ (directory of downloaded images, when localImages=true)
7
+ * - css/ (directory for stylesheets, when documentFormat=html and localImages=true)
8
+ * - js/ (directory for scripts, when documentFormat=html and localImages=true)
9
+ *
10
+ * Query parameters:
11
+ * url (required) - URL to capture
12
+ * engine - 'puppeteer' or 'playwright'
13
+ * localImages - 'true' (default) to download images locally into the archive,
14
+ * 'false' to keep original remote URLs in the document
15
+ * documentFormat - 'markdown' (default) or 'html' - format of the main document
16
+ */
17
+
18
+ import archiver from 'archiver';
19
+ import fetch from 'node-fetch';
20
+ import * as cheerio from 'cheerio';
21
+ import { URL } from 'url';
22
+ import {
23
+ fetchHtml,
24
+ convertHtmlToMarkdown,
25
+ convertRelativeUrls,
26
+ } from './lib.js';
27
+ import { retry } from './retry.js';
28
+
29
+ export async function archiveHandler(req, res) {
30
+ const url = req.query.url;
31
+ if (!url) {
32
+ return res.status(400).send('Missing `url` parameter');
33
+ }
34
+
35
+ const localImages = req.query.localImages !== 'false'; // default true
36
+ const documentFormat =
37
+ req.query.documentFormat === 'html' ? 'html' : 'markdown';
38
+
39
+ try {
40
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
41
+
42
+ const html = await retry(() => fetchHtml(absoluteUrl), {
43
+ retries: 3,
44
+ baseDelay: 1000,
45
+ onRetry: (err, attempt, delay) => {
46
+ console.log(
47
+ `Retry ${attempt} fetching ${absoluteUrl} after ${delay}ms: ${err.message}`
48
+ );
49
+ },
50
+ });
51
+
52
+ // Collect images from the HTML
53
+ const $ = cheerio.load(html);
54
+ const images = [];
55
+ $('img').each(function () {
56
+ const src = $(this).attr('src');
57
+ if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
58
+ try {
59
+ const imgUrl = new URL(src, absoluteUrl).href;
60
+ images.push(imgUrl);
61
+ } catch {
62
+ /* skip invalid URLs */
63
+ }
64
+ }
65
+ });
66
+
67
+ // Deduplicate
68
+ const uniqueImages = [...new Set(images)];
69
+
70
+ // Set up the ZIP archive stream
71
+ res.set('Content-Type', 'application/zip');
72
+ const hostname = new URL(absoluteUrl).hostname.replace(/\./g, '-');
73
+ res.set(
74
+ 'Content-Disposition',
75
+ `attachment; filename="${hostname}-archive.zip"`
76
+ );
77
+
78
+ const archive = archiver('zip', { zlib: { level: 9 } });
79
+ archive.pipe(res);
80
+
81
+ // Build the image map for local downloads
82
+ const imageMap = new Map();
83
+ if (localImages && uniqueImages.length > 0) {
84
+ let idx = 1;
85
+ for (const imgUrl of uniqueImages) {
86
+ const ext = guessImageExtension(imgUrl);
87
+ const filename = `image-${idx}.${ext}`;
88
+ imageMap.set(imgUrl, `images/${filename}`);
89
+ idx++;
90
+ }
91
+ }
92
+
93
+ if (documentFormat === 'html') {
94
+ // HTML format: produce a local HTML document with assets in folders
95
+ let outputHtml = convertRelativeUrls(html, absoluteUrl);
96
+
97
+ // Remove scripts and non-essential elements for a clean local copy
98
+ const $out = cheerio.load(outputHtml);
99
+ $out('script, noscript').remove();
100
+
101
+ if (localImages && imageMap.size > 0) {
102
+ // Rewrite image URLs to local paths
103
+ $out('img').each(function () {
104
+ const src = $out(this).attr('src');
105
+ if (src) {
106
+ try {
107
+ const resolvedUrl = new URL(src, absoluteUrl).href;
108
+ if (imageMap.has(resolvedUrl)) {
109
+ $out(this).attr('src', imageMap.get(resolvedUrl));
110
+ }
111
+ } catch {
112
+ /* skip invalid URLs */
113
+ }
114
+ }
115
+ });
116
+ }
117
+
118
+ // Collect and localize CSS stylesheets
119
+ const cssFiles = [];
120
+ if (localImages) {
121
+ let cssIdx = 1;
122
+ $out('link[rel="stylesheet"]').each(function () {
123
+ const href = $out(this).attr('href');
124
+ if (href && !href.startsWith('data:')) {
125
+ try {
126
+ const cssUrl = new URL(href, absoluteUrl).href;
127
+ const localPath = `css/style-${cssIdx}.css`;
128
+ cssFiles.push({ url: cssUrl, localPath });
129
+ $out(this).attr('href', localPath);
130
+ cssIdx++;
131
+ } catch {
132
+ /* skip invalid URLs */
133
+ }
134
+ }
135
+ });
136
+ }
137
+
138
+ outputHtml = $out.html();
139
+ archive.append(outputHtml, { name: 'article.html' });
140
+
141
+ // Download and add CSS files
142
+ for (const { url: cssUrl, localPath } of cssFiles) {
143
+ try {
144
+ const cssResp = await retry(() => fetch(cssUrl), {
145
+ retries: 2,
146
+ baseDelay: 500,
147
+ });
148
+ if (cssResp.ok) {
149
+ const cssText = await cssResp.text();
150
+ archive.append(cssText, { name: localPath });
151
+ }
152
+ } catch {
153
+ /* skip failed CSS downloads */
154
+ }
155
+ }
156
+ } else {
157
+ // Markdown format (default)
158
+ let markdown = convertHtmlToMarkdown(html, absoluteUrl);
159
+
160
+ if (localImages && imageMap.size > 0) {
161
+ // Rewrite image URLs in markdown to local paths
162
+ for (const [remoteUrl, localPath] of imageMap) {
163
+ markdown = markdown.split(remoteUrl).join(localPath);
164
+ }
165
+ }
166
+
167
+ archive.append(markdown, { name: 'article.md' });
168
+ }
169
+
170
+ // Download and add images if local mode
171
+ if (localImages && imageMap.size > 0) {
172
+ for (const [imgUrl, localPath] of imageMap) {
173
+ try {
174
+ const imgResp = await retry(() => fetch(imgUrl), {
175
+ retries: 2,
176
+ baseDelay: 500,
177
+ });
178
+ if (imgResp.ok) {
179
+ const buffer = await imgResp.buffer();
180
+ archive.append(buffer, { name: localPath });
181
+ }
182
+ } catch {
183
+ /* skip failed image downloads */
184
+ }
185
+ }
186
+ }
187
+
188
+ await archive.finalize();
189
+ } catch (err) {
190
+ console.error(err);
191
+ if (!res.headersSent) {
192
+ res.status(500).send('Error creating archive');
193
+ }
194
+ }
195
+ }
196
+
197
+ function guessImageExtension(url) {
198
+ const pathname = new URL(url).pathname.toLowerCase();
199
+ if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
200
+ return 'jpg';
201
+ }
202
+ if (pathname.endsWith('.gif')) {
203
+ return 'gif';
204
+ }
205
+ if (pathname.endsWith('.webp')) {
206
+ return 'webp';
207
+ }
208
+ if (pathname.endsWith('.svg')) {
209
+ return 'svg';
210
+ }
211
+ return 'png';
212
+ }
package/src/batch.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Batch processing and configuration module (R7).
3
+ *
4
+ * Supports processing multiple URLs from a configuration file.
5
+ * Configuration format matches the articles-config pattern from meta-theory.
6
+ *
7
+ * Based on reference implementation from:
8
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/articles-config.mjs
9
+ *
10
+ * @module batch
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import { URL } from 'url';
15
+
16
+ /**
17
+ * @typedef {Object} ArticleConfig
18
+ * @property {string} url - Article URL
19
+ * @property {string} [title] - Article title
20
+ * @property {string} [language] - Article language (e.g., 'en', 'ru')
21
+ * @property {string} [archivePath] - Local archive directory path
22
+ * @property {string} [markdownFile] - Output markdown filename
23
+ * @property {string} [screenshotLightFile] - Light theme screenshot filename
24
+ * @property {string} [screenshotDarkFile] - Dark theme screenshot filename
25
+ * @property {string} [imagesDir] - Images directory name
26
+ * @property {boolean} [hasLocalImages] - Whether to download images locally
27
+ * @property {number} [expectedFigures] - Expected number of figure images
28
+ * @property {string} [format] - Output format override
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} BatchConfig
33
+ * @property {Object<string, ArticleConfig>} articles - Map of version/id to article config
34
+ * @property {Object} [defaults] - Default options applied to all articles
35
+ */
36
+
37
+ /**
38
+ * Load batch configuration from a JSON or JavaScript file.
39
+ *
40
+ * Supports:
41
+ * - JSON files (.json)
42
+ * - JavaScript/ESM files (.mjs, .js) with default or named exports
43
+ *
44
+ * @param {string} configPath - Path to configuration file
45
+ * @returns {Promise<BatchConfig>} Loaded configuration
46
+ */
47
+ export async function loadConfig(configPath) {
48
+ const ext = configPath.split('.').pop().toLowerCase();
49
+
50
+ if (ext === 'json') {
51
+ const content = fs.readFileSync(configPath, 'utf-8');
52
+ return JSON.parse(content);
53
+ }
54
+
55
+ if (ext === 'mjs' || ext === 'js') {
56
+ // Dynamic import for ESM modules
57
+ const fileUrl = new URL(`file://${configPath}`);
58
+ const module = await import(fileUrl.href);
59
+
60
+ // Support both default export and named exports
61
+ if (module.default) {
62
+ return module.default;
63
+ }
64
+
65
+ // If module exports ARTICLES (matching meta-theory pattern)
66
+ if (module.ARTICLES) {
67
+ return { articles: module.ARTICLES };
68
+ }
69
+
70
+ return module;
71
+ }
72
+
73
+ throw new Error(
74
+ `Unsupported config format: .${ext}. Use .json, .mjs, or .js`
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Get article configuration by version/id.
80
+ *
81
+ * @param {BatchConfig} config - Batch configuration
82
+ * @param {string} version - Article version/id
83
+ * @returns {ArticleConfig} Article configuration
84
+ */
85
+ export function getArticle(config, version) {
86
+ const article = config.articles[version];
87
+ if (!article) {
88
+ throw new Error(
89
+ `Unknown article version: ${version}. Available: ${Object.keys(config.articles).join(', ')}`
90
+ );
91
+ }
92
+ // Merge defaults
93
+ return { ...config.defaults, ...article };
94
+ }
95
+
96
+ /**
97
+ * Get all article versions from configuration.
98
+ *
99
+ * @param {BatchConfig} config - Batch configuration
100
+ * @returns {string[]} Array of version/id strings
101
+ */
102
+ export function getAllVersions(config) {
103
+ return Object.keys(config.articles);
104
+ }
105
+
106
+ /**
107
+ * Get all article configurations.
108
+ *
109
+ * @param {BatchConfig} config - Batch configuration
110
+ * @returns {ArticleConfig[]} Array of article configs (with defaults merged)
111
+ */
112
+ export function getAllArticles(config) {
113
+ return Object.values(config.articles).map((article) => ({
114
+ ...config.defaults,
115
+ ...article,
116
+ }));
117
+ }
118
+
119
+ /**
120
+ * Create a default batch configuration for a list of URLs.
121
+ *
122
+ * @param {string[]} urls - Array of URLs to process
123
+ * @param {Object} [defaults] - Default options for all articles
124
+ * @returns {BatchConfig} Generated configuration
125
+ */
126
+ export function createConfigFromUrls(urls, defaults = {}) {
127
+ const articles = {};
128
+
129
+ urls.forEach((url, index) => {
130
+ const id = String(index + 1);
131
+ let hostname;
132
+ try {
133
+ hostname = new URL(url).hostname;
134
+ } catch {
135
+ hostname = 'article';
136
+ }
137
+
138
+ articles[id] = {
139
+ url,
140
+ title: `Article ${id}`,
141
+ archivePath: `archive/${hostname.replace(/\./g, '-')}/${id}`,
142
+ markdownFile: 'article.md',
143
+ screenshotLightFile: 'article-light.png',
144
+ screenshotDarkFile: 'article-dark.png',
145
+ imagesDir: 'images',
146
+ hasLocalImages: true,
147
+ };
148
+ });
149
+
150
+ return { articles, defaults };
151
+ }
152
+
153
+ /**
154
+ * Validate a batch configuration.
155
+ *
156
+ * @param {BatchConfig} config - Configuration to validate
157
+ * @returns {Object} Validation result with {valid, errors}
158
+ */
159
+ export function validateConfig(config) {
160
+ const errors = [];
161
+
162
+ if (!config.articles || typeof config.articles !== 'object') {
163
+ errors.push('Configuration must have an "articles" object');
164
+ return { valid: false, errors };
165
+ }
166
+
167
+ for (const [id, article] of Object.entries(config.articles)) {
168
+ if (!article.url) {
169
+ errors.push(`Article "${id}" missing required "url" field`);
170
+ } else {
171
+ try {
172
+ new URL(article.url);
173
+ } catch {
174
+ errors.push(`Article "${id}" has invalid URL: ${article.url}`);
175
+ }
176
+ }
177
+ }
178
+
179
+ return { valid: errors.length === 0, errors };
180
+ }