@link-assistant/web-capture 1.7.6 → 1.7.8
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 +13 -0
- package/bin/web-capture.js +16 -1
- package/package.json +1 -1
- package/src/lib.js +32 -2
- package/src/localize-images.js +17 -2
- package/src/markdown.js +5 -2
- package/tests/unit/cli.test.js +23 -1
- package/tests/unit/html2md.test.js +86 -1
- package/tests/unit/localize-images.test.js +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e951b65: Fix JavaScript CLI version output when run from another npm project.
|
|
8
|
+
- aa4888d: Add content selector options for article-only Markdown capture while preserving full-page metadata extraction.
|
|
9
|
+
|
|
10
|
+
## 1.7.7
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 9034d01: Add Habr archive-compatible markdown controls for figure-numbered image localization and preserved code block whitespace.
|
|
15
|
+
|
|
3
16
|
## 1.7.6
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/bin/web-capture.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'path';
|
|
|
9
9
|
import { URL } from 'node:url';
|
|
10
10
|
import { makeConfig } from 'lino-arguments';
|
|
11
11
|
import makeLog from 'log-lazy';
|
|
12
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
12
13
|
|
|
13
14
|
function makeVerboseLog(enabled) {
|
|
14
15
|
return makeLog({
|
|
@@ -138,6 +139,18 @@ const config = makeConfig({
|
|
|
138
139
|
'Detect and correct code block languages (default: true). Use --no-detect-code-language to disable.',
|
|
139
140
|
default: getenv('WEB_CAPTURE_DETECT_CODE_LANGUAGE', true),
|
|
140
141
|
})
|
|
142
|
+
.option('contentSelector', {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description:
|
|
145
|
+
'CSS selector used to scope markdown conversion while preserving full-page metadata extraction.',
|
|
146
|
+
default: getenv('WEB_CAPTURE_CONTENT_SELECTOR', undefined),
|
|
147
|
+
})
|
|
148
|
+
.option('bodySelector', {
|
|
149
|
+
type: 'string',
|
|
150
|
+
description:
|
|
151
|
+
'CSS selector for article body markdown; prepends the selected article title when available.',
|
|
152
|
+
default: getenv('WEB_CAPTURE_BODY_SELECTOR', undefined),
|
|
153
|
+
})
|
|
141
154
|
.option('embedImages', {
|
|
142
155
|
type: 'boolean',
|
|
143
156
|
description:
|
|
@@ -213,7 +226,7 @@ const config = makeConfig({
|
|
|
213
226
|
})
|
|
214
227
|
.help('help')
|
|
215
228
|
.alias('help', 'h')
|
|
216
|
-
.version()
|
|
229
|
+
.version(packageJson.version)
|
|
217
230
|
.alias('version', 'v')
|
|
218
231
|
.example('web-capture --serve', 'Start API server on port 3000')
|
|
219
232
|
.example(
|
|
@@ -956,6 +969,8 @@ async function captureUrl(url, options) {
|
|
|
956
969
|
extractMetadata: options.extractMetadata,
|
|
957
970
|
postProcess: options.postProcess,
|
|
958
971
|
detectCodeLanguage: options.detectCodeLanguage,
|
|
972
|
+
contentSelector: options.contentSelector,
|
|
973
|
+
bodySelector: options.bodySelector,
|
|
959
974
|
});
|
|
960
975
|
let markdown = result.markdown;
|
|
961
976
|
|
package/package.json
CHANGED
package/src/lib.js
CHANGED
|
@@ -171,6 +171,14 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
171
171
|
return he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ');
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
function selectedHtml($, selector) {
|
|
175
|
+
if (!selector) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const $selected = $(selector).first();
|
|
179
|
+
return $selected.length ? $.html($selected) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
174
182
|
// Convert relative URLs to absolute URLs in HTML content
|
|
175
183
|
export function convertRelativeUrls(html, baseUrl) {
|
|
176
184
|
const base = new URL(baseUrl);
|
|
@@ -312,6 +320,9 @@ export function convertRelativeUrls(html, baseUrl) {
|
|
|
312
320
|
* @param {boolean} [options.extractMetadata=true] - Extract article metadata
|
|
313
321
|
* @param {boolean} [options.postProcess=true] - Apply post-processing pipeline
|
|
314
322
|
* @param {boolean} [options.detectCodeLanguage=true] - Detect/correct code languages
|
|
323
|
+
* @param {boolean} [options.preserveCodeWhitespace=false] - Keep original whitespace inside code blocks
|
|
324
|
+
* @param {string} [options.contentSelector] - CSS selector to scope Markdown conversion
|
|
325
|
+
* @param {string} [options.bodySelector] - CSS selector appended after the selected article title
|
|
315
326
|
* @returns {Object} Result with { markdown, metadata }
|
|
316
327
|
*/
|
|
317
328
|
export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
@@ -320,6 +331,9 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
320
331
|
extractMetadata: shouldExtractMetadata = true,
|
|
321
332
|
postProcess = true,
|
|
322
333
|
detectCodeLanguage = true,
|
|
334
|
+
preserveCodeWhitespace = false,
|
|
335
|
+
contentSelector,
|
|
336
|
+
bodySelector,
|
|
323
337
|
} = options;
|
|
324
338
|
|
|
325
339
|
// Ensure all URLs are absolute before Markdown conversion
|
|
@@ -327,7 +341,7 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
327
341
|
html = convertRelativeUrls(html, baseUrl);
|
|
328
342
|
}
|
|
329
343
|
|
|
330
|
-
|
|
344
|
+
let $ = cheerio.load(html);
|
|
331
345
|
|
|
332
346
|
// Extract metadata before cleaning
|
|
333
347
|
let metadata = null;
|
|
@@ -335,6 +349,16 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
335
349
|
metadata = extractMetadata($);
|
|
336
350
|
}
|
|
337
351
|
|
|
352
|
+
const bodyHtml = selectedHtml($, bodySelector);
|
|
353
|
+
const contentHtml = selectedHtml($, contentSelector);
|
|
354
|
+
if (bodyHtml || contentHtml) {
|
|
355
|
+
const titleSelector = contentSelector ? `${contentSelector} h1, h1` : 'h1';
|
|
356
|
+
const titleHtml = bodyHtml ? selectedHtml($, titleSelector) : null;
|
|
357
|
+
$ = cheerio.load(
|
|
358
|
+
[titleHtml, bodyHtml || contentHtml].filter(Boolean).join('\n')
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
338
362
|
// Remove unwanted elements
|
|
339
363
|
$('style, script, noscript').remove();
|
|
340
364
|
$('*').each(function () {
|
|
@@ -422,7 +446,7 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
422
446
|
// Handle code language detection/correction
|
|
423
447
|
if (detectCodeLanguage) {
|
|
424
448
|
$('pre code').each(function () {
|
|
425
|
-
const codeText = $(this).text()
|
|
449
|
+
const codeText = $(this).text();
|
|
426
450
|
const language =
|
|
427
451
|
$(this)
|
|
428
452
|
.attr('class')
|
|
@@ -444,6 +468,12 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
444
468
|
});
|
|
445
469
|
}
|
|
446
470
|
|
|
471
|
+
if (preserveCodeWhitespace) {
|
|
472
|
+
$('pre code').each(function () {
|
|
473
|
+
$(this).text($(this).text().replace(/\r\n?/g, '\n'));
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
447
477
|
// Preprocess ARIA tables (same as original)
|
|
448
478
|
$('[role="table"]').each(function () {
|
|
449
479
|
const $table = $(this);
|
package/src/localize-images.js
CHANGED
|
@@ -24,7 +24,8 @@ import { retry } from './retry.js';
|
|
|
24
24
|
* @returns {Object[]} Array of {fullMatch, altText, url}
|
|
25
25
|
*/
|
|
26
26
|
export function extractImageReferences(markdownText) {
|
|
27
|
-
const imageRegex =
|
|
27
|
+
const imageRegex =
|
|
28
|
+
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
28
29
|
const images = [];
|
|
29
30
|
let match;
|
|
30
31
|
|
|
@@ -73,6 +74,18 @@ export function generateLocalFilename(url, index) {
|
|
|
73
74
|
return `image-${String(index + 1).padStart(2, '0')}${ext}`;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Generate a stable figure-numbered filename.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} url - Image URL
|
|
81
|
+
* @param {number} index - Zero-based index
|
|
82
|
+
* @returns {string} Local filename (e.g., 'figure-1.png')
|
|
83
|
+
*/
|
|
84
|
+
export function generateFigureFilename(url, index) {
|
|
85
|
+
const ext = getExtensionFromUrl(url);
|
|
86
|
+
return `figure-${index + 1}${ext}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
/**
|
|
77
90
|
* Localize images in markdown text by downloading external images
|
|
78
91
|
* and replacing URLs with local paths.
|
|
@@ -83,6 +96,7 @@ export function generateLocalFilename(url, index) {
|
|
|
83
96
|
* @param {boolean} [options.dryRun=false] - Only report what would be done
|
|
84
97
|
* @param {Function} [options.onProgress] - Callback(index, total, status, url)
|
|
85
98
|
* @param {string[]} [options.excludeDomains] - Domains to skip (already local)
|
|
99
|
+
* @param {Function} [options.filenameGenerator] - Callback(url, index, image) for local filenames
|
|
86
100
|
* @returns {Promise<Object>} Result with updated markdown and metadata
|
|
87
101
|
*/
|
|
88
102
|
export async function localizeImages(markdownText, options = {}) {
|
|
@@ -91,6 +105,7 @@ export async function localizeImages(markdownText, options = {}) {
|
|
|
91
105
|
dryRun = false,
|
|
92
106
|
onProgress,
|
|
93
107
|
excludeDomains = [],
|
|
108
|
+
filenameGenerator = generateLocalFilename,
|
|
94
109
|
} = options;
|
|
95
110
|
|
|
96
111
|
const allImages = extractImageReferences(markdownText);
|
|
@@ -128,7 +143,7 @@ export async function localizeImages(markdownText, options = {}) {
|
|
|
128
143
|
|
|
129
144
|
for (let i = 0; i < externalImages.length; i++) {
|
|
130
145
|
const image = externalImages[i];
|
|
131
|
-
const localFilename =
|
|
146
|
+
const localFilename = filenameGenerator(image.url, i, image);
|
|
132
147
|
const relativePath = `${imagesDir}/${localFilename}`;
|
|
133
148
|
|
|
134
149
|
if (onProgress) {
|
package/src/markdown.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchHtml,
|
|
1
|
+
import { fetchHtml, convertHtmlToMarkdownEnhanced } from './lib.js';
|
|
2
2
|
import { stripBase64Images } from './extract-images.js';
|
|
3
3
|
|
|
4
4
|
export async function markdownHandler(req, res) {
|
|
@@ -9,7 +9,10 @@ export async function markdownHandler(req, res) {
|
|
|
9
9
|
const embedImages = req.query.embedImages === 'true';
|
|
10
10
|
try {
|
|
11
11
|
const html = await fetchHtml(url);
|
|
12
|
-
let markdown =
|
|
12
|
+
let { markdown } = convertHtmlToMarkdownEnhanced(html, url, {
|
|
13
|
+
contentSelector: req.query.contentSelector,
|
|
14
|
+
bodySelector: req.query.bodySelector,
|
|
15
|
+
});
|
|
13
16
|
if (!embedImages) {
|
|
14
17
|
const strip = stripBase64Images(markdown);
|
|
15
18
|
markdown = strip.markdown;
|
package/tests/unit/cli.test.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Unit tests for CLI argument parsing and functionality
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
3
|
import http from 'node:http';
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { dirname, resolve } from 'path';
|
|
6
|
+
import { dirname, join, resolve } from 'path';
|
|
7
|
+
import packageJson from '../../package.json' with { type: 'json' };
|
|
6
8
|
|
|
7
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
10
|
const __dirname = dirname(__filename);
|
|
@@ -108,6 +110,26 @@ describe('CLI', () => {
|
|
|
108
110
|
// yargs outputs just the version number
|
|
109
111
|
expect(result.stdout).toMatch(/\d+\.\d+\.\d+/);
|
|
110
112
|
});
|
|
113
|
+
|
|
114
|
+
test('shows web-capture version when run from another npm project', async () => {
|
|
115
|
+
const callerProjectDir = mkdtempSync(join(process.cwd(), 'caller-'));
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(callerProjectDir, 'package.json'),
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
name: 'caller-project',
|
|
120
|
+
version: '1.0.0',
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const result = await runCli(['--version'], { cwd: callerProjectDir });
|
|
126
|
+
|
|
127
|
+
expect(result.code).toBe(0);
|
|
128
|
+
expect(result.stdout.trim()).toBe(packageJson.version);
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(callerProjectDir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
111
133
|
});
|
|
112
134
|
|
|
113
135
|
describe('URL validation', () => {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
convertHtmlToMarkdown,
|
|
3
|
+
convertHtmlToMarkdownEnhanced,
|
|
4
|
+
} from '../../src/lib.js';
|
|
2
5
|
|
|
3
6
|
describe('convertHtmlToMarkdown', () => {
|
|
4
7
|
it('removes empty links and anchor headings', () => {
|
|
@@ -608,3 +611,85 @@ describe('convertHtmlToMarkdown', () => {
|
|
|
608
611
|
);
|
|
609
612
|
});
|
|
610
613
|
});
|
|
614
|
+
|
|
615
|
+
describe('convertHtmlToMarkdownEnhanced content selectors', () => {
|
|
616
|
+
it('scopes Habr-shaped article markdown while keeping full-page metadata', () => {
|
|
617
|
+
const html = `
|
|
618
|
+
<html>
|
|
619
|
+
<head><meta name="keywords" content="links, theory"></head>
|
|
620
|
+
<body>
|
|
621
|
+
<nav><a href="/en/feed">Habr</a><a href="/en/search">Search</a></nav>
|
|
622
|
+
<a href="/en/sandbox/start/">Write a publication</a>
|
|
623
|
+
<article>
|
|
624
|
+
<header>
|
|
625
|
+
<h1>The Links Theory 0.0.2</h1>
|
|
626
|
+
<a class="tm-user-info__username" href="/users/links">links</a>
|
|
627
|
+
<time datetime="2026-04-01T00:00:00Z">April 1</time>
|
|
628
|
+
</header>
|
|
629
|
+
<div class="article-formatted-body">
|
|
630
|
+
<p>Last April 1st, as you might have guessed, the project shipped.</p>
|
|
631
|
+
</div>
|
|
632
|
+
</article>
|
|
633
|
+
</body>
|
|
634
|
+
</html>
|
|
635
|
+
`;
|
|
636
|
+
|
|
637
|
+
const result = convertHtmlToMarkdownEnhanced(
|
|
638
|
+
html,
|
|
639
|
+
'https://habr.com/en/articles/895896/',
|
|
640
|
+
{
|
|
641
|
+
contentSelector: 'article',
|
|
642
|
+
bodySelector: '.article-formatted-body',
|
|
643
|
+
}
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
expect(result.markdown.trim()).toMatch(/^# The Links Theory 0\.0\.2/);
|
|
647
|
+
expect(result.markdown).toContain('Last April 1st');
|
|
648
|
+
expect(result.markdown).not.toContain('Habr');
|
|
649
|
+
expect(result.markdown).not.toContain('Search');
|
|
650
|
+
expect(result.markdown).not.toContain('Write a publication');
|
|
651
|
+
expect(result.metadata.author).toBe('links');
|
|
652
|
+
expect(result.metadata.tags).toEqual(['links', 'theory']);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
describe('convertHtmlToMarkdownEnhanced Habr compatibility', () => {
|
|
657
|
+
it('preserves article code line breaks and extracts formulas from a Habr fixture', () => {
|
|
658
|
+
const html = `
|
|
659
|
+
<article>
|
|
660
|
+
<h1>The Links Theory 0.0.2</h1>
|
|
661
|
+
<div class="article-formatted-body">
|
|
662
|
+
<p>A formula <img class="formula" source="\\mathbf{L}: L \\to L^2" alt="formula"> remains inline.</p>
|
|
663
|
+
<figure>
|
|
664
|
+
<img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/abc.png" alt="Figure 1">
|
|
665
|
+
<figcaption>Figure 1. Link multiplication.</figcaption>
|
|
666
|
+
</figure>
|
|
667
|
+
<pre><code>L = { 1 , 2 }
|
|
668
|
+
L x L = {
|
|
669
|
+
(1, 1),
|
|
670
|
+
(1, 2),
|
|
671
|
+
}</code></pre>
|
|
672
|
+
</div>
|
|
673
|
+
</article>
|
|
674
|
+
`;
|
|
675
|
+
|
|
676
|
+
const { markdown } = convertHtmlToMarkdownEnhanced(
|
|
677
|
+
html,
|
|
678
|
+
'https://habr.com/en/articles/895896/',
|
|
679
|
+
{ preserveCodeWhitespace: true }
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
expect(markdown).toContain('$\\\\mathbf{L}: L \\\\to L^2$');
|
|
683
|
+
expect(markdown).toContain(
|
|
684
|
+
''
|
|
685
|
+
);
|
|
686
|
+
expect(markdown).toContain(`\`\`\`
|
|
687
|
+
L = { 1 , 2 }
|
|
688
|
+
L x L = {
|
|
689
|
+
(1, 1),
|
|
690
|
+
(1, 2),
|
|
691
|
+
}
|
|
692
|
+
\`\`\``);
|
|
693
|
+
expect(markdown).not.toContain('L = { 1 , 2 }L x L');
|
|
694
|
+
});
|
|
695
|
+
});
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
extractImageReferences,
|
|
3
3
|
getExtensionFromUrl,
|
|
4
4
|
generateLocalFilename,
|
|
5
|
+
generateFigureFilename,
|
|
6
|
+
localizeImages,
|
|
5
7
|
} from '../../src/localize-images.js';
|
|
6
8
|
|
|
7
9
|
describe('localize-images module', () => {
|
|
@@ -75,4 +77,38 @@ describe('localize-images module', () => {
|
|
|
75
77
|
);
|
|
76
78
|
});
|
|
77
79
|
});
|
|
80
|
+
|
|
81
|
+
describe('generateFigureFilename', () => {
|
|
82
|
+
it('generates figure-numbered filenames while preserving extensions', () => {
|
|
83
|
+
expect(
|
|
84
|
+
generateFigureFilename('https://habrastorage.org/getpro/habr/a.png', 0)
|
|
85
|
+
).toBe('figure-1.png');
|
|
86
|
+
expect(
|
|
87
|
+
generateFigureFilename('https://habrastorage.org/getpro/habr/a.jpg', 9)
|
|
88
|
+
).toBe('figure-10.jpg');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('localizeImages', () => {
|
|
93
|
+
it('accepts a custom filename generator for archive-compatible paths', async () => {
|
|
94
|
+
const markdown =
|
|
95
|
+
'';
|
|
96
|
+
|
|
97
|
+
const result = await localizeImages(markdown, {
|
|
98
|
+
dryRun: true,
|
|
99
|
+
filenameGenerator: generateFigureFilename,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.markdown).toBe('');
|
|
103
|
+
expect(result.metadata).toEqual([
|
|
104
|
+
{
|
|
105
|
+
index: 1,
|
|
106
|
+
originalUrl:
|
|
107
|
+
'https://habrastorage.org/r/w1560/getpro/habr/figure.png',
|
|
108
|
+
altText: 'Figure 1',
|
|
109
|
+
localPath: 'images/figure-1.png',
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
78
114
|
});
|