@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.
- package/CHANGELOG.md +17 -14
- package/README.md +256 -146
- package/bin/web-capture.js +403 -6
- package/package.json +11 -4
- package/src/animation.js +283 -0
- package/src/archive.js +212 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +333 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +226 -1
- package/src/localize-images.js +202 -0
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +265 -0
- package/src/retry.js +48 -0
- package/src/themed-image.js +167 -0
- package/src/verify.js +312 -0
- package/tests/e2e/docker.test.js +51 -12
- package/tests/e2e/process.test.js +34 -10
- package/tests/fixtures/habr-articles.js +45 -0
- package/tests/integration/api-endpoints.test.js +231 -0
- package/tests/integration/habr-article.test.js +337 -0
- package/tests/mock/index.test.js +5 -3
- package/tests/unit/animation.test.js +33 -0
- package/tests/unit/batch.test.js +114 -0
- package/tests/unit/browser.test.js +38 -2
- package/tests/unit/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +203 -0
- package/tests/unit/latex.test.js +123 -0
- package/tests/unit/localize-images.test.js +78 -0
- package/tests/unit/metadata.test.js +140 -0
- package/tests/unit/postprocess.test.js +108 -0
- package/tests/unit/retry.test.js +111 -0
- package/tests/unit/verify.test.js +182 -0
- package/.github/workflows/release.yml +0 -294
- package/ARCHITECTURE.md +0 -982
- package/LICENSE +0 -24
- package/apply.sh +0 -166
- package/docs/market-analysis-2025-05-13.md +0 -64
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -88
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -218
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -128
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -106
- 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: 'article.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('article.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('article.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('article.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.
|
|
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.
|
|
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,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
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
|
|
10
|
+
jest.setTimeout(30000);
|
|
11
|
+
|
|
12
|
+
describe('gdocs', () => {
|
|
13
|
+
describe('isGoogleDocsUrl', () => {
|
|
14
|
+
it('returns true for standard Google Docs edit URL', () => {
|
|
15
|
+
expect(
|
|
16
|
+
isGoogleDocsUrl(
|
|
17
|
+
'https://docs.google.com/document/d/1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4/edit'
|
|
18
|
+
)
|
|
19
|
+
).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns true for URL with query parameters', () => {
|
|
23
|
+
expect(
|
|
24
|
+
isGoogleDocsUrl(
|
|
25
|
+
'https://docs.google.com/document/d/abc123/edit?tab=t.0'
|
|
26
|
+
)
|
|
27
|
+
).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns true for URL without /edit', () => {
|
|
31
|
+
expect(
|
|
32
|
+
isGoogleDocsUrl('https://docs.google.com/document/d/abc-123_XYZ/')
|
|
33
|
+
).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns false for non-Google Docs URL', () => {
|
|
37
|
+
expect(isGoogleDocsUrl('https://example.com')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false for Google Sheets URL', () => {
|
|
41
|
+
expect(
|
|
42
|
+
isGoogleDocsUrl('https://docs.google.com/spreadsheets/d/abc123/edit')
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns false for empty string', () => {
|
|
47
|
+
expect(isGoogleDocsUrl('')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns false for null', () => {
|
|
51
|
+
expect(isGoogleDocsUrl(null)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns false for undefined', () => {
|
|
55
|
+
expect(isGoogleDocsUrl(undefined)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns false for non-string input', () => {
|
|
59
|
+
expect(isGoogleDocsUrl(123)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('extractDocumentId', () => {
|
|
64
|
+
it('extracts document ID from standard URL', () => {
|
|
65
|
+
expect(
|
|
66
|
+
extractDocumentId(
|
|
67
|
+
'https://docs.google.com/document/d/1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4/edit'
|
|
68
|
+
)
|
|
69
|
+
).toBe('1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('extracts document ID from URL with query params', () => {
|
|
73
|
+
expect(
|
|
74
|
+
extractDocumentId(
|
|
75
|
+
'https://docs.google.com/document/d/abc123/edit?tab=t.0'
|
|
76
|
+
)
|
|
77
|
+
).toBe('abc123');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('extracts document ID with hyphens and underscores', () => {
|
|
81
|
+
expect(
|
|
82
|
+
extractDocumentId('https://docs.google.com/document/d/abc-123_XYZ/')
|
|
83
|
+
).toBe('abc-123_XYZ');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns null for non-Google Docs URL', () => {
|
|
87
|
+
expect(extractDocumentId('https://example.com')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns null for empty string', () => {
|
|
91
|
+
expect(extractDocumentId('')).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns null for null input', () => {
|
|
95
|
+
expect(extractDocumentId(null)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('buildExportUrl', () => {
|
|
100
|
+
it('builds HTML export URL', () => {
|
|
101
|
+
expect(buildExportUrl('abc123', 'html')).toBe(
|
|
102
|
+
'https://docs.google.com/document/d/abc123/export?format=html'
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('builds Markdown export URL', () => {
|
|
107
|
+
expect(buildExportUrl('abc123', 'md')).toBe(
|
|
108
|
+
'https://docs.google.com/document/d/abc123/export?format=md'
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('builds PDF export URL', () => {
|
|
113
|
+
expect(buildExportUrl('abc123', 'pdf')).toBe(
|
|
114
|
+
'https://docs.google.com/document/d/abc123/export?format=pdf'
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('builds TXT export URL', () => {
|
|
119
|
+
expect(buildExportUrl('abc123', 'txt')).toBe(
|
|
120
|
+
'https://docs.google.com/document/d/abc123/export?format=txt'
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('defaults to html for unknown format', () => {
|
|
125
|
+
expect(buildExportUrl('abc123', 'invalid')).toBe(
|
|
126
|
+
'https://docs.google.com/document/d/abc123/export?format=html'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('defaults to html when format is omitted', () => {
|
|
131
|
+
expect(buildExportUrl('abc123')).toBe(
|
|
132
|
+
'https://docs.google.com/document/d/abc123/export?format=html'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('uses the real document ID from test document', () => {
|
|
137
|
+
const docId = '1yn5AWdus0Rh5xRr6voNy-g2wNzdC4bM0CV7SQWch9H4';
|
|
138
|
+
expect(buildExportUrl(docId, 'html')).toBe(
|
|
139
|
+
`https://docs.google.com/document/d/${docId}/export?format=html`
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('GDOCS_EXPORT_FORMATS', () => {
|
|
145
|
+
it('contains all expected formats', () => {
|
|
146
|
+
expect(GDOCS_EXPORT_FORMATS).toEqual({
|
|
147
|
+
html: 'html',
|
|
148
|
+
txt: 'txt',
|
|
149
|
+
md: 'md',
|
|
150
|
+
pdf: 'pdf',
|
|
151
|
+
docx: 'docx',
|
|
152
|
+
epub: 'epub',
|
|
153
|
+
zip: 'zip',
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('extractBase64Images', () => {
|
|
159
|
+
it('extracts a single PNG data URI image', () => {
|
|
160
|
+
const html = '<img src="data:image/png;base64,iVBORw0KGgo=" alt="test">';
|
|
161
|
+
const { html: updated, images } = extractBase64Images(html);
|
|
162
|
+
|
|
163
|
+
expect(images).toHaveLength(1);
|
|
164
|
+
expect(images[0].filename).toBe('image-01.png');
|
|
165
|
+
expect(images[0].mimeType).toBe('image/png');
|
|
166
|
+
expect(images[0].data).toBeInstanceOf(Buffer);
|
|
167
|
+
expect(updated).toContain('src="images/image-01.png"');
|
|
168
|
+
expect(updated).not.toContain('data:image');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('extracts multiple images with correct numbering and extensions', () => {
|
|
172
|
+
const html =
|
|
173
|
+
'<img src="data:image/png;base64,AAAA" alt="a"><img src="data:image/jpeg;base64,BBBB" alt="b">';
|
|
174
|
+
const { html: updated, images } = extractBase64Images(html);
|
|
175
|
+
|
|
176
|
+
expect(images).toHaveLength(2);
|
|
177
|
+
expect(images[0].filename).toBe('image-01.png');
|
|
178
|
+
expect(images[1].filename).toBe('image-02.jpg');
|
|
179
|
+
expect(images[1].mimeType).toBe('image/jpeg');
|
|
180
|
+
expect(updated).toContain('images/image-01.png');
|
|
181
|
+
expect(updated).toContain('images/image-02.jpg');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('returns empty array when no data URI images', () => {
|
|
185
|
+
const html = '<p>No images here</p>';
|
|
186
|
+
const { html: updated, images } = extractBase64Images(html);
|
|
187
|
+
|
|
188
|
+
expect(images).toHaveLength(0);
|
|
189
|
+
expect(updated).toBe(html);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('preserves non-data-URI images', () => {
|
|
193
|
+
const html =
|
|
194
|
+
'<img src="https://example.com/photo.png" alt="remote"><img src="data:image/gif;base64,R0lG" alt="local">';
|
|
195
|
+
const { html: updated, images } = extractBase64Images(html);
|
|
196
|
+
|
|
197
|
+
expect(images).toHaveLength(1);
|
|
198
|
+
expect(images[0].filename).toBe('image-01.gif');
|
|
199
|
+
expect(updated).toContain('https://example.com/photo.png');
|
|
200
|
+
expect(updated).toContain('images/image-01.gif');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -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
|
+
'\n';
|
|
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 = '';
|
|
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
|
+
});
|