@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/latex.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaTeX formula extraction module.
|
|
3
|
+
*
|
|
4
|
+
* Extracts LaTeX formulas from HTML content, handling multiple sources:
|
|
5
|
+
* - Habr: img.formula elements with `source` attribute
|
|
6
|
+
* - KaTeX: .katex elements with annotation[encoding="application/x-tex"]
|
|
7
|
+
* - MathJax: mjx-container elements with data-tex/data-latex attributes
|
|
8
|
+
*
|
|
9
|
+
* Based on reference implementation from:
|
|
10
|
+
* https://github.com/link-foundation/meta-theory/blob/main/scripts/download-article.mjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract LaTeX source from a formula image element (Habr-specific).
|
|
15
|
+
* Habr renders formulas as SVG/PNG images with class "formula".
|
|
16
|
+
* The original LaTeX source is in the `source` attribute.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} $ - Cheerio instance
|
|
19
|
+
* @param {Object} el - Cheerio element (img.formula)
|
|
20
|
+
* @returns {string|null} LaTeX source or null
|
|
21
|
+
*/
|
|
22
|
+
export function extractHabrFormula($, el) {
|
|
23
|
+
const source = $(el).attr('source');
|
|
24
|
+
if (source) {
|
|
25
|
+
return source.trim();
|
|
26
|
+
}
|
|
27
|
+
const alt = $(el).attr('alt');
|
|
28
|
+
if (alt) {
|
|
29
|
+
return alt.trim();
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract LaTeX from KaTeX elements.
|
|
36
|
+
* KaTeX stores the TeX source in annotation[encoding="application/x-tex"].
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} $ - Cheerio instance
|
|
39
|
+
* @param {Object} el - Cheerio element (.katex or .math)
|
|
40
|
+
* @returns {string|null} LaTeX source or null
|
|
41
|
+
*/
|
|
42
|
+
export function extractKatexFormula($, el) {
|
|
43
|
+
const annotation = $(el).find('annotation[encoding="application/x-tex"]');
|
|
44
|
+
if (annotation.length > 0) {
|
|
45
|
+
return annotation.text().trim();
|
|
46
|
+
}
|
|
47
|
+
const dataTex = $(el).attr('data-tex') || $(el).attr('data-latex');
|
|
48
|
+
if (dataTex) {
|
|
49
|
+
return dataTex.trim();
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract LaTeX from MathJax elements.
|
|
56
|
+
* MathJax stores TeX in data-tex attribute or annotation elements.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} $ - Cheerio instance
|
|
59
|
+
* @param {Object} el - Cheerio element (mjx-container)
|
|
60
|
+
* @returns {string|null} LaTeX source or null
|
|
61
|
+
*/
|
|
62
|
+
export function extractMathJaxFormula($, el) {
|
|
63
|
+
const dataTex = $(el).attr('data-tex') || $(el).attr('data-latex');
|
|
64
|
+
if (dataTex) {
|
|
65
|
+
return dataTex.trim();
|
|
66
|
+
}
|
|
67
|
+
const annotation = $(el).find('annotation[encoding="application/x-tex"]');
|
|
68
|
+
if (annotation.length > 0) {
|
|
69
|
+
return annotation.text().trim();
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if an element is a formula image (Habr-specific).
|
|
76
|
+
*
|
|
77
|
+
* @param {Object} $ - Cheerio instance
|
|
78
|
+
* @param {Object} el - Cheerio element
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function isFormulaImage($, el) {
|
|
82
|
+
return (
|
|
83
|
+
$(el).is('img') &&
|
|
84
|
+
($(el).hasClass('formula') ||
|
|
85
|
+
$(el).attr('source') !== undefined ||
|
|
86
|
+
($(el).attr('class') || '').includes('formula'))
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if an element is a math element (KaTeX, MathJax, or generic math class).
|
|
92
|
+
*
|
|
93
|
+
* @param {Object} $ - Cheerio instance
|
|
94
|
+
* @param {Object} el - Cheerio element
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
export function isMathElement($, el) {
|
|
98
|
+
const tag = (el.tagName || el.name || '').toLowerCase();
|
|
99
|
+
const classes = $(el).attr('class') || '';
|
|
100
|
+
return (
|
|
101
|
+
classes.includes('katex') ||
|
|
102
|
+
classes.includes('math') ||
|
|
103
|
+
classes.includes('MathJax') ||
|
|
104
|
+
tag === 'mjx-container'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract formula from any supported element type.
|
|
110
|
+
*
|
|
111
|
+
* @param {Object} $ - Cheerio instance
|
|
112
|
+
* @param {Object} el - Cheerio element
|
|
113
|
+
* @returns {string|null} LaTeX source or null
|
|
114
|
+
*/
|
|
115
|
+
export function extractFormula($, el) {
|
|
116
|
+
if (isFormulaImage($, el)) {
|
|
117
|
+
return extractHabrFormula($, el);
|
|
118
|
+
}
|
|
119
|
+
const tag = (el.tagName || el.name || '').toLowerCase();
|
|
120
|
+
if (tag === 'mjx-container') {
|
|
121
|
+
return extractMathJaxFormula($, el);
|
|
122
|
+
}
|
|
123
|
+
if (isMathElement($, el)) {
|
|
124
|
+
return extractKatexFormula($, el);
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
package/src/lib.js
CHANGED
|
@@ -6,6 +6,10 @@ import TurndownService from 'turndown';
|
|
|
6
6
|
import iconv from 'iconv-lite';
|
|
7
7
|
import { URL } from 'url';
|
|
8
8
|
import turndownPluginGfm from 'turndown-plugin-gfm';
|
|
9
|
+
import he from 'he';
|
|
10
|
+
import { isFormulaImage, isMathElement, extractFormula } from './latex.js';
|
|
11
|
+
import { extractMetadata } from './metadata.js';
|
|
12
|
+
import { postProcessMarkdown } from './postprocess.js';
|
|
9
13
|
|
|
10
14
|
export async function fetchHtml(url) {
|
|
11
15
|
if (!url) {
|
|
@@ -162,7 +166,8 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
162
166
|
style: false,
|
|
163
167
|
});
|
|
164
168
|
turndown.use(turndownPluginGfm.gfm);
|
|
165
|
-
|
|
169
|
+
// Decode HTML entities to unicode after markdown conversion
|
|
170
|
+
return he.decode(turndown.turndown($.html()));
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
// Convert relative URLs to absolute URLs in HTML content
|
|
@@ -287,6 +292,226 @@ export function convertRelativeUrls(html, baseUrl) {
|
|
|
287
292
|
return result;
|
|
288
293
|
}
|
|
289
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Enhanced HTML to Markdown conversion with LaTeX formula extraction,
|
|
297
|
+
* metadata extraction, code language detection, and post-processing.
|
|
298
|
+
*
|
|
299
|
+
* This is the enhanced version of convertHtmlToMarkdown that handles:
|
|
300
|
+
* - LaTeX formulas from img.formula (Habr), KaTeX, MathJax
|
|
301
|
+
* - Article metadata (author, date, views, hubs, tags)
|
|
302
|
+
* - Code language detection with content-based correction
|
|
303
|
+
* - Blockquote math grouping with \displaystyle
|
|
304
|
+
* - Unicode normalization, quote straightening, dash normalization
|
|
305
|
+
* - LaTeX spacing fixes for GitHub rendering
|
|
306
|
+
*
|
|
307
|
+
* @param {string} html - HTML content
|
|
308
|
+
* @param {string} [baseUrl] - Base URL for resolving relative URLs
|
|
309
|
+
* @param {Object} [options] - Enhanced options
|
|
310
|
+
* @param {boolean} [options.extractLatex=true] - Extract LaTeX formulas
|
|
311
|
+
* @param {boolean} [options.extractMetadata=true] - Extract article metadata
|
|
312
|
+
* @param {boolean} [options.postProcess=true] - Apply post-processing pipeline
|
|
313
|
+
* @param {boolean} [options.detectCodeLanguage=true] - Detect/correct code languages
|
|
314
|
+
* @returns {Object} Result with { markdown, metadata }
|
|
315
|
+
*/
|
|
316
|
+
export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
317
|
+
const {
|
|
318
|
+
extractLatex = true,
|
|
319
|
+
extractMetadata: shouldExtractMetadata = true,
|
|
320
|
+
postProcess = true,
|
|
321
|
+
detectCodeLanguage = true,
|
|
322
|
+
} = options;
|
|
323
|
+
|
|
324
|
+
// Ensure all URLs are absolute before Markdown conversion
|
|
325
|
+
if (baseUrl) {
|
|
326
|
+
html = convertRelativeUrls(html, baseUrl);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const $ = cheerio.load(html);
|
|
330
|
+
|
|
331
|
+
// Extract metadata before cleaning
|
|
332
|
+
let metadata = null;
|
|
333
|
+
if (shouldExtractMetadata) {
|
|
334
|
+
metadata = extractMetadata($);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Remove unwanted elements
|
|
338
|
+
$('style, script, noscript').remove();
|
|
339
|
+
$('*').each(function () {
|
|
340
|
+
const attribs = this.attribs || {};
|
|
341
|
+
Object.keys(attribs).forEach((attr) => {
|
|
342
|
+
if (attr.toLowerCase().startsWith('on')) {
|
|
343
|
+
$(this).removeAttr(attr);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
$('a[href^="javascript:"]').remove();
|
|
348
|
+
$('[style]').removeAttr('style');
|
|
349
|
+
|
|
350
|
+
// Remove empty headings
|
|
351
|
+
$('h1, h2, h3, h4, h5, h6').each(function () {
|
|
352
|
+
if (!$(this).text().trim()) {
|
|
353
|
+
$(this).remove();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Remove empty links (same logic as convertHtmlToMarkdown)
|
|
358
|
+
$('a').each(function () {
|
|
359
|
+
const directText = $(this)
|
|
360
|
+
.contents()
|
|
361
|
+
.filter(function () {
|
|
362
|
+
return this.type === 'text';
|
|
363
|
+
})
|
|
364
|
+
.text();
|
|
365
|
+
if (!directText.trim()) {
|
|
366
|
+
let allChildrenEmpty = true;
|
|
367
|
+
$(this)
|
|
368
|
+
.children()
|
|
369
|
+
.each(function () {
|
|
370
|
+
if (
|
|
371
|
+
$(this).text().trim() ||
|
|
372
|
+
(this.tagName === 'img' && $(this).attr('alt'))
|
|
373
|
+
) {
|
|
374
|
+
allChildrenEmpty = false;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
if (allChildrenEmpty) {
|
|
378
|
+
$(this).remove();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
$('a').each(function () {
|
|
383
|
+
const children = $(this).children();
|
|
384
|
+
if (
|
|
385
|
+
children.length === 1 &&
|
|
386
|
+
children[0].tagName === 'img' &&
|
|
387
|
+
!$(children[0]).attr('alt')
|
|
388
|
+
) {
|
|
389
|
+
$(this).remove();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
$('a').each(function () {
|
|
393
|
+
if (!$(this).text().trim()) {
|
|
394
|
+
$(this).remove();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Handle LaTeX formulas before Turndown conversion
|
|
399
|
+
if (extractLatex) {
|
|
400
|
+
// Replace Habr formula images with LaTeX text
|
|
401
|
+
$('img.formula, img[source]').each(function () {
|
|
402
|
+
if (isFormulaImage($, this)) {
|
|
403
|
+
const latex = extractFormula($, this);
|
|
404
|
+
if (latex) {
|
|
405
|
+
$(this).replaceWith(`$${latex}$`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Replace KaTeX/MathJax elements
|
|
411
|
+
$('.katex, .math, mjx-container, .MathJax').each(function () {
|
|
412
|
+
if (isMathElement($, this)) {
|
|
413
|
+
const latex = extractFormula($, this);
|
|
414
|
+
if (latex) {
|
|
415
|
+
$(this).replaceWith(`$${latex}$`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Handle code language detection/correction
|
|
422
|
+
if (detectCodeLanguage) {
|
|
423
|
+
$('pre code').each(function () {
|
|
424
|
+
const codeText = $(this).text().trim();
|
|
425
|
+
const language =
|
|
426
|
+
$(this)
|
|
427
|
+
.attr('class')
|
|
428
|
+
?.match(/language-(\w+)/)?.[1] ||
|
|
429
|
+
$(this)
|
|
430
|
+
.attr('class')
|
|
431
|
+
?.match(/^(\w+)$/)?.[1] ||
|
|
432
|
+
'';
|
|
433
|
+
|
|
434
|
+
// Content-based language correction
|
|
435
|
+
if (
|
|
436
|
+
language === 'matlab' &&
|
|
437
|
+
/\b(Require\s+Import|Definition|Fixpoint|Lemma|Theorem|Proof|Qed|Notation|Inductive)\b/.test(
|
|
438
|
+
codeText
|
|
439
|
+
)
|
|
440
|
+
) {
|
|
441
|
+
$(this).removeClass(`language-${language}`).addClass('language-coq');
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Preprocess ARIA tables (same as original)
|
|
447
|
+
$('[role="table"]').each(function () {
|
|
448
|
+
const $table = $(this);
|
|
449
|
+
const $newTable = $('<table></table>');
|
|
450
|
+
const label = $table.attr('aria-label');
|
|
451
|
+
const descId = $table.attr('aria-describedby');
|
|
452
|
+
if (label) {
|
|
453
|
+
$newTable.append(`<caption>${label}</caption>`);
|
|
454
|
+
} else if (descId && $(`#${descId}`).length) {
|
|
455
|
+
$newTable.append(`<caption>${$(`#${descId}`).text()}</caption>`);
|
|
456
|
+
}
|
|
457
|
+
const rowgroups = $table.find('> [role="rowgroup"]');
|
|
458
|
+
if (rowgroups.length > 0) {
|
|
459
|
+
const $thead = $('<thead></thead>');
|
|
460
|
+
const $tbody = $('<tbody></tbody>');
|
|
461
|
+
rowgroups.each(function (i) {
|
|
462
|
+
$(this)
|
|
463
|
+
.find('> [role="row"]')
|
|
464
|
+
.each(function () {
|
|
465
|
+
const $row = $(this);
|
|
466
|
+
const $tr = $('<tr></tr>');
|
|
467
|
+
$row.children('[role="columnheader"]').each(function () {
|
|
468
|
+
$tr.append($('<th></th>').text($(this).text()));
|
|
469
|
+
});
|
|
470
|
+
$row.children('[role="cell"]').each(function () {
|
|
471
|
+
$tr.append($('<td></td>').text($(this).text()));
|
|
472
|
+
});
|
|
473
|
+
if (i === 0 && $tr.children('th').length) {
|
|
474
|
+
$thead.append($tr);
|
|
475
|
+
} else {
|
|
476
|
+
$tbody.append($tr);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
if ($thead.children().length) {
|
|
481
|
+
$newTable.append($thead);
|
|
482
|
+
}
|
|
483
|
+
if ($tbody.children().length) {
|
|
484
|
+
$newTable.append($tbody);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
$table.replaceWith($newTable);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Convert to Markdown using Turndown
|
|
491
|
+
const turndown = new TurndownService({
|
|
492
|
+
headingStyle: 'atx',
|
|
493
|
+
codeBlockStyle: 'fenced',
|
|
494
|
+
emDelimiter: '*',
|
|
495
|
+
bulletListMarker: '-',
|
|
496
|
+
strongDelimiter: '**',
|
|
497
|
+
linkStyle: 'inlined',
|
|
498
|
+
linkReferenceStyle: 'full',
|
|
499
|
+
hr: '---',
|
|
500
|
+
style: false,
|
|
501
|
+
});
|
|
502
|
+
turndown.use(turndownPluginGfm.gfm);
|
|
503
|
+
|
|
504
|
+
// Decode HTML entities to unicode after markdown conversion
|
|
505
|
+
let markdown = he.decode(turndown.turndown($.html()));
|
|
506
|
+
|
|
507
|
+
// Apply post-processing
|
|
508
|
+
if (postProcess) {
|
|
509
|
+
markdown = postProcessMarkdown(markdown);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { markdown, metadata };
|
|
513
|
+
}
|
|
514
|
+
|
|
290
515
|
// Convert HTML content to UTF-8 if it's not already
|
|
291
516
|
export function convertToUtf8(html) {
|
|
292
517
|
// First, try to detect the current encoding from meta tag
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown image localization module (R5).
|
|
3
|
+
*
|
|
4
|
+
* Post-processing tool that:
|
|
5
|
+
* 1. Reads markdown files
|
|
6
|
+
* 2. Extracts all external image URLs
|
|
7
|
+
* 3. Downloads images to local directory
|
|
8
|
+
* 4. Updates markdown to reference local paths
|
|
9
|
+
*
|
|
10
|
+
* Based on reference implementation from:
|
|
11
|
+
* https://github.com/link-foundation/meta-theory/blob/main/scripts/download-markdown-images.mjs
|
|
12
|
+
*
|
|
13
|
+
* @module localize-images
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fetch from 'node-fetch';
|
|
17
|
+
import { URL } from 'url';
|
|
18
|
+
import { retry } from './retry.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract image references from markdown text.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} markdownText - Markdown content
|
|
24
|
+
* @returns {Object[]} Array of {fullMatch, altText, url}
|
|
25
|
+
*/
|
|
26
|
+
export function extractImageReferences(markdownText) {
|
|
27
|
+
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
|
|
28
|
+
const images = [];
|
|
29
|
+
let match;
|
|
30
|
+
|
|
31
|
+
while ((match = imageRegex.exec(markdownText)) !== null) {
|
|
32
|
+
images.push({
|
|
33
|
+
fullMatch: match[0],
|
|
34
|
+
altText: match[1],
|
|
35
|
+
url: match[2],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return images;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get file extension from URL.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} url - Image URL
|
|
46
|
+
* @returns {string} File extension with dot (e.g., '.png')
|
|
47
|
+
*/
|
|
48
|
+
export function getExtensionFromUrl(url) {
|
|
49
|
+
try {
|
|
50
|
+
const pathname = new URL(url).pathname.split('?')[0];
|
|
51
|
+
const ext = pathname.match(/\.(\w+)$/);
|
|
52
|
+
if (ext) {
|
|
53
|
+
const lower = ext[1].toLowerCase();
|
|
54
|
+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(lower)) {
|
|
55
|
+
return `.${lower}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
return '.png';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate local filename for a downloaded image.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} url - Image URL
|
|
68
|
+
* @param {number} index - Zero-based index
|
|
69
|
+
* @returns {string} Local filename (e.g., 'image-01.png')
|
|
70
|
+
*/
|
|
71
|
+
export function generateLocalFilename(url, index) {
|
|
72
|
+
const ext = getExtensionFromUrl(url);
|
|
73
|
+
return `image-${String(index + 1).padStart(2, '0')}${ext}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Localize images in markdown text by downloading external images
|
|
78
|
+
* and replacing URLs with local paths.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} markdownText - Input markdown
|
|
81
|
+
* @param {Object} [options] - Options
|
|
82
|
+
* @param {string} [options.imagesDir='images'] - Local images directory name
|
|
83
|
+
* @param {boolean} [options.dryRun=false] - Only report what would be done
|
|
84
|
+
* @param {Function} [options.onProgress] - Callback(index, total, status, url)
|
|
85
|
+
* @param {string[]} [options.excludeDomains] - Domains to skip (already local)
|
|
86
|
+
* @returns {Promise<Object>} Result with updated markdown and metadata
|
|
87
|
+
*/
|
|
88
|
+
export async function localizeImages(markdownText, options = {}) {
|
|
89
|
+
const {
|
|
90
|
+
imagesDir = 'images',
|
|
91
|
+
dryRun = false,
|
|
92
|
+
onProgress,
|
|
93
|
+
excludeDomains = [],
|
|
94
|
+
} = options;
|
|
95
|
+
|
|
96
|
+
const allImages = extractImageReferences(markdownText);
|
|
97
|
+
|
|
98
|
+
// Filter to only external images not already localized
|
|
99
|
+
const externalImages = allImages.filter((img) => {
|
|
100
|
+
if (!img.url.startsWith('http')) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (img.url.includes(`${imagesDir}/`)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
for (const domain of excludeDomains) {
|
|
107
|
+
if (img.url.includes(domain)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (externalImages.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
markdown: markdownText,
|
|
117
|
+
downloaded: 0,
|
|
118
|
+
total: 0,
|
|
119
|
+
replacements: [],
|
|
120
|
+
metadata: [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const replacements = [];
|
|
125
|
+
const metadata = [];
|
|
126
|
+
let downloadedCount = 0;
|
|
127
|
+
let updatedMarkdown = markdownText;
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < externalImages.length; i++) {
|
|
130
|
+
const image = externalImages[i];
|
|
131
|
+
const localFilename = generateLocalFilename(image.url, i);
|
|
132
|
+
const relativePath = `${imagesDir}/${localFilename}`;
|
|
133
|
+
|
|
134
|
+
if (onProgress) {
|
|
135
|
+
onProgress(i + 1, externalImages.length, 'downloading', image.url);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (dryRun) {
|
|
139
|
+
replacements.push({
|
|
140
|
+
from: image.fullMatch,
|
|
141
|
+
to: ``,
|
|
142
|
+
});
|
|
143
|
+
metadata.push({
|
|
144
|
+
index: i + 1,
|
|
145
|
+
originalUrl: image.url,
|
|
146
|
+
altText: image.altText,
|
|
147
|
+
localPath: relativePath,
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const resp = await retry(() => fetch(image.url), {
|
|
154
|
+
retries: 3,
|
|
155
|
+
baseDelay: 1000,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!resp.ok) {
|
|
159
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const buffer = await resp.buffer();
|
|
163
|
+
downloadedCount++;
|
|
164
|
+
|
|
165
|
+
replacements.push({
|
|
166
|
+
from: image.fullMatch,
|
|
167
|
+
to: ``,
|
|
168
|
+
buffer,
|
|
169
|
+
filename: localFilename,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
metadata.push({
|
|
173
|
+
index: i + 1,
|
|
174
|
+
originalUrl: image.url,
|
|
175
|
+
altText: image.altText,
|
|
176
|
+
localPath: relativePath,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (onProgress) {
|
|
180
|
+
onProgress(i + 1, externalImages.length, 'downloaded', image.url);
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
if (onProgress) {
|
|
184
|
+
onProgress(i + 1, externalImages.length, 'failed', image.url);
|
|
185
|
+
}
|
|
186
|
+
// Keep original URL if download fails
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Apply replacements to markdown
|
|
191
|
+
for (const replacement of replacements) {
|
|
192
|
+
updatedMarkdown = updatedMarkdown.replace(replacement.from, replacement.to);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
markdown: updatedMarkdown,
|
|
197
|
+
downloaded: downloadedCount,
|
|
198
|
+
total: externalImages.length,
|
|
199
|
+
replacements,
|
|
200
|
+
metadata,
|
|
201
|
+
};
|
|
202
|
+
}
|