@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
@@ -0,0 +1,114 @@
1
+ import {
2
+ getArticle,
3
+ getAllVersions,
4
+ getAllArticles,
5
+ createConfigFromUrls,
6
+ validateConfig,
7
+ } from '../../src/batch.js';
8
+
9
+ describe('batch module', () => {
10
+ const sampleConfig = {
11
+ articles: {
12
+ '0.0.0': {
13
+ url: 'https://example.com/article-1',
14
+ title: 'First Article',
15
+ language: 'en',
16
+ },
17
+ '0.0.1': {
18
+ url: 'https://example.com/article-2',
19
+ title: 'Second Article',
20
+ language: 'ru',
21
+ },
22
+ },
23
+ defaults: {
24
+ archivePath: 'archive',
25
+ markdownFile: 'document.md',
26
+ imagesDir: 'images',
27
+ hasLocalImages: true,
28
+ },
29
+ };
30
+
31
+ describe('getArticle', () => {
32
+ it('returns article config by version', () => {
33
+ const article = getArticle(sampleConfig, '0.0.0');
34
+ expect(article.url).toBe('https://example.com/article-1');
35
+ expect(article.title).toBe('First Article');
36
+ });
37
+
38
+ it('merges defaults into article config', () => {
39
+ const article = getArticle(sampleConfig, '0.0.0');
40
+ expect(article.markdownFile).toBe('document.md');
41
+ expect(article.hasLocalImages).toBe(true);
42
+ });
43
+
44
+ it('throws for unknown version', () => {
45
+ expect(() => getArticle(sampleConfig, '9.9.9')).toThrow(
46
+ /Unknown article version/
47
+ );
48
+ });
49
+ });
50
+
51
+ describe('getAllVersions', () => {
52
+ it('returns all version keys', () => {
53
+ const versions = getAllVersions(sampleConfig);
54
+ expect(versions).toEqual(['0.0.0', '0.0.1']);
55
+ });
56
+ });
57
+
58
+ describe('getAllArticles', () => {
59
+ it('returns all articles with defaults merged', () => {
60
+ const articles = getAllArticles(sampleConfig);
61
+ expect(articles).toHaveLength(2);
62
+ expect(articles[0].markdownFile).toBe('document.md');
63
+ expect(articles[1].language).toBe('ru');
64
+ });
65
+ });
66
+
67
+ describe('createConfigFromUrls', () => {
68
+ it('creates config from URL list', () => {
69
+ const config = createConfigFromUrls([
70
+ 'https://example.com/a',
71
+ 'https://example.com/b',
72
+ ]);
73
+ expect(Object.keys(config.articles)).toHaveLength(2);
74
+ expect(config.articles['1'].url).toBe('https://example.com/a');
75
+ expect(config.articles['2'].url).toBe('https://example.com/b');
76
+ });
77
+
78
+ it('sets default archive paths', () => {
79
+ const config = createConfigFromUrls(['https://habr.com/article/123']);
80
+ expect(config.articles['1'].archivePath).toContain('habr-com');
81
+ expect(config.articles['1'].markdownFile).toBe('document.md');
82
+ });
83
+ });
84
+
85
+ describe('validateConfig', () => {
86
+ it('validates correct config', () => {
87
+ const result = validateConfig(sampleConfig);
88
+ expect(result.valid).toBe(true);
89
+ expect(result.errors).toHaveLength(0);
90
+ });
91
+
92
+ it('rejects config without articles', () => {
93
+ const result = validateConfig({});
94
+ expect(result.valid).toBe(false);
95
+ expect(result.errors[0]).toContain('articles');
96
+ });
97
+
98
+ it('rejects article without url', () => {
99
+ const result = validateConfig({
100
+ articles: { 1: { title: 'No URL' } },
101
+ });
102
+ expect(result.valid).toBe(false);
103
+ expect(result.errors[0]).toContain('missing required "url"');
104
+ });
105
+
106
+ it('rejects article with invalid url', () => {
107
+ const result = validateConfig({
108
+ articles: { 1: { url: 'not-a-url' } },
109
+ });
110
+ expect(result.valid).toBe(false);
111
+ expect(result.errors[0]).toContain('invalid URL');
112
+ });
113
+ });
114
+ });
@@ -80,7 +80,7 @@ describe('Browser Abstraction Layer', () => {
80
80
  expect(page.goto).toBeDefined();
81
81
  expect(page.content).toBeDefined();
82
82
  expect(page.screenshot).toBeDefined();
83
- expect(page._type).toBe('puppeteer');
83
+ expect(page.type).toBe('puppeteer');
84
84
  await browser.close();
85
85
  });
86
86
  });
@@ -101,7 +101,7 @@ describe('Browser Abstraction Layer', () => {
101
101
  expect(page.goto).toBeDefined();
102
102
  expect(page.content).toBeDefined();
103
103
  expect(page.screenshot).toBeDefined();
104
- expect(page._type).toBe('playwright');
104
+ expect(page.type).toBe('playwright');
105
105
  await browser.close();
106
106
  });
107
107
  });
@@ -132,5 +132,41 @@ describe('Browser Abstraction Layer', () => {
132
132
  await puppeteerBrowser.close();
133
133
  await playwrightBrowser.close();
134
134
  });
135
+
136
+ it('both adapters expose browser-commander v0.7.0+ APIs', async () => {
137
+ const puppeteerBrowser = await createBrowser('puppeteer');
138
+ const playwrightBrowser = await createBrowser('playwright');
139
+
140
+ const puppeteerPage = await puppeteerBrowser.newPage();
141
+ const playwrightPage = await playwrightBrowser.newPage();
142
+
143
+ // browser-commander v0.7.0: keyboard interaction
144
+ expect(puppeteerPage.keyboard).toBeDefined();
145
+ expect(puppeteerPage.keyboard.press).toBeDefined();
146
+ expect(puppeteerPage.keyboard.type).toBeDefined();
147
+ expect(playwrightPage.keyboard).toBeDefined();
148
+ expect(playwrightPage.keyboard.press).toBeDefined();
149
+
150
+ // browser-commander v0.7.0: dialog handling
151
+ expect(puppeteerPage.onDialog).toBeDefined();
152
+ expect(puppeteerPage.offDialog).toBeDefined();
153
+ expect(puppeteerPage.clearDialogHandlers).toBeDefined();
154
+ expect(playwrightPage.onDialog).toBeDefined();
155
+
156
+ // browser-commander v0.7.0: media emulation
157
+ expect(puppeteerPage.emulateMedia).toBeDefined();
158
+ expect(playwrightPage.emulateMedia).toBeDefined();
159
+
160
+ // browser-commander v0.8.0: PDF generation
161
+ expect(puppeteerPage.pdf).toBeDefined();
162
+ expect(playwrightPage.pdf).toBeDefined();
163
+
164
+ // browser-commander: evaluate
165
+ expect(puppeteerPage.evaluate).toBeDefined();
166
+ expect(playwrightPage.evaluate).toBeDefined();
167
+
168
+ await puppeteerBrowser.close();
169
+ await playwrightBrowser.close();
170
+ });
135
171
  });
136
172
  });
@@ -0,0 +1,233 @@
1
+ import { createHash } from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import {
6
+ extractAndSaveImages,
7
+ extractBase64ToBuffers,
8
+ stripBase64Images,
9
+ hasBase64Images,
10
+ } from '../../src/extract-images.js';
11
+
12
+ describe('extract-images module', () => {
13
+ let tmpDir;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'extract-images-test-'));
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ // 1x1 red PNG pixel as base64
24
+ const TINY_PNG =
25
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
26
+ // 1x1 white JPEG as base64
27
+ const TINY_JPEG =
28
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP///wAAAf/bAEMA/9sAQwD/2wBDAf///wAAAf/bAEMA/9sAQwD/2Q==';
29
+
30
+ function contentHash(base64Data) {
31
+ const buf = Buffer.from(base64Data, 'base64');
32
+ return createHash('sha256').update(buf).digest('hex').slice(0, 8);
33
+ }
34
+
35
+ const PNG_HASH = contentHash(TINY_PNG);
36
+ const JPEG_HASH = contentHash(TINY_JPEG);
37
+
38
+ describe('extractAndSaveImages', () => {
39
+ it('extracts a single PNG image from markdown', () => {
40
+ const md = `# Hello\n\n![test](data:image/png;base64,${TINY_PNG})\n\nEnd.`;
41
+ const result = extractAndSaveImages(md, tmpDir);
42
+
43
+ expect(result.extracted).toBe(1);
44
+ expect(result.markdown).toContain(
45
+ `![test](images/image-${PNG_HASH}.png)`
46
+ );
47
+ expect(result.markdown).not.toContain('data:image');
48
+
49
+ const imgPath = path.join(tmpDir, 'images', `image-${PNG_HASH}.png`);
50
+ expect(fs.existsSync(imgPath)).toBe(true);
51
+ const buf = fs.readFileSync(imgPath);
52
+ expect(buf.length).toBeGreaterThan(0);
53
+ // Verify PNG magic bytes
54
+ expect(buf[0]).toBe(0x89);
55
+ expect(buf[1]).toBe(0x50); // P
56
+ });
57
+
58
+ it('extracts multiple images with content-hash filenames', () => {
59
+ const md = [
60
+ `![a](data:image/png;base64,${TINY_PNG})`,
61
+ `![b](data:image/jpeg;base64,${TINY_JPEG})`,
62
+ ].join('\n');
63
+
64
+ const result = extractAndSaveImages(md, tmpDir);
65
+ expect(result.extracted).toBe(2);
66
+ expect(result.markdown).toContain(`images/image-${PNG_HASH}.png`);
67
+ expect(result.markdown).toContain(`images/image-${JPEG_HASH}.jpg`);
68
+ });
69
+
70
+ it('produces stable filenames for duplicate images', () => {
71
+ const md = [
72
+ `![a](data:image/png;base64,${TINY_PNG})`,
73
+ `![b](data:image/png;base64,${TINY_PNG})`,
74
+ ].join('\n');
75
+
76
+ const result = extractAndSaveImages(md, tmpDir);
77
+ expect(result.extracted).toBe(2);
78
+ // Both should produce the same hash-based filename
79
+ const matches = result.markdown.match(
80
+ new RegExp(`image-${PNG_HASH}\\.png`, 'g')
81
+ );
82
+ expect(matches).toHaveLength(2);
83
+ });
84
+
85
+ it('handles JPEG mime type correctly', () => {
86
+ const md = `![photo](data:image/jpeg;base64,${TINY_JPEG})`;
87
+ const result = extractAndSaveImages(md, tmpDir);
88
+
89
+ expect(result.extracted).toBe(1);
90
+ expect(result.markdown).toContain(`images/image-${JPEG_HASH}.jpg`);
91
+ });
92
+
93
+ it('uses custom imagesDir', () => {
94
+ const md = `![img](data:image/png;base64,${TINY_PNG})`;
95
+ const result = extractAndSaveImages(md, tmpDir, {
96
+ imagesDir: 'my-images',
97
+ });
98
+
99
+ expect(result.extracted).toBe(1);
100
+ expect(result.markdown).toContain(`my-images/image-${PNG_HASH}.png`);
101
+ expect(
102
+ fs.existsSync(path.join(tmpDir, 'my-images', `image-${PNG_HASH}.png`))
103
+ ).toBe(true);
104
+ });
105
+
106
+ it('returns markdown unchanged when no base64 images', () => {
107
+ const md = '# No images\n\nJust text.';
108
+ const result = extractAndSaveImages(md, tmpDir);
109
+
110
+ expect(result.extracted).toBe(0);
111
+ expect(result.markdown).toBe(md);
112
+ expect(fs.existsSync(path.join(tmpDir, 'images'))).toBe(false);
113
+ });
114
+
115
+ it('preserves non-base64 image references', () => {
116
+ const md =
117
+ '![remote](https://example.com/img.png)\n![local](images/existing.png)';
118
+ const result = extractAndSaveImages(md, tmpDir);
119
+
120
+ expect(result.extracted).toBe(0);
121
+ expect(result.markdown).toBe(md);
122
+ });
123
+
124
+ it('preserves alt text during extraction', () => {
125
+ const md = `![A descriptive alt text](data:image/png;base64,${TINY_PNG})`;
126
+ const result = extractAndSaveImages(md, tmpDir);
127
+
128
+ expect(result.markdown).toContain(
129
+ `![A descriptive alt text](images/image-${PNG_HASH}.png)`
130
+ );
131
+ });
132
+
133
+ it('handles SVG data URIs', () => {
134
+ const svgContent =
135
+ '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect fill="red" width="1" height="1"/></svg>';
136
+ const svgBase64 = Buffer.from(svgContent).toString('base64');
137
+ const svgHash = contentHash(svgBase64);
138
+ const md = `![icon](data:image/svg+xml;base64,${svgBase64})`;
139
+ const result = extractAndSaveImages(md, tmpDir);
140
+
141
+ expect(result.extracted).toBe(1);
142
+ expect(result.markdown).toContain(`images/image-${svgHash}.svg`);
143
+
144
+ const imgPath = path.join(tmpDir, 'images', `image-${svgHash}.svg`);
145
+ expect(fs.existsSync(imgPath)).toBe(true);
146
+ const content = fs.readFileSync(imgPath, 'utf-8');
147
+ expect(content).toContain('<svg');
148
+ });
149
+ });
150
+
151
+ describe('extractBase64ToBuffers', () => {
152
+ it('extracts base64 images to in-memory buffers', () => {
153
+ const md = `# Hello\n\n![test](data:image/png;base64,${TINY_PNG})\n\nEnd.`;
154
+ const result = extractBase64ToBuffers(md);
155
+
156
+ expect(result.images).toHaveLength(1);
157
+ expect(result.images[0].filename).toMatch(/^image-[0-9a-f]{8}\.png$/);
158
+ expect(result.images[0].buffer.length).toBeGreaterThan(0);
159
+ expect(result.markdown).toContain(`images/${result.images[0].filename}`);
160
+ expect(result.markdown).not.toContain('data:image');
161
+ });
162
+
163
+ it('uses custom imagesDir prefix', () => {
164
+ const md = `![img](data:image/png;base64,${TINY_PNG})`;
165
+ const result = extractBase64ToBuffers(md, 'assets');
166
+
167
+ expect(result.markdown).toContain('assets/image-');
168
+ });
169
+
170
+ it('does not write files to disk', () => {
171
+ const md = `![img](data:image/png;base64,${TINY_PNG})`;
172
+ extractBase64ToBuffers(md);
173
+ expect(fs.existsSync(path.join(tmpDir, 'images'))).toBe(false);
174
+ });
175
+ });
176
+
177
+ describe('stripBase64Images', () => {
178
+ it('strips base64 images and leaves alt text placeholder', () => {
179
+ const md = `![my image](data:image/png;base64,${TINY_PNG})`;
180
+ const result = stripBase64Images(md);
181
+
182
+ expect(result.stripped).toBe(1);
183
+ expect(result.markdown).toBe('*[image: my image]*');
184
+ expect(result.markdown).not.toContain('data:image');
185
+ });
186
+
187
+ it('strips base64 images with empty alt text', () => {
188
+ const md = `![](data:image/png;base64,${TINY_PNG})`;
189
+ const result = stripBase64Images(md);
190
+
191
+ expect(result.stripped).toBe(1);
192
+ expect(result.markdown).toBe('');
193
+ });
194
+
195
+ it('preserves remote image URLs', () => {
196
+ const md = '![remote](https://example.com/img.png)';
197
+ const result = stripBase64Images(md);
198
+
199
+ expect(result.stripped).toBe(0);
200
+ expect(result.markdown).toBe(md);
201
+ });
202
+
203
+ it('strips multiple base64 images', () => {
204
+ const md = [
205
+ `![a](data:image/png;base64,${TINY_PNG})`,
206
+ `![b](data:image/jpeg;base64,${TINY_JPEG})`,
207
+ '![c](https://example.com/img.png)',
208
+ ].join('\n');
209
+ const result = stripBase64Images(md);
210
+
211
+ expect(result.stripped).toBe(2);
212
+ expect(result.markdown).toContain('*[image: a]*');
213
+ expect(result.markdown).toContain('*[image: b]*');
214
+ expect(result.markdown).toContain('https://example.com/img.png');
215
+ });
216
+ });
217
+
218
+ describe('hasBase64Images', () => {
219
+ it('returns true when markdown has base64 images', () => {
220
+ expect(hasBase64Images(`![x](data:image/png;base64,${TINY_PNG})`)).toBe(
221
+ true
222
+ );
223
+ });
224
+
225
+ it('returns false when no base64 images', () => {
226
+ expect(hasBase64Images('![x](https://example.com/img.png)')).toBe(false);
227
+ });
228
+
229
+ it('returns false for empty string', () => {
230
+ expect(hasBase64Images('')).toBe(false);
231
+ });
232
+ });
233
+ });
@@ -0,0 +1,74 @@
1
+ import { extractFigures } from '../../src/figures.js';
2
+
3
+ describe('figures module', () => {
4
+ describe('extractFigures', () => {
5
+ it('extracts figures with captions', () => {
6
+ const html = `
7
+ <figure>
8
+ <img src="https://example.com/fig1.png" alt="Graph">
9
+ <figcaption>Figure 1. The relationship graph</figcaption>
10
+ </figure>
11
+ `;
12
+ const figures = extractFigures(html, 'https://example.com');
13
+ expect(figures).toHaveLength(1);
14
+ expect(figures[0].figureNum).toBe(1);
15
+ expect(figures[0].src).toBe('https://example.com/fig1.png');
16
+ expect(figures[0].caption).toContain('Figure 1');
17
+ });
18
+
19
+ it('handles Russian figure captions', () => {
20
+ const html = `
21
+ <figure>
22
+ <img src="https://example.com/fig.png" alt="">
23
+ <figcaption>Рис. 5. Описание</figcaption>
24
+ </figure>
25
+ `;
26
+ const figures = extractFigures(html, 'https://example.com');
27
+ expect(figures[0].figureNum).toBe(5);
28
+ });
29
+
30
+ it('uses sequential numbering when no caption number', () => {
31
+ const html = `
32
+ <figure>
33
+ <img src="https://example.com/a.png" alt="">
34
+ <figcaption>Some caption without number</figcaption>
35
+ </figure>
36
+ <figure>
37
+ <img src="https://example.com/b.png" alt="">
38
+ </figure>
39
+ `;
40
+ const figures = extractFigures(html, 'https://example.com');
41
+ expect(figures).toHaveLength(2);
42
+ expect(figures[0].figureNum).toBe(1);
43
+ expect(figures[1].figureNum).toBe(2);
44
+ });
45
+
46
+ it('skips SVG images', () => {
47
+ const html = `
48
+ <figure>
49
+ <img src="https://example.com/icon.svg" alt="">
50
+ </figure>
51
+ `;
52
+ const figures = extractFigures(html, 'https://example.com');
53
+ expect(figures).toHaveLength(0);
54
+ });
55
+
56
+ it('resolves relative URLs', () => {
57
+ const html = `
58
+ <figure>
59
+ <img src="/images/fig.png" alt="">
60
+ </figure>
61
+ `;
62
+ const figures = extractFigures(html, 'https://example.com');
63
+ expect(figures[0].src).toBe('https://example.com/images/fig.png');
64
+ });
65
+
66
+ it('returns empty array for no figures', () => {
67
+ const figures = extractFigures(
68
+ '<div>no figures</div>',
69
+ 'https://example.com'
70
+ );
71
+ expect(figures).toHaveLength(0);
72
+ });
73
+ });
74
+ });