@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,278 @@
1
+ import { jest } from '@jest/globals';
2
+ import {
3
+ isGoogleDocsUrl,
4
+ extractDocumentId,
5
+ buildExportUrl,
6
+ extractBase64Images,
7
+ GDOCS_EXPORT_FORMATS,
8
+ } from '../../src/gdocs.js';
9
+ import {
10
+ extractAndSaveImages,
11
+ extractBase64ToBuffers,
12
+ stripBase64Images,
13
+ } from '../../src/extract-images.js';
14
+ import fs from 'fs';
15
+ import os from 'os';
16
+ import path from 'path';
17
+
18
+ jest.setTimeout(30000);
19
+
20
+ describe('gdocs', () => {
21
+ describe('isGoogleDocsUrl', () => {
22
+ it('returns true for standard Google Docs edit URL', () => {
23
+ expect(
24
+ isGoogleDocsUrl(
25
+ 'https://docs.google.com/document/d/1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4/edit'
26
+ )
27
+ ).toBe(true);
28
+ });
29
+
30
+ it('returns true for URL with query parameters', () => {
31
+ expect(
32
+ isGoogleDocsUrl(
33
+ 'https://docs.google.com/document/d/abc123/edit?tab=t.0'
34
+ )
35
+ ).toBe(true);
36
+ });
37
+
38
+ it('returns true for URL without /edit', () => {
39
+ expect(
40
+ isGoogleDocsUrl('https://docs.google.com/document/d/abc-123_XYZ/')
41
+ ).toBe(true);
42
+ });
43
+
44
+ it('returns false for non-Google Docs URL', () => {
45
+ expect(isGoogleDocsUrl('https://example.com')).toBe(false);
46
+ });
47
+
48
+ it('returns false for Google Sheets URL', () => {
49
+ expect(
50
+ isGoogleDocsUrl('https://docs.google.com/spreadsheets/d/abc123/edit')
51
+ ).toBe(false);
52
+ });
53
+
54
+ it('returns false for empty string', () => {
55
+ expect(isGoogleDocsUrl('')).toBe(false);
56
+ });
57
+
58
+ it('returns false for null', () => {
59
+ expect(isGoogleDocsUrl(null)).toBe(false);
60
+ });
61
+
62
+ it('returns false for undefined', () => {
63
+ expect(isGoogleDocsUrl(undefined)).toBe(false);
64
+ });
65
+
66
+ it('returns false for non-string input', () => {
67
+ expect(isGoogleDocsUrl(123)).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('extractDocumentId', () => {
72
+ it('extracts document ID from standard URL', () => {
73
+ expect(
74
+ extractDocumentId(
75
+ 'https://docs.google.com/document/d/1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4/edit'
76
+ )
77
+ ).toBe('1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4');
78
+ });
79
+
80
+ it('extracts document ID from URL with query params', () => {
81
+ expect(
82
+ extractDocumentId(
83
+ 'https://docs.google.com/document/d/abc123/edit?tab=t.0'
84
+ )
85
+ ).toBe('abc123');
86
+ });
87
+
88
+ it('extracts document ID with hyphens and underscores', () => {
89
+ expect(
90
+ extractDocumentId('https://docs.google.com/document/d/abc-123_XYZ/')
91
+ ).toBe('abc-123_XYZ');
92
+ });
93
+
94
+ it('returns null for non-Google Docs URL', () => {
95
+ expect(extractDocumentId('https://example.com')).toBeNull();
96
+ });
97
+
98
+ it('returns null for empty string', () => {
99
+ expect(extractDocumentId('')).toBeNull();
100
+ });
101
+
102
+ it('returns null for null input', () => {
103
+ expect(extractDocumentId(null)).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe('buildExportUrl', () => {
108
+ it('builds HTML export URL', () => {
109
+ expect(buildExportUrl('abc123', 'html')).toBe(
110
+ 'https://docs.google.com/document/d/abc123/export?format=html'
111
+ );
112
+ });
113
+
114
+ it('builds Markdown export URL', () => {
115
+ expect(buildExportUrl('abc123', 'md')).toBe(
116
+ 'https://docs.google.com/document/d/abc123/export?format=md'
117
+ );
118
+ });
119
+
120
+ it('builds PDF export URL', () => {
121
+ expect(buildExportUrl('abc123', 'pdf')).toBe(
122
+ 'https://docs.google.com/document/d/abc123/export?format=pdf'
123
+ );
124
+ });
125
+
126
+ it('builds TXT export URL', () => {
127
+ expect(buildExportUrl('abc123', 'txt')).toBe(
128
+ 'https://docs.google.com/document/d/abc123/export?format=txt'
129
+ );
130
+ });
131
+
132
+ it('defaults to html for unknown format', () => {
133
+ expect(buildExportUrl('abc123', 'invalid')).toBe(
134
+ 'https://docs.google.com/document/d/abc123/export?format=html'
135
+ );
136
+ });
137
+
138
+ it('defaults to html when format is omitted', () => {
139
+ expect(buildExportUrl('abc123')).toBe(
140
+ 'https://docs.google.com/document/d/abc123/export?format=html'
141
+ );
142
+ });
143
+
144
+ it('uses the real document ID from test document', () => {
145
+ const docId = '1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4';
146
+ expect(buildExportUrl(docId, 'html')).toBe(
147
+ `https://docs.google.com/document/d/${docId}/export?format=html`
148
+ );
149
+ });
150
+ });
151
+
152
+ describe('GDOCS_EXPORT_FORMATS', () => {
153
+ it('contains all expected formats', () => {
154
+ expect(GDOCS_EXPORT_FORMATS).toEqual({
155
+ html: 'html',
156
+ txt: 'txt',
157
+ md: 'md',
158
+ pdf: 'pdf',
159
+ docx: 'docx',
160
+ epub: 'epub',
161
+ zip: 'zip',
162
+ });
163
+ });
164
+ });
165
+
166
+ describe('extractBase64Images', () => {
167
+ it('extracts a single PNG data URI image', () => {
168
+ const html = '<img src="data:image/png;base64,iVBORw0KGgo=" alt="test">';
169
+ const { html: updated, images } = extractBase64Images(html);
170
+
171
+ expect(images).toHaveLength(1);
172
+ expect(images[0].filename).toBe('image-01.png');
173
+ expect(images[0].mimeType).toBe('image/png');
174
+ expect(images[0].data).toBeInstanceOf(Buffer);
175
+ expect(updated).toContain('src="images/image-01.png"');
176
+ expect(updated).not.toContain('data:image');
177
+ });
178
+
179
+ it('extracts multiple images with correct numbering and extensions', () => {
180
+ const html =
181
+ '<img src="data:image/png;base64,AAAA" alt="a"><img src="data:image/jpeg;base64,BBBB" alt="b">';
182
+ const { html: updated, images } = extractBase64Images(html);
183
+
184
+ expect(images).toHaveLength(2);
185
+ expect(images[0].filename).toBe('image-01.png');
186
+ expect(images[1].filename).toBe('image-02.jpg');
187
+ expect(images[1].mimeType).toBe('image/jpeg');
188
+ expect(updated).toContain('images/image-01.png');
189
+ expect(updated).toContain('images/image-02.jpg');
190
+ });
191
+
192
+ it('returns empty array when no data URI images', () => {
193
+ const html = '<p>No images here</p>';
194
+ const { html: updated, images } = extractBase64Images(html);
195
+
196
+ expect(images).toHaveLength(0);
197
+ expect(updated).toBe(html);
198
+ });
199
+
200
+ it('preserves non-data-URI images', () => {
201
+ const html =
202
+ '<img src="https://example.com/photo.png" alt="remote"><img src="data:image/gif;base64,R0lG" alt="local">';
203
+ const { html: updated, images } = extractBase64Images(html);
204
+
205
+ expect(images).toHaveLength(1);
206
+ expect(images[0].filename).toBe('image-01.gif');
207
+ expect(updated).toContain('https://example.com/photo.png');
208
+ expect(updated).toContain('images/image-01.gif');
209
+ });
210
+ });
211
+
212
+ describe('image extraction pipeline (issue #53)', () => {
213
+ const TINY_PNG =
214
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
215
+ let tmpDir;
216
+
217
+ beforeEach(() => {
218
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gdocs-issue53-'));
219
+ });
220
+
221
+ afterEach(() => {
222
+ fs.rmSync(tmpDir, { recursive: true, force: true });
223
+ });
224
+
225
+ it('extractBase64Images from HTML produces images with data buffers', () => {
226
+ const html = `<html><body><img src="data:image/png;base64,${TINY_PNG}" alt="photo"><p>Hello</p></body></html>`;
227
+ const { html: localHtml, images } = extractBase64Images(html);
228
+
229
+ expect(images).toHaveLength(1);
230
+ expect(images[0].filename).toBe('image-01.png');
231
+ expect(images[0].data.length).toBeGreaterThan(0);
232
+ // Verify PNG magic bytes in extracted data
233
+ expect(images[0].data[0]).toBe(0x89);
234
+ expect(images[0].data[1]).toBe(0x50); // P
235
+ expect(localHtml).toContain('images/image-01.png');
236
+ expect(localHtml).not.toContain('data:image');
237
+ });
238
+
239
+ it('default markdown mode extracts base64 images to disk (not strip)', () => {
240
+ const markdown = `# Hello\n\n![photo](data:image/png;base64,${TINY_PNG})\n\nEnd.`;
241
+ const result = extractAndSaveImages(markdown, tmpDir);
242
+
243
+ expect(result.extracted).toBe(1);
244
+ expect(result.markdown).not.toContain('data:image');
245
+ expect(result.markdown).toContain('images/image-');
246
+ expect(result.markdown).toContain('.png');
247
+
248
+ const imagesDir = path.join(tmpDir, 'images');
249
+ expect(fs.existsSync(imagesDir)).toBe(true);
250
+ const files = fs.readdirSync(imagesDir);
251
+ expect(files).toHaveLength(1);
252
+ expect(files[0]).toMatch(/^image-.*\.png$/);
253
+
254
+ const imgBuf = fs.readFileSync(path.join(imagesDir, files[0]));
255
+ expect(imgBuf[0]).toBe(0x89); // PNG magic
256
+ });
257
+
258
+ it('keepOriginalLinks strips base64 images', () => {
259
+ const markdown = `![photo](data:image/png;base64,${TINY_PNG})`;
260
+ const result = stripBase64Images(markdown);
261
+
262
+ expect(result.stripped).toBe(1);
263
+ expect(result.markdown).toBe('*[image: photo]*');
264
+ expect(result.markdown).not.toContain('data:image');
265
+ });
266
+
267
+ it('extractBase64ToBuffers produces in-memory buffers for archive', () => {
268
+ const markdown = `![test](data:image/png;base64,${TINY_PNG})`;
269
+ const result = extractBase64ToBuffers(markdown);
270
+
271
+ expect(result.images).toHaveLength(1);
272
+ expect(result.images[0].buffer.length).toBeGreaterThan(0);
273
+ expect(result.images[0].buffer[0]).toBe(0x89); // PNG magic
274
+ expect(result.markdown).toContain('images/');
275
+ expect(result.markdown).not.toContain('data:image');
276
+ });
277
+ });
278
+ });
@@ -0,0 +1,49 @@
1
+ import { convertHtmlToMarkdown } from '../../src/lib.js';
2
+ import { extractBase64Images } from '../../src/gdocs.js';
3
+
4
+ const PNG_B64 =
5
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=';
6
+
7
+ const IMG_REF_PATTERN = /!\[[^\]]*\]\([^)]*images\/[^)]+\)/g;
8
+
9
+ describe('heading image parity', () => {
10
+ it('<img> inside <h1> survives Turndown and stays in sync with extraction', () => {
11
+ const html = `<html><body><h1>Title <img src="data:image/png;base64,${PNG_B64}" alt="x"></h1><p>Body</p></body></html>`;
12
+
13
+ const { html: localHtml, images } = extractBase64Images(html);
14
+ const md = convertHtmlToMarkdown(localHtml);
15
+ const mdImgCount = (md.match(IMG_REF_PATTERN) || []).length;
16
+
17
+ expect(images).toHaveLength(1);
18
+ expect(mdImgCount).toBe(images.length);
19
+ });
20
+
21
+ it('<img> inside all heading levels is preserved', () => {
22
+ for (let level = 1; level <= 6; level++) {
23
+ const tag = `h${level}`;
24
+ const html = `<html><body><${tag}><img src="data:image/png;base64,${PNG_B64}" alt="icon"> Heading</${tag}></body></html>`;
25
+
26
+ const { html: localHtml, images } = extractBase64Images(html);
27
+ const md = convertHtmlToMarkdown(localHtml);
28
+ const refs = (md.match(IMG_REF_PATTERN) || []).length;
29
+
30
+ expect(refs).toBe(images.length);
31
+ }
32
+ });
33
+
34
+ it('image count parity across full pipeline', () => {
35
+ const html = `<html><body>
36
+ <h1><img src="data:image/png;base64,${PNG_B64}" alt="h1"> Chapter</h1>
37
+ <p>Text <img src="data:image/png;base64,${PNG_B64}" alt="p1"> here.</p>
38
+ <h2><img src="data:image/png;base64,${PNG_B64}" alt="h2"> Section</h2>
39
+ <p><img src="data:image/png;base64,${PNG_B64}" alt="p2"></p>
40
+ </body></html>`;
41
+
42
+ const { html: localHtml, images } = extractBase64Images(html);
43
+ const md = convertHtmlToMarkdown(localHtml);
44
+ const mdRefCount = (md.match(IMG_REF_PATTERN) || []).length;
45
+
46
+ expect(images).toHaveLength(4);
47
+ expect(mdRefCount).toBe(images.length);
48
+ });
49
+ });
@@ -0,0 +1,123 @@
1
+ import * as cheerio from 'cheerio';
2
+ import {
3
+ extractHabrFormula,
4
+ extractKatexFormula,
5
+ extractMathJaxFormula,
6
+ isFormulaImage,
7
+ isMathElement,
8
+ extractFormula,
9
+ } from '../../src/latex.js';
10
+
11
+ describe('latex module', () => {
12
+ describe('extractHabrFormula', () => {
13
+ it('extracts LaTeX from Habr formula img source attribute', () => {
14
+ const $ = cheerio.load(
15
+ '<img class="formula" source=" L \\to L^2 " alt="formula">'
16
+ );
17
+ const el = $('img')[0];
18
+ expect(extractHabrFormula($, el)).toBe('L \\to L^2');
19
+ });
20
+
21
+ it('falls back to alt text when source is missing', () => {
22
+ const $ = cheerio.load('<img class="formula" alt=" x + y ">');
23
+ const el = $('img')[0];
24
+ expect(extractHabrFormula($, el)).toBe('x + y');
25
+ });
26
+
27
+ it('returns null when no source or alt', () => {
28
+ const $ = cheerio.load('<img class="formula">');
29
+ const el = $('img')[0];
30
+ expect(extractHabrFormula($, el)).toBeNull();
31
+ });
32
+ });
33
+
34
+ describe('extractKatexFormula', () => {
35
+ it('extracts from annotation element', () => {
36
+ const $ = cheerio.load(
37
+ '<span class="katex"><annotation encoding="application/x-tex">E = mc^2</annotation></span>'
38
+ );
39
+ const el = $('span.katex')[0];
40
+ expect(extractKatexFormula($, el)).toBe('E = mc^2');
41
+ });
42
+
43
+ it('extracts from data-tex attribute', () => {
44
+ const $ = cheerio.load(
45
+ '<span class="math" data-tex="\\alpha + \\beta"></span>'
46
+ );
47
+ const el = $('span.math')[0];
48
+ expect(extractKatexFormula($, el)).toBe('\\alpha + \\beta');
49
+ });
50
+
51
+ it('returns null when no TeX source found', () => {
52
+ const $ = cheerio.load('<span class="katex">rendered</span>');
53
+ const el = $('span.katex')[0];
54
+ expect(extractKatexFormula($, el)).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe('extractMathJaxFormula', () => {
59
+ it('extracts from data-tex attribute', () => {
60
+ const $ = cheerio.load(
61
+ '<mjx-container data-tex="\\sum_{i=1}^{n}"></mjx-container>'
62
+ );
63
+ const el = $('mjx-container')[0];
64
+ expect(extractMathJaxFormula($, el)).toBe('\\sum_{i=1}^{n}');
65
+ });
66
+ });
67
+
68
+ describe('isFormulaImage', () => {
69
+ it('identifies img.formula elements', () => {
70
+ const $ = cheerio.load('<img class="formula" source="x^2">');
71
+ expect(isFormulaImage($, $('img')[0])).toBe(true);
72
+ });
73
+
74
+ it('identifies img with source attribute', () => {
75
+ const $ = cheerio.load('<img source="x^2">');
76
+ expect(isFormulaImage($, $('img')[0])).toBe(true);
77
+ });
78
+
79
+ it('rejects regular images', () => {
80
+ const $ = cheerio.load('<img src="photo.jpg" alt="photo">');
81
+ expect(isFormulaImage($, $('img')[0])).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe('isMathElement', () => {
86
+ it('identifies katex elements', () => {
87
+ const $ = cheerio.load('<span class="katex">x</span>');
88
+ expect(isMathElement($, $('span')[0])).toBe(true);
89
+ });
90
+
91
+ it('identifies math elements', () => {
92
+ const $ = cheerio.load('<span class="math">x</span>');
93
+ expect(isMathElement($, $('span')[0])).toBe(true);
94
+ });
95
+
96
+ it('identifies mjx-container', () => {
97
+ const $ = cheerio.load('<mjx-container>x</mjx-container>');
98
+ expect(isMathElement($, $('mjx-container')[0])).toBe(true);
99
+ });
100
+
101
+ it('rejects regular elements', () => {
102
+ const $ = cheerio.load('<span class="text">x</span>');
103
+ expect(isMathElement($, $('span')[0])).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('extractFormula', () => {
108
+ it('extracts from Habr formula image', () => {
109
+ const $ = cheerio.load('<img class="formula" source="L \\to L^2">');
110
+ expect(extractFormula($, $('img')[0])).toBe('L \\to L^2');
111
+ });
112
+
113
+ it('extracts from KaTeX element', () => {
114
+ const $ = cheerio.load('<span class="katex" data-tex="\\alpha"></span>');
115
+ expect(extractFormula($, $('span')[0])).toBe('\\alpha');
116
+ });
117
+
118
+ it('returns null for regular elements', () => {
119
+ const $ = cheerio.load('<p>text</p>');
120
+ expect(extractFormula($, $('p')[0])).toBeNull();
121
+ });
122
+ });
123
+ });
@@ -0,0 +1,78 @@
1
+ import {
2
+ extractImageReferences,
3
+ getExtensionFromUrl,
4
+ generateLocalFilename,
5
+ } from '../../src/localize-images.js';
6
+
7
+ describe('localize-images module', () => {
8
+ describe('extractImageReferences', () => {
9
+ it('extracts markdown image references', () => {
10
+ const md =
11
+ '![alt text](https://example.com/img.png)\n![photo](https://cdn.example.com/photo.jpg)';
12
+ const images = extractImageReferences(md);
13
+ expect(images).toHaveLength(2);
14
+ expect(images[0].altText).toBe('alt text');
15
+ expect(images[0].url).toBe('https://example.com/img.png');
16
+ expect(images[1].altText).toBe('photo');
17
+ expect(images[1].url).toBe('https://cdn.example.com/photo.jpg');
18
+ });
19
+
20
+ it('ignores local image references', () => {
21
+ const md = '![local](images/figure-1.png)';
22
+ const images = extractImageReferences(md);
23
+ expect(images).toHaveLength(0);
24
+ });
25
+
26
+ it('ignores non-image links', () => {
27
+ const md = '[link text](https://example.com)';
28
+ const images = extractImageReferences(md);
29
+ expect(images).toHaveLength(0);
30
+ });
31
+
32
+ it('returns empty array for no images', () => {
33
+ const images = extractImageReferences('plain text');
34
+ expect(images).toHaveLength(0);
35
+ });
36
+ });
37
+
38
+ describe('getExtensionFromUrl', () => {
39
+ it('detects .png extension', () => {
40
+ expect(getExtensionFromUrl('https://example.com/image.png')).toBe('.png');
41
+ });
42
+
43
+ it('detects .jpg extension', () => {
44
+ expect(getExtensionFromUrl('https://example.com/photo.jpg')).toBe('.jpg');
45
+ });
46
+
47
+ it('detects .gif extension', () => {
48
+ expect(getExtensionFromUrl('https://example.com/anim.gif')).toBe('.gif');
49
+ });
50
+
51
+ it('handles query parameters', () => {
52
+ expect(
53
+ getExtensionFromUrl('https://example.com/image.png?w=800&h=600')
54
+ ).toBe('.png');
55
+ });
56
+
57
+ it('defaults to .png for unknown extension', () => {
58
+ expect(getExtensionFromUrl('https://example.com/image')).toBe('.png');
59
+ });
60
+ });
61
+
62
+ describe('generateLocalFilename', () => {
63
+ it('generates zero-padded filename', () => {
64
+ expect(generateLocalFilename('https://example.com/img.png', 0)).toBe(
65
+ 'image-01.png'
66
+ );
67
+ expect(generateLocalFilename('https://example.com/img.jpg', 9)).toBe(
68
+ 'image-10.jpg'
69
+ );
70
+ });
71
+
72
+ it('preserves file extension from URL', () => {
73
+ expect(generateLocalFilename('https://example.com/photo.gif', 2)).toBe(
74
+ 'image-03.gif'
75
+ );
76
+ });
77
+ });
78
+ });
@@ -0,0 +1,140 @@
1
+ import * as cheerio from 'cheerio';
2
+ import {
3
+ extractMetadata,
4
+ formatMetadataBlock,
5
+ formatFooterBlock,
6
+ } from '../../src/metadata.js';
7
+
8
+ describe('metadata module', () => {
9
+ describe('extractMetadata', () => {
10
+ it('extracts author information', () => {
11
+ const $ = cheerio.load(
12
+ '<a class="tm-user-info__username" href="/user/john">john_doe</a>'
13
+ );
14
+ const meta = extractMetadata($);
15
+ expect(meta.author).toBe('john_doe');
16
+ expect(meta.authorUrl).toBe('/user/john');
17
+ });
18
+
19
+ it('extracts publication date', () => {
20
+ const $ = cheerio.load(
21
+ '<time datetime="2024-01-15T10:00:00Z">Jan 15</time>'
22
+ );
23
+ const meta = extractMetadata($);
24
+ expect(meta.publishDate).toBe('2024-01-15T10:00:00Z');
25
+ expect(meta.publishDateText).toBe('Jan 15');
26
+ });
27
+
28
+ it('extracts hubs', () => {
29
+ const $ = cheerio.load(`
30
+ <a class="tm-publication-hub__link"><span>Math</span></a>
31
+ <a class="tm-publication-hub__link"><span>Science</span></a>
32
+ `);
33
+ const meta = extractMetadata($);
34
+ expect(meta.hubs).toEqual(['Math', 'Science']);
35
+ });
36
+
37
+ it('extracts tags from meta keywords', () => {
38
+ const $ = cheerio.load(
39
+ '<meta name="keywords" content="math, science, theory">'
40
+ );
41
+ const meta = extractMetadata($);
42
+ expect(meta.tags).toEqual(['math', 'science', 'theory']);
43
+ });
44
+
45
+ it('extracts LD+JSON author name', () => {
46
+ const $ = cheerio.load(
47
+ '<script type="application/ld+json">{"author":{"name":"John Doe"},"dateModified":"2024-02-01"}</script>'
48
+ );
49
+ const meta = extractMetadata($);
50
+ expect(meta.authorFullName).toBe('John Doe');
51
+ expect(meta.dateModified).toBe('2024-02-01');
52
+ });
53
+
54
+ it('extracts votes and comments', () => {
55
+ const $ = cheerio.load(`
56
+ <span class="tm-votes-meter__value">+42</span>
57
+ <span class="tm-article-comments-counter-link__value">15</span>
58
+ `);
59
+ const meta = extractMetadata($);
60
+ expect(meta.votes).toBe('+42');
61
+ expect(meta.comments).toBe('15');
62
+ });
63
+
64
+ it('returns empty object for minimal HTML', () => {
65
+ const $ = cheerio.load('<div>plain content</div>');
66
+ const meta = extractMetadata($);
67
+ expect(meta).toBeDefined();
68
+ expect(meta.author).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe('formatMetadataBlock', () => {
73
+ it('formats author line with link', () => {
74
+ const lines = formatMetadataBlock({
75
+ author: 'john',
76
+ authorUrl: '/user/john',
77
+ });
78
+ expect(lines).toContain('**Author:** [john](/user/john)');
79
+ });
80
+
81
+ it('formats author with full name', () => {
82
+ const lines = formatMetadataBlock({
83
+ author: 'john',
84
+ authorFullName: 'John Doe',
85
+ authorUrl: '/user/john',
86
+ });
87
+ expect(lines[0]).toContain('John Doe (john)');
88
+ });
89
+
90
+ it('formats publication date', () => {
91
+ const lines = formatMetadataBlock({
92
+ publishDate: '2024-01-15T10:00:00Z',
93
+ });
94
+ expect(lines.some((l) => l.includes('Published:'))).toBe(true);
95
+ expect(lines.some((l) => l.includes('January 15, 2024'))).toBe(true);
96
+ });
97
+
98
+ it('includes hubs and tags', () => {
99
+ const lines = formatMetadataBlock({
100
+ hubs: ['Math', 'Science'],
101
+ tags: ['theory', 'links'],
102
+ });
103
+ expect(lines.some((l) => l.includes('Math, Science'))).toBe(true);
104
+ expect(lines.some((l) => l.includes('theory, links'))).toBe(true);
105
+ });
106
+
107
+ it('returns empty array for null metadata', () => {
108
+ expect(formatMetadataBlock(null)).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe('formatFooterBlock', () => {
113
+ it('includes separator line', () => {
114
+ const lines = formatFooterBlock({ author: 'test' });
115
+ expect(lines[0]).toBe('---');
116
+ });
117
+
118
+ it('formats tag links', () => {
119
+ const lines = formatFooterBlock({
120
+ tagLinks: [{ name: 'math', url: '/tag/math' }],
121
+ });
122
+ expect(lines.some((l) => l.includes('[math](/tag/math)'))).toBe(true);
123
+ });
124
+
125
+ it('formats article stats', () => {
126
+ const lines = formatFooterBlock({
127
+ votes: '+10',
128
+ views: '5000',
129
+ comments: '3',
130
+ });
131
+ const statsLine = lines.find((l) => l.includes('Votes:'));
132
+ expect(statsLine).toContain('+10');
133
+ expect(statsLine).toContain('5000');
134
+ });
135
+
136
+ it('returns empty array for null metadata', () => {
137
+ expect(formatFooterBlock(null)).toEqual([]);
138
+ });
139
+ });
140
+ });