@link-assistant/web-capture 1.1.2 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +57 -13
  2. package/Dockerfile +26 -21
  3. package/README.md +290 -160
  4. package/bin/web-capture.js +622 -17
  5. package/package.json +15 -4
  6. package/src/animation.js +283 -0
  7. package/src/archive.js +225 -0
  8. package/src/batch.js +180 -0
  9. package/src/browser.js +150 -111
  10. package/src/docx.js +182 -0
  11. package/src/extract-images.js +120 -0
  12. package/src/figures.js +208 -0
  13. package/src/gdocs.js +335 -0
  14. package/src/image.js +65 -8
  15. package/src/index.js +15 -0
  16. package/src/latex.js +127 -0
  17. package/src/lib.js +281 -1
  18. package/src/localize-images.js +202 -0
  19. package/src/markdown.js +7 -2
  20. package/src/metadata.js +333 -0
  21. package/src/pdf.js +76 -0
  22. package/src/popups.js +171 -0
  23. package/src/postprocess.js +263 -0
  24. package/src/retry.js +48 -0
  25. package/src/themed-image.js +167 -0
  26. package/src/verify.js +312 -0
  27. package/tests/e2e/docker.test.js +51 -12
  28. package/tests/e2e/process.test.js +34 -10
  29. package/tests/fixtures/habr-articles.js +45 -0
  30. package/tests/integration/api-endpoints.test.js +231 -0
  31. package/tests/integration/habr-article.test.js +337 -0
  32. package/tests/mock/index.test.js +5 -3
  33. package/tests/unit/animation.test.js +33 -0
  34. package/tests/unit/batch.test.js +114 -0
  35. package/tests/unit/browser.test.js +38 -2
  36. package/tests/unit/extract-images.test.js +233 -0
  37. package/tests/unit/figures.test.js +74 -0
  38. package/tests/unit/gdocs.test.js +278 -0
  39. package/tests/unit/heading-image.test.js +49 -0
  40. package/tests/unit/latex.test.js +123 -0
  41. package/tests/unit/localize-images.test.js +78 -0
  42. package/tests/unit/metadata.test.js +140 -0
  43. package/tests/unit/postprocess.test.js +110 -0
  44. package/tests/unit/retry.test.js +111 -0
  45. package/tests/unit/verify.test.js +182 -0
  46. package/.github/workflows/release.yml +0 -294
  47. package/ARCHITECTURE.md +0 -982
  48. package/LICENSE +0 -24
  49. package/apply.sh +0 -166
  50. package/docs/market-analysis-2025-05-13.md +0 -64
  51. package/scripts/changeset-version.mjs +0 -38
  52. package/scripts/create-github-release.mjs +0 -93
  53. package/scripts/create-manual-changeset.mjs +0 -88
  54. package/scripts/format-github-release.mjs +0 -83
  55. package/scripts/format-release-notes.mjs +0 -218
  56. package/scripts/instant-version-bump.mjs +0 -121
  57. package/scripts/publish-to-npm.mjs +0 -128
  58. package/scripts/setup-npm.mjs +0 -37
  59. package/scripts/validate-changeset.mjs +0 -106
  60. package/scripts/version-and-commit.mjs +0 -237
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.1.2",
3
+ "version": "1.7.1",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -27,23 +27,30 @@
27
27
  "check": "npm run lint && npm run format:check && npm run check:duplication",
28
28
  "prepare": "husky || true",
29
29
  "changeset": "changeset",
30
- "changeset:version": "node scripts/changeset-version.mjs",
30
+ "changeset:version": "node ../scripts/changeset-version.mjs",
31
31
  "changeset:publish": "changeset publish",
32
32
  "changeset:status": "changeset status --since=origin/main"
33
33
  },
34
34
  "dependencies": {
35
+ "archiver": "^7.0.1",
36
+ "browser-commander": "^0.8.0",
35
37
  "cheerio": "^1.0.0",
38
+ "docx": "^9.6.1",
36
39
  "express": "^4.18.2",
37
- "lino-arguments": "^0.2.1",
40
+ "he": "^1.2.0",
41
+ "iconv-lite": "^0.6.3",
42
+ "lino-arguments": "^0.3.0",
38
43
  "node-fetch": "^2.7.0",
39
44
  "playwright": "^1.49.0",
40
45
  "puppeteer": "^24.8.2",
46
+ "puppeteer-core": "^24.40.0",
41
47
  "turndown": "^7.1.1"
42
48
  },
43
49
  "devDependencies": {
44
50
  "@babel/core": "^7.24.0",
45
51
  "@babel/preset-env": "^7.24.0",
46
52
  "@changesets/cli": "^2.29.7",
53
+ "@types/archiver": "^7.0.0",
47
54
  "babel-jest": "^29.7.0",
48
55
  "eslint": "^9.38.0",
49
56
  "eslint-config-prettier": "^10.1.8",
@@ -65,7 +72,11 @@
65
72
  "screenshot",
66
73
  "microservice"
67
74
  ],
68
- "license": "UNLICENSED",
75
+ "license": "Unlicense",
76
+ "repository": {
77
+ "type": "git",
78
+ "url": "https://github.com/link-assistant/web-capture"
79
+ },
69
80
  "engines": {
70
81
  "node": ">=22.0.0 <23.0.0"
71
82
  },
@@ -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,225 @@
1
+ /**
2
+ * ZIP archive handler.
3
+ *
4
+ * Downloads a web page as a self-contained ZIP archive containing:
5
+ * - document.md or document.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
+ prettyPrintHtml,
27
+ } from './lib.js';
28
+ import { retry } from './retry.js';
29
+ import { extractBase64ToBuffers } from './extract-images.js';
30
+
31
+ export async function archiveHandler(req, res) {
32
+ const url = req.query.url;
33
+ if (!url) {
34
+ return res.status(400).send('Missing `url` parameter');
35
+ }
36
+
37
+ const keepOriginalLinks = req.query.keepOriginalLinks === 'true';
38
+ const localImages = keepOriginalLinks
39
+ ? false
40
+ : req.query.localImages !== 'false';
41
+ const embedImages = req.query.embedImages === 'true';
42
+ const documentFormat =
43
+ req.query.documentFormat === 'html' ? 'html' : 'markdown';
44
+
45
+ try {
46
+ const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
47
+
48
+ const html = await retry(() => fetchHtml(absoluteUrl), {
49
+ retries: 3,
50
+ baseDelay: 1000,
51
+ onRetry: (err, attempt, delay) => {
52
+ console.log(
53
+ `Retry ${attempt} fetching ${absoluteUrl} after ${delay}ms: ${err.message}`
54
+ );
55
+ },
56
+ });
57
+
58
+ // Collect images from the HTML
59
+ const $ = cheerio.load(html);
60
+ const images = [];
61
+ $('img').each(function () {
62
+ const src = $(this).attr('src');
63
+ if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
64
+ try {
65
+ const imgUrl = new URL(src, absoluteUrl).href;
66
+ images.push(imgUrl);
67
+ } catch {
68
+ /* skip invalid URLs */
69
+ }
70
+ }
71
+ });
72
+
73
+ // Deduplicate
74
+ const uniqueImages = [...new Set(images)];
75
+
76
+ // Set up the ZIP archive stream
77
+ res.set('Content-Type', 'application/zip');
78
+ const hostname = new URL(absoluteUrl).hostname.replace(/\./g, '-');
79
+ res.set(
80
+ 'Content-Disposition',
81
+ `attachment; filename="${hostname}-archive.zip"`
82
+ );
83
+
84
+ const archive = archiver('zip', { zlib: { level: 9 } });
85
+ archive.pipe(res);
86
+
87
+ // Build the image map for local downloads
88
+ const imageMap = new Map();
89
+ if (localImages && uniqueImages.length > 0) {
90
+ let idx = 1;
91
+ for (const imgUrl of uniqueImages) {
92
+ const ext = guessImageExtension(imgUrl);
93
+ const filename = `image-${idx}.${ext}`;
94
+ imageMap.set(imgUrl, `images/${filename}`);
95
+ idx++;
96
+ }
97
+ }
98
+
99
+ if (documentFormat === 'html') {
100
+ // HTML format: produce a local HTML document with assets in folders
101
+ let outputHtml = convertRelativeUrls(html, absoluteUrl);
102
+
103
+ // Remove scripts and non-essential elements for a clean local copy
104
+ const $out = cheerio.load(outputHtml);
105
+ $out('script, noscript').remove();
106
+
107
+ if (localImages && imageMap.size > 0) {
108
+ // Rewrite image URLs to local paths
109
+ $out('img').each(function () {
110
+ const src = $out(this).attr('src');
111
+ if (src) {
112
+ try {
113
+ const resolvedUrl = new URL(src, absoluteUrl).href;
114
+ if (imageMap.has(resolvedUrl)) {
115
+ $out(this).attr('src', imageMap.get(resolvedUrl));
116
+ }
117
+ } catch {
118
+ /* skip invalid URLs */
119
+ }
120
+ }
121
+ });
122
+ }
123
+
124
+ // Collect and localize CSS stylesheets
125
+ const cssFiles = [];
126
+ if (localImages) {
127
+ let cssIdx = 1;
128
+ $out('link[rel="stylesheet"]').each(function () {
129
+ const href = $out(this).attr('href');
130
+ if (href && !href.startsWith('data:')) {
131
+ try {
132
+ const cssUrl = new URL(href, absoluteUrl).href;
133
+ const localPath = `css/style-${cssIdx}.css`;
134
+ cssFiles.push({ url: cssUrl, localPath });
135
+ $out(this).attr('href', localPath);
136
+ cssIdx++;
137
+ } catch {
138
+ /* skip invalid URLs */
139
+ }
140
+ }
141
+ });
142
+ }
143
+
144
+ outputHtml = prettyPrintHtml($out.html());
145
+ archive.append(outputHtml, { name: 'document.html' });
146
+
147
+ // Download and add CSS files
148
+ for (const { url: cssUrl, localPath } of cssFiles) {
149
+ try {
150
+ const cssResp = await retry(() => fetch(cssUrl), {
151
+ retries: 2,
152
+ baseDelay: 500,
153
+ });
154
+ if (cssResp.ok) {
155
+ const cssText = await cssResp.text();
156
+ archive.append(cssText, { name: localPath });
157
+ }
158
+ } catch {
159
+ /* skip failed CSS downloads */
160
+ }
161
+ }
162
+ } else {
163
+ // Markdown format (default)
164
+ let markdown = convertHtmlToMarkdown(html, absoluteUrl);
165
+
166
+ if (localImages && imageMap.size > 0) {
167
+ for (const [remoteUrl, localPath] of imageMap) {
168
+ markdown = markdown.split(remoteUrl).join(localPath);
169
+ }
170
+ }
171
+
172
+ if (!embedImages && !keepOriginalLinks) {
173
+ const b64 = extractBase64ToBuffers(markdown, 'images');
174
+ markdown = b64.markdown;
175
+ for (const img of b64.images) {
176
+ archive.append(img.buffer, { name: `images/${img.filename}` });
177
+ }
178
+ }
179
+
180
+ archive.append(markdown, { name: 'document.md' });
181
+ }
182
+
183
+ // Download and add images if local mode
184
+ if (localImages && imageMap.size > 0) {
185
+ for (const [imgUrl, localPath] of imageMap) {
186
+ try {
187
+ const imgResp = await retry(() => fetch(imgUrl), {
188
+ retries: 2,
189
+ baseDelay: 500,
190
+ });
191
+ if (imgResp.ok) {
192
+ const buffer = await imgResp.buffer();
193
+ archive.append(buffer, { name: localPath });
194
+ }
195
+ } catch {
196
+ /* skip failed image downloads */
197
+ }
198
+ }
199
+ }
200
+
201
+ await archive.finalize();
202
+ } catch (err) {
203
+ console.error(err);
204
+ if (!res.headersSent) {
205
+ res.status(500).send('Error creating archive');
206
+ }
207
+ }
208
+ }
209
+
210
+ function guessImageExtension(url) {
211
+ const pathname = new URL(url).pathname.toLowerCase();
212
+ if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
213
+ return 'jpg';
214
+ }
215
+ if (pathname.endsWith('.gif')) {
216
+ return 'gif';
217
+ }
218
+ if (pathname.endsWith('.webp')) {
219
+ return 'webp';
220
+ }
221
+ if (pathname.endsWith('.svg')) {
222
+ return 'svg';
223
+ }
224
+ return 'png';
225
+ }