@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
package/src/verify.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content verification module (R6).
|
|
3
|
+
*
|
|
4
|
+
* Compares captured markdown content against the original web page
|
|
5
|
+
* to verify completeness and accuracy.
|
|
6
|
+
*
|
|
7
|
+
* Checks: title, headings, paragraphs, code blocks, formulas,
|
|
8
|
+
* blockquote formulas, list items, links, and figure images.
|
|
9
|
+
*
|
|
10
|
+
* Based on reference implementation from:
|
|
11
|
+
* https://github.com/link-foundation/meta-theory/blob/main/scripts/verify.mjs
|
|
12
|
+
*
|
|
13
|
+
* @module verify
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize text for comparison.
|
|
18
|
+
* Removes extra whitespace and normalizes unicode characters,
|
|
19
|
+
* LaTeX delimiters, and common symbol substitutions.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} text - Input text
|
|
22
|
+
* @returns {string} Normalized text
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeText(text) {
|
|
25
|
+
return text
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/\s+/g, ' ')
|
|
28
|
+
.replace(/[\u2000-\u200F\u2028-\u202F]/g, ' ')
|
|
29
|
+
.replace(/\u00A0/g, ' ')
|
|
30
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
31
|
+
.replace(/[\u201C\u201D]/g, '"')
|
|
32
|
+
.replace(/[\u00D7]/g, 'x')
|
|
33
|
+
.replace(/\\times/g, 'x')
|
|
34
|
+
.replace(/[\u2192\u21A6]/g, '->')
|
|
35
|
+
.replace(/\\to/g, '->')
|
|
36
|
+
.replace(/[\u2212]/g, '-')
|
|
37
|
+
.replace(/\$\$/g, '')
|
|
38
|
+
.replace(/\$/g, '')
|
|
39
|
+
.replace(/\\displaystyle\s*/g, '')
|
|
40
|
+
.replace(/\\text\{([^}]*)\}/g, '$1')
|
|
41
|
+
.replace(/\\\\%/g, '%')
|
|
42
|
+
.replace(/\\%/g, '%')
|
|
43
|
+
.replace(/\\subseteq/g, '\u2286')
|
|
44
|
+
.replace(/\\mathbb\{n\}_0/gi, '\u2115\u2080')
|
|
45
|
+
.replace(/\\in/g, '\u2208')
|
|
46
|
+
.replace(/\\emptyset/g, '\u2205')
|
|
47
|
+
.replace(/\^2/g, '\u00B2')
|
|
48
|
+
.replace(/\^n/g, '\u207F')
|
|
49
|
+
.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize code for comparison (more lenient than text).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} text - Code text
|
|
56
|
+
* @returns {string} Normalized code
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeCode(text) {
|
|
59
|
+
return text
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/\s+/g, ' ')
|
|
62
|
+
.replace(/[\u2000-\u200F\u2028-\u202F]/g, ' ')
|
|
63
|
+
.replace(/\u00A0/g, ' ')
|
|
64
|
+
.replace(/[\u00D7]/g, 'x')
|
|
65
|
+
.replace(/\\times/g, 'x')
|
|
66
|
+
.replace(/\$\$/g, '')
|
|
67
|
+
.replace(/\$/g, '')
|
|
68
|
+
.toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verify that markdown contains the expected web page content.
|
|
73
|
+
*
|
|
74
|
+
* @param {Object} webContent - Extracted web page content
|
|
75
|
+
* @param {string} webContent.title - Article title
|
|
76
|
+
* @param {Object[]} webContent.headings - Array of {level, text}
|
|
77
|
+
* @param {string[]} webContent.paragraphs - Paragraph texts
|
|
78
|
+
* @param {string[]} webContent.codeBlocks - Code block texts
|
|
79
|
+
* @param {string[]} webContent.formulas - Formula texts
|
|
80
|
+
* @param {string[]} webContent.blockquoteFormulas - Formulas in blockquotes
|
|
81
|
+
* @param {string[]} webContent.listItems - List item texts
|
|
82
|
+
* @param {Object[]} webContent.links - Array of {text, href}
|
|
83
|
+
* @param {number[]} webContent.figures - Figure numbers
|
|
84
|
+
* @param {string} markdownText - The markdown to verify
|
|
85
|
+
* @param {Object} [options] - Verification options
|
|
86
|
+
* @param {boolean} [options.verbose=false] - Detailed output
|
|
87
|
+
* @param {number} [options.expectedFigures] - Expected figure count
|
|
88
|
+
* @param {boolean} [options.hasLocalImages=false] - Whether images are localized
|
|
89
|
+
* @returns {Object} Verification result
|
|
90
|
+
*/
|
|
91
|
+
export function verifyMarkdownContent(webContent, markdownText, options = {}) {
|
|
92
|
+
const { verbose = false, expectedFigures, hasLocalImages = false } = options;
|
|
93
|
+
const normalizedMarkdown = normalizeText(markdownText);
|
|
94
|
+
const missing = {
|
|
95
|
+
title: false,
|
|
96
|
+
headings: [],
|
|
97
|
+
paragraphs: [],
|
|
98
|
+
codeBlocks: [],
|
|
99
|
+
formulas: [],
|
|
100
|
+
blockquoteFormulas: [],
|
|
101
|
+
listItems: [],
|
|
102
|
+
images: 0,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let totalChecks = 0;
|
|
106
|
+
let passedChecks = 0;
|
|
107
|
+
const details = [];
|
|
108
|
+
|
|
109
|
+
// Check title
|
|
110
|
+
if (webContent.title) {
|
|
111
|
+
totalChecks++;
|
|
112
|
+
const normalizedTitle = normalizeText(webContent.title);
|
|
113
|
+
if (normalizedMarkdown.includes(normalizedTitle)) {
|
|
114
|
+
passedChecks++;
|
|
115
|
+
if (verbose) {
|
|
116
|
+
details.push({ type: 'title', status: 'pass' });
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
missing.title = true;
|
|
120
|
+
if (verbose) {
|
|
121
|
+
details.push({
|
|
122
|
+
type: 'title',
|
|
123
|
+
status: 'fail',
|
|
124
|
+
text: webContent.title,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check headings
|
|
131
|
+
for (const heading of webContent.headings || []) {
|
|
132
|
+
totalChecks++;
|
|
133
|
+
const normalized = normalizeText(heading.text);
|
|
134
|
+
if (normalizedMarkdown.includes(normalized)) {
|
|
135
|
+
passedChecks++;
|
|
136
|
+
} else {
|
|
137
|
+
missing.headings.push(heading.text);
|
|
138
|
+
if (verbose) {
|
|
139
|
+
details.push({
|
|
140
|
+
type: 'heading',
|
|
141
|
+
status: 'fail',
|
|
142
|
+
text: heading.text,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check paragraphs (sample first 5 and last 5)
|
|
149
|
+
const paragraphs = webContent.paragraphs || [];
|
|
150
|
+
const paragraphsToCheck = [
|
|
151
|
+
...paragraphs.slice(0, 5),
|
|
152
|
+
...paragraphs.slice(-5),
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
for (const paragraph of paragraphsToCheck) {
|
|
156
|
+
totalChecks++;
|
|
157
|
+
const normalized = normalizeText(paragraph);
|
|
158
|
+
const words = normalized.split(' ').filter((w) => w.length > 2);
|
|
159
|
+
const matchingWords = words.filter((word) =>
|
|
160
|
+
normalizedMarkdown.includes(word)
|
|
161
|
+
);
|
|
162
|
+
const matchRate =
|
|
163
|
+
words.length > 0 ? matchingWords.length / words.length : 0;
|
|
164
|
+
|
|
165
|
+
const substringMatch =
|
|
166
|
+
normalized.length > 20 &&
|
|
167
|
+
normalizedMarkdown.includes(
|
|
168
|
+
normalized.substring(0, Math.min(50, normalized.length))
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (matchRate >= 0.6 || substringMatch) {
|
|
172
|
+
passedChecks++;
|
|
173
|
+
} else {
|
|
174
|
+
missing.paragraphs.push(`${paragraph.substring(0, 100)}...`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check code blocks (fuzzy matching)
|
|
179
|
+
const normalizedMarkdownForCode = normalizeCode(markdownText);
|
|
180
|
+
for (const code of webContent.codeBlocks || []) {
|
|
181
|
+
totalChecks++;
|
|
182
|
+
const normalizedCodeFull = normalizeCode(code);
|
|
183
|
+
|
|
184
|
+
const lines = code
|
|
185
|
+
.split('\n')
|
|
186
|
+
.map((l) => l.trim())
|
|
187
|
+
.filter((l) => l.length > 3 && !/^[{}[\](),;]+$/.test(l));
|
|
188
|
+
|
|
189
|
+
const matchingLines = lines.filter((line) => {
|
|
190
|
+
const normalizedLine = normalizeCode(line);
|
|
191
|
+
return normalizedMarkdownForCode.includes(normalizedLine);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const matchRate =
|
|
195
|
+
lines.length > 0 ? matchingLines.length / lines.length : 1;
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
matchRate >= 0.6 ||
|
|
199
|
+
normalizedMarkdownForCode.includes(normalizedCodeFull)
|
|
200
|
+
) {
|
|
201
|
+
passedChecks++;
|
|
202
|
+
} else {
|
|
203
|
+
missing.codeBlocks.push(`${code.substring(0, 100)}...`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check list items (sample first 10)
|
|
208
|
+
const listItemsToCheck = (webContent.listItems || []).slice(0, 10);
|
|
209
|
+
for (const item of listItemsToCheck) {
|
|
210
|
+
totalChecks++;
|
|
211
|
+
const normalized = normalizeText(item);
|
|
212
|
+
const words = normalized.split(' ').filter((w) => w.length > 2);
|
|
213
|
+
const matchingWords = words.filter((word) =>
|
|
214
|
+
normalizedMarkdown.includes(word)
|
|
215
|
+
);
|
|
216
|
+
const matchRate =
|
|
217
|
+
words.length > 0 ? matchingWords.length / words.length : 0;
|
|
218
|
+
|
|
219
|
+
const substringMatch =
|
|
220
|
+
normalized.length > 15 &&
|
|
221
|
+
normalizedMarkdown.includes(
|
|
222
|
+
normalized.substring(0, Math.min(40, normalized.length))
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (matchRate >= 0.6 || substringMatch) {
|
|
226
|
+
passedChecks++;
|
|
227
|
+
} else {
|
|
228
|
+
missing.listItems.push(`${item.substring(0, 100)}...`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Check blockquote formulas
|
|
233
|
+
if (
|
|
234
|
+
webContent.blockquoteFormulas &&
|
|
235
|
+
webContent.blockquoteFormulas.length > 0
|
|
236
|
+
) {
|
|
237
|
+
for (const formula of webContent.blockquoteFormulas) {
|
|
238
|
+
totalChecks++;
|
|
239
|
+
const normalizedFormula = formula.replace(/\s+/g, ' ').trim();
|
|
240
|
+
|
|
241
|
+
const keyParts = normalizedFormula
|
|
242
|
+
.replace(/\\mathbf\{([^}]*)\}/g, '$1')
|
|
243
|
+
.replace(/\\textbf\{([^}]*)\}/g, '$1')
|
|
244
|
+
.replace(/[{}\\]/g, '')
|
|
245
|
+
.split(/\s+/)
|
|
246
|
+
.filter((part) => part.length > 1);
|
|
247
|
+
|
|
248
|
+
const blockquoteLines = markdownText.match(/^>.*$/gm) || [];
|
|
249
|
+
let foundInBlockquote = false;
|
|
250
|
+
|
|
251
|
+
for (const line of blockquoteLines) {
|
|
252
|
+
if (line.includes('$')) {
|
|
253
|
+
const matchingParts = keyParts.filter((part) =>
|
|
254
|
+
line.toLowerCase().includes(part.toLowerCase())
|
|
255
|
+
);
|
|
256
|
+
if (
|
|
257
|
+
keyParts.length > 0 &&
|
|
258
|
+
matchingParts.length >= Math.min(2, keyParts.length)
|
|
259
|
+
) {
|
|
260
|
+
foundInBlockquote = true;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
line.includes(normalizedFormula) ||
|
|
265
|
+
line.includes(formula) ||
|
|
266
|
+
(formula.length < 20 && line.includes(formula.replace(/\s/g, '')))
|
|
267
|
+
) {
|
|
268
|
+
foundInBlockquote = true;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (foundInBlockquote) {
|
|
275
|
+
passedChecks++;
|
|
276
|
+
} else {
|
|
277
|
+
missing.blockquoteFormulas.push(formula.substring(0, 100));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Check for figure images
|
|
283
|
+
if (hasLocalImages && expectedFigures) {
|
|
284
|
+
const figurePattern =
|
|
285
|
+
/!\[(?:\*\*)?(?:Figure|Рис\.?|Рисунок)\s*\d+[\s\S]*?\]\(images\/figure-\d+\.(png|jpg)\)/gi;
|
|
286
|
+
const figureMatches = markdownText.match(figurePattern) || [];
|
|
287
|
+
|
|
288
|
+
totalChecks++;
|
|
289
|
+
if (figureMatches.length >= expectedFigures) {
|
|
290
|
+
passedChecks++;
|
|
291
|
+
} else {
|
|
292
|
+
missing.images = expectedFigures - figureMatches.length;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Calculate results
|
|
297
|
+
const passRate = totalChecks > 0 ? passedChecks / totalChecks : 0;
|
|
298
|
+
const hasMissingContent =
|
|
299
|
+
missing.title ||
|
|
300
|
+
missing.images > 0 ||
|
|
301
|
+
Object.values(missing).some((arr) => Array.isArray(arr) && arr.length > 0);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
totalChecks,
|
|
305
|
+
passedChecks,
|
|
306
|
+
passRate,
|
|
307
|
+
hasMissingContent,
|
|
308
|
+
missing,
|
|
309
|
+
success: !hasMissingContent || passRate >= 0.85,
|
|
310
|
+
details: verbose ? details : undefined,
|
|
311
|
+
};
|
|
312
|
+
}
|
package/tests/e2e/docker.test.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import getPort from 'get-port';
|
|
2
4
|
import { exec } from 'child_process';
|
|
3
5
|
import { promisify } from 'util';
|
|
4
6
|
|
|
@@ -8,10 +10,18 @@ const PORT = 3000; // Use the same port as in docker-compose.yml
|
|
|
8
10
|
const baseUrl = `http://localhost:${PORT}`;
|
|
9
11
|
|
|
10
12
|
const timings = {};
|
|
13
|
+
let mockServer;
|
|
14
|
+
let mockUrl;
|
|
15
|
+
|
|
16
|
+
const MOCK_HTML = `<!DOCTYPE html>
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<head><meta charset="utf-8"><title>Example Domain</title></head>
|
|
19
|
+
<body><h1>Example Domain</h1><p>This domain is for use in illustrative examples.</p></body>
|
|
20
|
+
</html>`;
|
|
11
21
|
|
|
12
22
|
async function isServiceRunning() {
|
|
13
23
|
try {
|
|
14
|
-
const res = await fetch(`${baseUrl}/
|
|
24
|
+
const res = await fetch(`${baseUrl}/health`);
|
|
15
25
|
return res.status === 200;
|
|
16
26
|
} catch {
|
|
17
27
|
return false;
|
|
@@ -20,6 +30,20 @@ async function isServiceRunning() {
|
|
|
20
30
|
|
|
21
31
|
beforeAll(async () => {
|
|
22
32
|
timings.start = Date.now();
|
|
33
|
+
|
|
34
|
+
// Start a local mock server to avoid depending on external network
|
|
35
|
+
const mockPort = await getPort();
|
|
36
|
+
mockServer = http.createServer((req, res) => {
|
|
37
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
38
|
+
res.end(MOCK_HTML);
|
|
39
|
+
});
|
|
40
|
+
await new Promise((resolve) =>
|
|
41
|
+
mockServer.listen(mockPort, '0.0.0.0', resolve)
|
|
42
|
+
);
|
|
43
|
+
// Use host.docker.internal or the host IP to make it reachable from Docker
|
|
44
|
+
// On Linux (CI), Docker containers can reach the host via 172.17.0.1
|
|
45
|
+
mockUrl = `http://172.17.0.1:${mockPort}`;
|
|
46
|
+
|
|
23
47
|
console.log('Checking if Docker service is already running...');
|
|
24
48
|
const alreadyRunning = await isServiceRunning();
|
|
25
49
|
if (!alreadyRunning) {
|
|
@@ -44,7 +68,7 @@ beforeAll(async () => {
|
|
|
44
68
|
const checkReady = async () => {
|
|
45
69
|
try {
|
|
46
70
|
console.log(`Checking if service is ready at ${baseUrl}...`);
|
|
47
|
-
const res = await fetch(`${baseUrl}/
|
|
71
|
+
const res = await fetch(`${baseUrl}/health`);
|
|
48
72
|
if (res.status === 200) {
|
|
49
73
|
console.log('Service is ready!');
|
|
50
74
|
clearTimeout(timeout);
|
|
@@ -79,11 +103,24 @@ beforeAll(async () => {
|
|
|
79
103
|
}
|
|
80
104
|
}, 30000); // Reduced timeout for beforeAll
|
|
81
105
|
|
|
106
|
+
afterAll(async () => {
|
|
107
|
+
if (mockServer) {
|
|
108
|
+
mockServer.close();
|
|
109
|
+
}
|
|
110
|
+
// Stop Docker containers
|
|
111
|
+
try {
|
|
112
|
+
await execAsync('docker compose down');
|
|
113
|
+
} catch {
|
|
114
|
+
// Ignore errors during cleanup
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
82
118
|
describe('E2E (Docker): Web Capture Microservice', () => {
|
|
83
119
|
it('should return HTML from /html endpoint', async () => {
|
|
84
120
|
const htmlStart = Date.now();
|
|
85
|
-
const
|
|
86
|
-
|
|
121
|
+
const res = await fetch(
|
|
122
|
+
`${baseUrl}/html?url=${encodeURIComponent(mockUrl)}`
|
|
123
|
+
);
|
|
87
124
|
expect(res.status).toBe(200);
|
|
88
125
|
const text = await res.text();
|
|
89
126
|
expect(text).toMatch(/<html/i);
|
|
@@ -93,9 +130,8 @@ describe('E2E (Docker): Web Capture Microservice', () => {
|
|
|
93
130
|
|
|
94
131
|
it('should return Markdown from /markdown endpoint', async () => {
|
|
95
132
|
const mdStart = Date.now();
|
|
96
|
-
const url = 'https://example.com';
|
|
97
133
|
const res = await fetch(
|
|
98
|
-
`${baseUrl}/markdown?url=${encodeURIComponent(
|
|
134
|
+
`${baseUrl}/markdown?url=${encodeURIComponent(mockUrl)}`
|
|
99
135
|
);
|
|
100
136
|
expect(res.status).toBe(200);
|
|
101
137
|
const text = await res.text();
|
|
@@ -106,8 +142,9 @@ describe('E2E (Docker): Web Capture Microservice', () => {
|
|
|
106
142
|
|
|
107
143
|
it('should return PNG from /image endpoint', async () => {
|
|
108
144
|
const pngStart = Date.now();
|
|
109
|
-
const
|
|
110
|
-
|
|
145
|
+
const res = await fetch(
|
|
146
|
+
`${baseUrl}/image?url=${encodeURIComponent(mockUrl)}`
|
|
147
|
+
);
|
|
111
148
|
expect(res.status).toBe(200);
|
|
112
149
|
expect(res.headers.get('content-type')).toMatch(/^image\/png/);
|
|
113
150
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
@@ -123,8 +160,9 @@ describe('E2E (Docker): Web Capture Microservice', () => {
|
|
|
123
160
|
|
|
124
161
|
it('should stream content from /stream endpoint', async () => {
|
|
125
162
|
const startTime = Date.now();
|
|
126
|
-
const
|
|
127
|
-
|
|
163
|
+
const res = await fetch(
|
|
164
|
+
`${baseUrl}/stream?url=${encodeURIComponent(mockUrl)}`
|
|
165
|
+
);
|
|
128
166
|
expect(res.status).toBe(200);
|
|
129
167
|
// Get the response as text
|
|
130
168
|
const text = await res.text();
|
|
@@ -136,8 +174,9 @@ describe('E2E (Docker): Web Capture Microservice', () => {
|
|
|
136
174
|
|
|
137
175
|
it('should return content from /fetch endpoint', async () => {
|
|
138
176
|
const startTime = Date.now();
|
|
139
|
-
const
|
|
140
|
-
|
|
177
|
+
const res = await fetch(
|
|
178
|
+
`${baseUrl}/fetch?url=${encodeURIComponent(mockUrl)}`
|
|
179
|
+
);
|
|
141
180
|
expect(res.status).toBe(200);
|
|
142
181
|
const text = await res.text();
|
|
143
182
|
expect(text).toMatch(/<html/i);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
+
import http from 'http';
|
|
2
3
|
import fetch from 'node-fetch';
|
|
3
4
|
import getPort from 'get-port';
|
|
4
5
|
import path from 'path';
|
|
@@ -6,8 +7,25 @@ import path from 'path';
|
|
|
6
7
|
const WAIT_FOR_READY = 5000; // ms
|
|
7
8
|
let serverProcess;
|
|
8
9
|
let baseUrl;
|
|
10
|
+
let mockServer;
|
|
11
|
+
let mockUrl;
|
|
12
|
+
|
|
13
|
+
const MOCK_HTML = `<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head><meta charset="utf-8"><title>Example Domain</title></head>
|
|
16
|
+
<body><h1>Example Domain</h1><p>This domain is for use in illustrative examples.</p></body>
|
|
17
|
+
</html>`;
|
|
9
18
|
|
|
10
19
|
beforeAll(async () => {
|
|
20
|
+
// Start a local mock server to avoid depending on external network
|
|
21
|
+
const mockPort = await getPort();
|
|
22
|
+
mockServer = http.createServer((req, res) => {
|
|
23
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
24
|
+
res.end(MOCK_HTML);
|
|
25
|
+
});
|
|
26
|
+
await new Promise((resolve) => mockServer.listen(mockPort, resolve));
|
|
27
|
+
mockUrl = `http://localhost:${mockPort}`;
|
|
28
|
+
|
|
11
29
|
const port = await getPort();
|
|
12
30
|
baseUrl = `http://localhost:${port}`;
|
|
13
31
|
|
|
@@ -61,21 +79,24 @@ afterAll(() => {
|
|
|
61
79
|
if (serverProcess) {
|
|
62
80
|
serverProcess.kill();
|
|
63
81
|
}
|
|
82
|
+
if (mockServer) {
|
|
83
|
+
mockServer.close();
|
|
84
|
+
}
|
|
64
85
|
});
|
|
65
86
|
|
|
66
87
|
describe('E2E: Web Capture Microservice', () => {
|
|
67
88
|
it('should return HTML from /html endpoint', async () => {
|
|
68
|
-
const
|
|
69
|
-
|
|
89
|
+
const res = await fetch(
|
|
90
|
+
`${baseUrl}/html?url=${encodeURIComponent(mockUrl)}`
|
|
91
|
+
);
|
|
70
92
|
expect(res.status).toBe(200);
|
|
71
93
|
const text = await res.text();
|
|
72
94
|
expect(text).toMatch(/<html/i);
|
|
73
95
|
});
|
|
74
96
|
|
|
75
97
|
it('should return Markdown from /markdown endpoint', async () => {
|
|
76
|
-
const url = 'https://example.com';
|
|
77
98
|
const res = await fetch(
|
|
78
|
-
`${baseUrl}/markdown?url=${encodeURIComponent(
|
|
99
|
+
`${baseUrl}/markdown?url=${encodeURIComponent(mockUrl)}`
|
|
79
100
|
);
|
|
80
101
|
expect(res.status).toBe(200);
|
|
81
102
|
const text = await res.text();
|
|
@@ -83,8 +104,9 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
83
104
|
});
|
|
84
105
|
|
|
85
106
|
it('should return PNG from /image endpoint', async () => {
|
|
86
|
-
const
|
|
87
|
-
|
|
107
|
+
const res = await fetch(
|
|
108
|
+
`${baseUrl}/image?url=${encodeURIComponent(mockUrl)}`
|
|
109
|
+
);
|
|
88
110
|
expect(res.status).toBe(200);
|
|
89
111
|
expect(res.headers.get('content-type')).toMatch(/^image\/png/);
|
|
90
112
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
@@ -97,8 +119,9 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
97
119
|
}, 60000);
|
|
98
120
|
|
|
99
121
|
it('should stream content from /stream endpoint', async () => {
|
|
100
|
-
const
|
|
101
|
-
|
|
122
|
+
const res = await fetch(
|
|
123
|
+
`${baseUrl}/stream?url=${encodeURIComponent(mockUrl)}`
|
|
124
|
+
);
|
|
102
125
|
expect(res.status).toBe(200);
|
|
103
126
|
// Get the response as text
|
|
104
127
|
const text = await res.text();
|
|
@@ -107,8 +130,9 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
107
130
|
}, 20000);
|
|
108
131
|
|
|
109
132
|
it('should return content from /fetch endpoint', async () => {
|
|
110
|
-
const
|
|
111
|
-
|
|
133
|
+
const res = await fetch(
|
|
134
|
+
`${baseUrl}/fetch?url=${encodeURIComponent(mockUrl)}`
|
|
135
|
+
);
|
|
112
136
|
expect(res.status).toBe(200);
|
|
113
137
|
const text = await res.text();
|
|
114
138
|
expect(text).toMatch(/<html/i);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for Habr test articles.
|
|
3
|
+
*
|
|
4
|
+
* These are the same three articles archived in the link-foundation/meta-theory repo.
|
|
5
|
+
* Each entry provides article metadata for use in integration tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const HABR_ARTICLES = {
|
|
9
|
+
'0.0.0': {
|
|
10
|
+
version: '0.0.0',
|
|
11
|
+
title: 'Math introduction to Deep Theory',
|
|
12
|
+
url: 'https://habr.com/en/companies/deepfoundation/articles/658705/',
|
|
13
|
+
language: 'en',
|
|
14
|
+
expectedFigures: 12,
|
|
15
|
+
},
|
|
16
|
+
'0.0.1': {
|
|
17
|
+
version: '0.0.1',
|
|
18
|
+
title: 'Глубокая Теория Связей 0.0.1',
|
|
19
|
+
titleEnglish: 'Deep Theory of Links 0.0.1',
|
|
20
|
+
url: 'https://habr.com/ru/companies/deepfoundation/articles/804617/',
|
|
21
|
+
language: 'ru',
|
|
22
|
+
expectedFigures: 10,
|
|
23
|
+
},
|
|
24
|
+
'0.0.2': {
|
|
25
|
+
version: '0.0.2',
|
|
26
|
+
title: 'The Links Theory 0.0.2',
|
|
27
|
+
url: 'https://habr.com/en/articles/895896/',
|
|
28
|
+
language: 'en',
|
|
29
|
+
expectedFigures: 13,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function getArticle(version) {
|
|
34
|
+
const article = HABR_ARTICLES[version];
|
|
35
|
+
if (!article) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Unknown article version: ${version}. Available: ${Object.keys(HABR_ARTICLES).join(', ')}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return article;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getAllArticles() {
|
|
44
|
+
return Object.values(HABR_ARTICLES);
|
|
45
|
+
}
|