@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +17 -14
  2. package/README.md +256 -146
  3. package/bin/web-capture.js +403 -6
  4. package/package.json +11 -4
  5. package/src/animation.js +283 -0
  6. package/src/archive.js +212 -0
  7. package/src/batch.js +180 -0
  8. package/src/browser.js +150 -111
  9. package/src/docx.js +182 -0
  10. package/src/figures.js +208 -0
  11. package/src/gdocs.js +333 -0
  12. package/src/image.js +65 -8
  13. package/src/index.js +15 -0
  14. package/src/latex.js +127 -0
  15. package/src/lib.js +226 -1
  16. package/src/localize-images.js +202 -0
  17. package/src/metadata.js +333 -0
  18. package/src/pdf.js +76 -0
  19. package/src/popups.js +171 -0
  20. package/src/postprocess.js +265 -0
  21. package/src/retry.js +48 -0
  22. package/src/themed-image.js +167 -0
  23. package/src/verify.js +312 -0
  24. package/tests/e2e/docker.test.js +51 -12
  25. package/tests/e2e/process.test.js +34 -10
  26. package/tests/fixtures/habr-articles.js +45 -0
  27. package/tests/integration/api-endpoints.test.js +231 -0
  28. package/tests/integration/habr-article.test.js +337 -0
  29. package/tests/mock/index.test.js +5 -3
  30. package/tests/unit/animation.test.js +33 -0
  31. package/tests/unit/batch.test.js +114 -0
  32. package/tests/unit/browser.test.js +38 -2
  33. package/tests/unit/figures.test.js +74 -0
  34. package/tests/unit/gdocs.test.js +203 -0
  35. package/tests/unit/latex.test.js +123 -0
  36. package/tests/unit/localize-images.test.js +78 -0
  37. package/tests/unit/metadata.test.js +140 -0
  38. package/tests/unit/postprocess.test.js +108 -0
  39. package/tests/unit/retry.test.js +111 -0
  40. package/tests/unit/verify.test.js +182 -0
  41. package/.github/workflows/release.yml +0 -294
  42. package/ARCHITECTURE.md +0 -982
  43. package/LICENSE +0 -24
  44. package/apply.sh +0 -166
  45. package/docs/market-analysis-2025-05-13.md +0 -64
  46. package/scripts/changeset-version.mjs +0 -38
  47. package/scripts/create-github-release.mjs +0 -93
  48. package/scripts/create-manual-changeset.mjs +0 -88
  49. package/scripts/format-github-release.mjs +0 -83
  50. package/scripts/format-release-notes.mjs +0 -218
  51. package/scripts/instant-version-bump.mjs +0 -121
  52. package/scripts/publish-to-npm.mjs +0 -128
  53. package/scripts/setup-npm.mjs +0 -37
  54. package/scripts/validate-changeset.mjs +0 -106
  55. 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
+ }
@@ -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}/html?url=https://example.com`);
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}/html?url=https://example.com`);
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 url = 'https://example.com';
86
- const res = await fetch(`${baseUrl}/html?url=${encodeURIComponent(url)}`);
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(url)}`
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 url = 'https://example.com';
110
- const res = await fetch(`${baseUrl}/image?url=${encodeURIComponent(url)}`);
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 url = 'https://example.com';
127
- const res = await fetch(`${baseUrl}/stream?url=${encodeURIComponent(url)}`);
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 url = 'https://example.com';
140
- const res = await fetch(`${baseUrl}/fetch?url=${encodeURIComponent(url)}`);
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 url = 'https://example.com';
69
- const res = await fetch(`${baseUrl}/html?url=${encodeURIComponent(url)}`);
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(url)}`
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 url = 'https://example.com';
87
- const res = await fetch(`${baseUrl}/image?url=${encodeURIComponent(url)}`);
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 url = 'https://example.com';
101
- const res = await fetch(`${baseUrl}/stream?url=${encodeURIComponent(url)}`);
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 url = 'https://example.com';
111
- const res = await fetch(`${baseUrl}/fetch?url=${encodeURIComponent(url)}`);
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
+ }