@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
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
- return turndown.turndown($.html());
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: `![${image.altText}](${relativePath})`,
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: `![${image.altText}](${relativePath})`,
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
+ }