@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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.6",
3
+ "version": "1.7.8",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- const $ = cheerio.load(html);
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().trim();
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);
@@ -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 = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
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 = generateLocalFilename(image.url, i);
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, convertHtmlToMarkdown } from './lib.js';
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 = convertHtmlToMarkdown(html, url);
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;
@@ -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 { convertHtmlToMarkdown } from '../../src/lib.js';
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
+ '![Figure 1](https://habrastorage.org/r/w1560/getpro/habr/upload_files/abc.png)'
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
+ '![Figure 1](https://habrastorage.org/r/w1560/getpro/habr/figure.png "Figure 1")';
96
+
97
+ const result = await localizeImages(markdown, {
98
+ dryRun: true,
99
+ filenameGenerator: generateFigureFilename,
100
+ });
101
+
102
+ expect(result.markdown).toBe('![Figure 1](images/figure-1.png)');
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
  });