@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.
- package/CHANGELOG.md +17 -14
- package/README.md +256 -146
- package/bin/web-capture.js +403 -6
- package/package.json +11 -4
- package/src/animation.js +283 -0
- package/src/archive.js +212 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +333 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +226 -1
- package/src/localize-images.js +202 -0
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +265 -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/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +203 -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 +108 -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/animation.js
ADDED
|
@@ -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
|
+
}
|