@link-assistant/web-capture 1.1.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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +294 -0
- package/.jscpd.json +20 -0
- package/.lenv +8 -0
- package/.prettierignore +7 -0
- package/.prettierrc +10 -0
- package/ARCHITECTURE.md +982 -0
- package/CHANGELOG.md +22 -0
- package/Dockerfile +45 -0
- package/LICENSE +24 -0
- package/README.md +268 -0
- package/apply.sh +166 -0
- package/babel.config.cjs +13 -0
- package/bin/web-capture.js +285 -0
- package/docker-compose.yml +9 -0
- package/docs/market-analysis-2025-05-13.md +64 -0
- package/eslint.config.js +112 -0
- package/examples/js/engine_comparison.js +39 -0
- package/examples/js/html_download.js +23 -0
- package/examples/js/image_download.js +36 -0
- package/examples/js/markdown_download.js +23 -0
- package/examples/js/playwright_example.js +23 -0
- package/examples/python/html_download.py +15 -0
- package/examples/python/image_download.py +20 -0
- package/examples/python/markdown_download.py +15 -0
- package/examples/python/playwright_example.py +22 -0
- package/jest.config.mjs +18 -0
- package/package.json +83 -0
- package/scripts/changeset-version.mjs +38 -0
- package/scripts/create-github-release.mjs +93 -0
- package/scripts/create-manual-changeset.mjs +88 -0
- package/scripts/format-github-release.mjs +83 -0
- package/scripts/format-release-notes.mjs +218 -0
- package/scripts/instant-version-bump.mjs +121 -0
- package/scripts/publish-to-npm.mjs +128 -0
- package/scripts/setup-npm.mjs +37 -0
- package/scripts/validate-changeset.mjs +106 -0
- package/scripts/version-and-commit.mjs +237 -0
- package/src/browser.js +207 -0
- package/src/fetch.js +38 -0
- package/src/html.js +64 -0
- package/src/image.js +41 -0
- package/src/index.js +55 -0
- package/src/lib.js +330 -0
- package/src/markdown.js +17 -0
- package/src/stream.js +50 -0
- package/tests/e2e/docker.test.js +148 -0
- package/tests/e2e/process.test.js +117 -0
- package/tests/integration/browser-engines.test.js +128 -0
- package/tests/jest.setup.mjs +7 -0
- package/tests/mock/index.test.js +223 -0
- package/tests/unit/browser.test.js +136 -0
- package/tests/unit/cli.test.js +151 -0
- package/tests/unit/convertRelativeUrls.test.js +34 -0
- package/tests/unit/html2md.test.js +610 -0
package/src/lib.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/* eslint-disable no-useless-escape */
|
|
2
|
+
// Common logic for the web-capture microservice
|
|
3
|
+
import fetch from 'node-fetch';
|
|
4
|
+
import * as cheerio from 'cheerio';
|
|
5
|
+
import TurndownService from 'turndown';
|
|
6
|
+
import iconv from 'iconv-lite';
|
|
7
|
+
import { URL } from 'url';
|
|
8
|
+
import turndownPluginGfm from 'turndown-plugin-gfm';
|
|
9
|
+
|
|
10
|
+
export async function fetchHtml(url) {
|
|
11
|
+
if (!url) {
|
|
12
|
+
throw new Error('Missing URL parameter');
|
|
13
|
+
}
|
|
14
|
+
const response = await fetch(url);
|
|
15
|
+
return response.text();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function convertHtmlToMarkdown(html, baseUrl) {
|
|
19
|
+
// Ensure all URLs are absolute before Markdown conversion
|
|
20
|
+
if (baseUrl) {
|
|
21
|
+
html = convertRelativeUrls(html, baseUrl);
|
|
22
|
+
}
|
|
23
|
+
// Load HTML into Cheerio
|
|
24
|
+
const $ = cheerio.load(html);
|
|
25
|
+
|
|
26
|
+
// Remove <style>, <script>, and <noscript> tags
|
|
27
|
+
$('style, script, noscript').remove();
|
|
28
|
+
|
|
29
|
+
// Remove inline event handlers (attributes starting with 'on')
|
|
30
|
+
$('*').each(function () {
|
|
31
|
+
const attribs = this.attribs || {};
|
|
32
|
+
Object.keys(attribs).forEach((attr) => {
|
|
33
|
+
if (attr.toLowerCase().startsWith('on')) {
|
|
34
|
+
$(this).removeAttr(attr);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
// Remove javascript: links
|
|
39
|
+
$('a[href^="javascript:"]').remove();
|
|
40
|
+
|
|
41
|
+
// Remove inline style attributes
|
|
42
|
+
$('[style]').removeAttr('style');
|
|
43
|
+
|
|
44
|
+
// Remove empty headings (h1-h2) and headings with only whitespace
|
|
45
|
+
$('h1, h2, h3, h4, h5, h6').each(function () {
|
|
46
|
+
if (!$(this).text().trim()) {
|
|
47
|
+
$(this).remove();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Remove <a> tags with no direct text content (including only whitespace or only child elements)
|
|
52
|
+
$('a').each(function () {
|
|
53
|
+
// Get all text nodes directly under this <a>
|
|
54
|
+
const directText = $(this)
|
|
55
|
+
.contents()
|
|
56
|
+
.filter(function () {
|
|
57
|
+
return this.type === 'text';
|
|
58
|
+
})
|
|
59
|
+
.text();
|
|
60
|
+
// If no direct text and all children are empty or whitespace, remove the <a>
|
|
61
|
+
if (!directText.trim()) {
|
|
62
|
+
// Also check if all children are empty or whitespace
|
|
63
|
+
let allChildrenEmpty = true;
|
|
64
|
+
$(this)
|
|
65
|
+
.children()
|
|
66
|
+
.each(function () {
|
|
67
|
+
if (
|
|
68
|
+
$(this).text().trim() ||
|
|
69
|
+
(this.tagName === 'img' && $(this).attr('alt'))
|
|
70
|
+
) {
|
|
71
|
+
allChildrenEmpty = false;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
if (allChildrenEmpty) {
|
|
75
|
+
$(this).remove();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Remove <a> tags with only <img> as child and no alt text
|
|
81
|
+
$('a').each(function () {
|
|
82
|
+
const children = $(this).children();
|
|
83
|
+
if (
|
|
84
|
+
children.length === 1 &&
|
|
85
|
+
children[0].tagName === 'img' &&
|
|
86
|
+
!$(children[0]).attr('alt')
|
|
87
|
+
) {
|
|
88
|
+
$(this).remove();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Remove any leftover empty <a> tags
|
|
93
|
+
$('a').each(function () {
|
|
94
|
+
if (!$(this).text().trim()) {
|
|
95
|
+
$(this).remove();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Remove any leftover empty elements (optional, for robustness)
|
|
100
|
+
$('[data-remove-empty]').each(function () {
|
|
101
|
+
if (!$(this).text().trim()) {
|
|
102
|
+
$(this).remove();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Preprocess ARIA role-based tables to semantic tables with <thead> and <tbody>
|
|
107
|
+
$('[role="table"]').each(function () {
|
|
108
|
+
const $table = $(this);
|
|
109
|
+
const $newTable = $('<table></table>');
|
|
110
|
+
// Add caption if present
|
|
111
|
+
const label = $table.attr('aria-label');
|
|
112
|
+
const descId = $table.attr('aria-describedby');
|
|
113
|
+
if (label) {
|
|
114
|
+
$newTable.append(`<caption>${label}</caption>`);
|
|
115
|
+
} else if (descId && $(`#${descId}`).length) {
|
|
116
|
+
$newTable.append(`<caption>${$(`#${descId}`).text()}</caption>`);
|
|
117
|
+
}
|
|
118
|
+
const rowgroups = $table.find('> [role="rowgroup"]');
|
|
119
|
+
if (rowgroups.length > 0) {
|
|
120
|
+
// First rowgroup is header
|
|
121
|
+
const $thead = $('<thead></thead>');
|
|
122
|
+
const $tbody = $('<tbody></tbody>');
|
|
123
|
+
rowgroups.each(function (i) {
|
|
124
|
+
$(this)
|
|
125
|
+
.find('> [role="row"]')
|
|
126
|
+
.each(function () {
|
|
127
|
+
const $row = $(this);
|
|
128
|
+
const $tr = $('<tr></tr>');
|
|
129
|
+
$row.children('[role="columnheader"]').each(function () {
|
|
130
|
+
$tr.append($('<th></th>').text($(this).text()));
|
|
131
|
+
});
|
|
132
|
+
$row.children('[role="cell"]').each(function () {
|
|
133
|
+
$tr.append($('<td></td>').text($(this).text()));
|
|
134
|
+
});
|
|
135
|
+
if (i === 0 && $tr.children('th').length) {
|
|
136
|
+
$thead.append($tr);
|
|
137
|
+
} else {
|
|
138
|
+
$tbody.append($tr);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
if ($thead.children().length) {
|
|
143
|
+
$newTable.append($thead);
|
|
144
|
+
}
|
|
145
|
+
if ($tbody.children().length) {
|
|
146
|
+
$newTable.append($tbody);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
$table.replaceWith($newTable);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Convert cleaned HTML to Markdown
|
|
153
|
+
const turndown = new TurndownService({
|
|
154
|
+
headingStyle: 'atx',
|
|
155
|
+
codeBlockStyle: 'fenced',
|
|
156
|
+
emDelimiter: '*',
|
|
157
|
+
bulletListMarker: '-',
|
|
158
|
+
strongDelimiter: '**',
|
|
159
|
+
linkStyle: 'inlined',
|
|
160
|
+
linkReferenceStyle: 'full',
|
|
161
|
+
hr: '---',
|
|
162
|
+
style: false,
|
|
163
|
+
});
|
|
164
|
+
turndown.use(turndownPluginGfm.gfm);
|
|
165
|
+
return turndown.turndown($.html());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Convert relative URLs to absolute URLs in HTML content
|
|
169
|
+
export function convertRelativeUrls(html, baseUrl) {
|
|
170
|
+
const base = new URL(baseUrl);
|
|
171
|
+
|
|
172
|
+
// Function to convert a single URL
|
|
173
|
+
const toAbsolute = (url) => {
|
|
174
|
+
if (
|
|
175
|
+
!url ||
|
|
176
|
+
url.startsWith('data:') ||
|
|
177
|
+
url.startsWith('blob:') ||
|
|
178
|
+
url.startsWith('javascript:')
|
|
179
|
+
) {
|
|
180
|
+
return url;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return new URL(url, base).href;
|
|
184
|
+
} catch {
|
|
185
|
+
return url;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Convert URLs in various attributes
|
|
190
|
+
const attributes = [
|
|
191
|
+
{ tag: 'a', attr: 'href' },
|
|
192
|
+
{ tag: 'img', attr: 'src' },
|
|
193
|
+
{ tag: 'script', attr: 'src' },
|
|
194
|
+
{ tag: 'link', attr: 'href' },
|
|
195
|
+
{ tag: 'form', attr: 'action' },
|
|
196
|
+
{ tag: 'video', attr: 'src' },
|
|
197
|
+
{ tag: 'audio', attr: 'src' },
|
|
198
|
+
{ tag: 'source', attr: 'src' },
|
|
199
|
+
{ tag: 'track', attr: 'src' },
|
|
200
|
+
{ tag: 'embed', attr: 'src' },
|
|
201
|
+
{ tag: 'object', attr: 'data' },
|
|
202
|
+
{ tag: 'iframe', attr: 'src' },
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
let result = html;
|
|
206
|
+
|
|
207
|
+
// Process each attribute type
|
|
208
|
+
for (const { tag, attr } of attributes) {
|
|
209
|
+
const regex = new RegExp(
|
|
210
|
+
`<${tag}[^>]*${attr}=["']([^"']+)["'][^>]*>`,
|
|
211
|
+
'gi'
|
|
212
|
+
);
|
|
213
|
+
result = result.replace(regex, (match, url) => {
|
|
214
|
+
const absoluteUrl = toAbsolute(url);
|
|
215
|
+
return match.replace(url, absoluteUrl);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Also handle inline styles with url()
|
|
220
|
+
result = result.replace(/url\(['"]?([^'"()]+)['"]?\)/gi, (match, url) => {
|
|
221
|
+
const absoluteUrl = toAbsolute(url);
|
|
222
|
+
return `url("${absoluteUrl}")`;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Only inject runtime JS hook if there is a <script> tag in the original HTML
|
|
226
|
+
if (/<script[\s>]/i.test(html)) {
|
|
227
|
+
const runtimeHook = `
|
|
228
|
+
<script>(function() {
|
|
229
|
+
const baseUrl = '${base.href}';
|
|
230
|
+
function absolutifyUrl(url) {
|
|
231
|
+
if (!url) return url;
|
|
232
|
+
if (url.startsWith('http') || url.startsWith('data:') || url.startsWith('blob:')) return url;
|
|
233
|
+
try { return new URL(url, baseUrl).href; } catch { return url; }
|
|
234
|
+
}
|
|
235
|
+
function fixElementUrls(el) {
|
|
236
|
+
if (el.tagName === 'A' || el.tagName === 'LINK') {
|
|
237
|
+
el.href = absolutifyUrl(el.getAttribute('href'));
|
|
238
|
+
}
|
|
239
|
+
if (el.tagName === 'IMG' || el.tagName === 'SCRIPT' || el.tagName === 'IFRAME' || el.tagName === 'SOURCE') {
|
|
240
|
+
el.src = absolutifyUrl(el.getAttribute('src'));
|
|
241
|
+
}
|
|
242
|
+
if (el.hasAttribute('style')) {
|
|
243
|
+
// The escapes in the regex are intentional for matching CSS url() syntax
|
|
244
|
+
// eslint-disable-next-line no-useless-escape
|
|
245
|
+
const urlPattern = /url\(["']?([^'")]+)["']?\)/g;
|
|
246
|
+
el.setAttribute(
|
|
247
|
+
'style',
|
|
248
|
+
el.getAttribute('style').replace(urlPattern, (m, capturedUrl) => {
|
|
249
|
+
const absoluteUrl = absolutifyUrl(capturedUrl);
|
|
250
|
+
return "url('" + absoluteUrl + "')";
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function fixAllUrls(root) {
|
|
256
|
+
root.querySelectorAll('*').forEach(fixElementUrls);
|
|
257
|
+
// The escapes in the regex are intentional for matching CSS url() syntax
|
|
258
|
+
// eslint-disable-next-line no-useless-escape
|
|
259
|
+
const urlPattern = /url\(["']?([^'")]+)["']?\)/g;
|
|
260
|
+
root.querySelectorAll('style').forEach(function (styleTag) {
|
|
261
|
+
styleTag.textContent = styleTag.textContent.replace(
|
|
262
|
+
urlPattern,
|
|
263
|
+
(m, capturedUrl) => {
|
|
264
|
+
const absoluteUrl = absolutifyUrl(capturedUrl);
|
|
265
|
+
return "url('" + absoluteUrl + "')";
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
fixAllUrls(document);
|
|
271
|
+
const observer = new MutationObserver(function(mutations) {
|
|
272
|
+
for (const mutation of mutations) {
|
|
273
|
+
mutation.addedNodes.forEach(function(node) {
|
|
274
|
+
if (node.nodeType === 1) {
|
|
275
|
+
fixElementUrls(node);
|
|
276
|
+
fixAllUrls(node);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
282
|
+
})();</script>
|
|
283
|
+
`;
|
|
284
|
+
result = result.replace(/<\/head>/i, `${runtimeHook}</head>`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Convert HTML content to UTF-8 if it's not already
|
|
291
|
+
export function convertToUtf8(html) {
|
|
292
|
+
// First, try to detect the current encoding from meta tag
|
|
293
|
+
const charsetMatch = html.match(/<meta[^>]+charset=["']?([^"'>\s]+)/i);
|
|
294
|
+
const currentCharset = charsetMatch ? charsetMatch[1].toLowerCase() : 'utf-8';
|
|
295
|
+
|
|
296
|
+
// If it's already UTF-8, just ensure the meta tag is present
|
|
297
|
+
if (currentCharset === 'utf-8' || currentCharset === 'utf8') {
|
|
298
|
+
if (!charsetMatch) {
|
|
299
|
+
return html.replace(/<head[^>]*>/i, '$&<meta charset="utf-8">');
|
|
300
|
+
}
|
|
301
|
+
return html;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Convert from detected charset to UTF-8
|
|
305
|
+
try {
|
|
306
|
+
// Convert the HTML string to a buffer using the detected charset
|
|
307
|
+
const buffer = iconv.encode(html, currentCharset);
|
|
308
|
+
// Decode the buffer to UTF-8
|
|
309
|
+
const utf8Html = iconv.decode(buffer, 'utf-8');
|
|
310
|
+
|
|
311
|
+
// Replace the charset meta tag with UTF-8
|
|
312
|
+
return utf8Html.replace(
|
|
313
|
+
/<meta[^>]+charset=["']?[^"'>\s]+["']?/i,
|
|
314
|
+
'<meta charset="utf-8">'
|
|
315
|
+
);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
console.error('Error converting charset:', error);
|
|
318
|
+
// If conversion fails, return original HTML with UTF-8 meta tag
|
|
319
|
+
return html.replace(/<head[^>]*>/i, '$&<meta charset="utf-8">');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Detect encoding and convert to UTF-8 for Puppeteer-rendered HTML
|
|
324
|
+
export function ensureUtf8(html) {
|
|
325
|
+
// If no charset meta tag is present, inject one
|
|
326
|
+
if (!/<meta[^>]+charset/i.test(html)) {
|
|
327
|
+
html = html.replace(/<head[^>]*>/i, '$&<meta charset="utf-8">');
|
|
328
|
+
}
|
|
329
|
+
return html;
|
|
330
|
+
}
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fetchHtml, convertHtmlToMarkdown } from './lib.js';
|
|
2
|
+
|
|
3
|
+
export async function markdownHandler(req, res) {
|
|
4
|
+
const url = req.query.url;
|
|
5
|
+
if (!url) {
|
|
6
|
+
return res.status(400).send('Missing `url` parameter');
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const html = await fetchHtml(url);
|
|
10
|
+
// Pass baseUrl to convertHtmlToMarkdown so all URLs are absolute
|
|
11
|
+
const markdown = convertHtmlToMarkdown(html, url);
|
|
12
|
+
res.type('text/markdown').send(markdown);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error(err);
|
|
15
|
+
res.status(500).send('Error converting to Markdown');
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/stream.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import { pipeline } from 'stream';
|
|
3
|
+
|
|
4
|
+
export async function streamHandler(req, res) {
|
|
5
|
+
const url = req.query.url;
|
|
6
|
+
if (!url) {
|
|
7
|
+
return res.status(400).send('Missing `url` parameter');
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(url);
|
|
11
|
+
// Copy status and headers
|
|
12
|
+
res.status(response.status);
|
|
13
|
+
|
|
14
|
+
// Set default content type if not present
|
|
15
|
+
const contentType = response.headers.get('content-type') || 'text/plain';
|
|
16
|
+
res.setHeader('Content-Type', contentType);
|
|
17
|
+
|
|
18
|
+
// Copy other headers
|
|
19
|
+
for (const [key, value] of response.headers.entries()) {
|
|
20
|
+
if (
|
|
21
|
+
key.toLowerCase() !== 'transfer-encoding' &&
|
|
22
|
+
key.toLowerCase() !== 'content-encoding' &&
|
|
23
|
+
key.toLowerCase() !== 'content-length'
|
|
24
|
+
) {
|
|
25
|
+
res.setHeader(key, value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Stream the response body
|
|
30
|
+
if (response.body) {
|
|
31
|
+
pipeline(response.body, res, (err) => {
|
|
32
|
+
if (err) {
|
|
33
|
+
console.error('Pipeline error in /stream:', err);
|
|
34
|
+
if (!res.headersSent) {
|
|
35
|
+
res.status(500);
|
|
36
|
+
res.end('Error proxying content');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
} else {
|
|
41
|
+
res.end();
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('Stream error:', err);
|
|
45
|
+
if (!res.headersSent) {
|
|
46
|
+
res.status(500);
|
|
47
|
+
res.end('Error proxying content');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
const WAIT_FOR_READY = 10000; // ms
|
|
7
|
+
const PORT = 3000; // Use the same port as in docker-compose.yml
|
|
8
|
+
const baseUrl = `http://localhost:${PORT}`;
|
|
9
|
+
|
|
10
|
+
const timings = {};
|
|
11
|
+
|
|
12
|
+
async function isServiceRunning() {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(`${baseUrl}/html?url=https://example.com`);
|
|
15
|
+
return res.status === 200;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
timings.start = Date.now();
|
|
23
|
+
console.log('Checking if Docker service is already running...');
|
|
24
|
+
const alreadyRunning = await isServiceRunning();
|
|
25
|
+
if (!alreadyRunning) {
|
|
26
|
+
console.log('Service not running, starting Docker container...');
|
|
27
|
+
try {
|
|
28
|
+
const dockerStart = Date.now();
|
|
29
|
+
// Start the Docker container using docker compose (v2 command)
|
|
30
|
+
const { stdout, stderr } = await execAsync('docker compose up -d');
|
|
31
|
+
timings.dockerStartup = Date.now() - dockerStart;
|
|
32
|
+
console.log('Docker compose output:', stdout);
|
|
33
|
+
if (stderr) {
|
|
34
|
+
console.error('Docker compose errors:', stderr);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Wait for the service to be ready
|
|
38
|
+
const readyStart = Date.now();
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
const timeout = setTimeout(() => {
|
|
41
|
+
reject(new Error('Docker service did not start in time'));
|
|
42
|
+
}, WAIT_FOR_READY);
|
|
43
|
+
|
|
44
|
+
const checkReady = async () => {
|
|
45
|
+
try {
|
|
46
|
+
console.log(`Checking if service is ready at ${baseUrl}...`);
|
|
47
|
+
const res = await fetch(`${baseUrl}/html?url=https://example.com`);
|
|
48
|
+
if (res.status === 200) {
|
|
49
|
+
console.log('Service is ready!');
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
resolve();
|
|
52
|
+
} else {
|
|
53
|
+
console.log(`Service not ready yet, status: ${res.status}`);
|
|
54
|
+
setTimeout(checkReady, 500);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.log('Service not ready yet, error:', err.message);
|
|
58
|
+
setTimeout(checkReady, 500);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
checkReady();
|
|
62
|
+
});
|
|
63
|
+
timings.serviceReady = Date.now() - readyStart;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('Failed to start Docker container:', error);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
timings.beforeAll = Date.now() - timings.start;
|
|
69
|
+
console.log('Timing: Docker startup:', `${timings.dockerStartup}ms`);
|
|
70
|
+
console.log('Timing: Service readiness:', `${timings.serviceReady}ms`);
|
|
71
|
+
console.log('Timing: beforeAll total:', `${timings.beforeAll}ms`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log('Docker service is already running. Skipping startup.');
|
|
74
|
+
timings.beforeAll = Date.now() - timings.start;
|
|
75
|
+
console.log(
|
|
76
|
+
'Timing: beforeAll (already running):',
|
|
77
|
+
`${timings.beforeAll}ms`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}, 30000); // Reduced timeout for beforeAll
|
|
81
|
+
|
|
82
|
+
describe('E2E (Docker): Web Capture Microservice', () => {
|
|
83
|
+
it('should return HTML from /html endpoint', async () => {
|
|
84
|
+
const htmlStart = Date.now();
|
|
85
|
+
const url = 'https://example.com';
|
|
86
|
+
const res = await fetch(`${baseUrl}/html?url=${encodeURIComponent(url)}`);
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
expect(text).toMatch(/<html/i);
|
|
90
|
+
timings.html = Date.now() - htmlStart;
|
|
91
|
+
console.log('Timing: /html endpoint:', `${timings.html}ms`);
|
|
92
|
+
}, 20000);
|
|
93
|
+
|
|
94
|
+
it('should return Markdown from /markdown endpoint', async () => {
|
|
95
|
+
const mdStart = Date.now();
|
|
96
|
+
const url = 'https://example.com';
|
|
97
|
+
const res = await fetch(
|
|
98
|
+
`${baseUrl}/markdown?url=${encodeURIComponent(url)}`
|
|
99
|
+
);
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
const text = await res.text();
|
|
102
|
+
expect(text).toMatch(/example/i);
|
|
103
|
+
timings.markdown = Date.now() - mdStart;
|
|
104
|
+
console.log('Timing: /markdown endpoint:', `${timings.markdown}ms`);
|
|
105
|
+
}, 20000);
|
|
106
|
+
|
|
107
|
+
it('should return PNG from /image endpoint', async () => {
|
|
108
|
+
const pngStart = Date.now();
|
|
109
|
+
const url = 'https://example.com';
|
|
110
|
+
const res = await fetch(`${baseUrl}/image?url=${encodeURIComponent(url)}`);
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
expect(res.headers.get('content-type')).toMatch(/^image\/png/);
|
|
113
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
114
|
+
// PNG signature check
|
|
115
|
+
const pngSignature = Buffer.from([
|
|
116
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
117
|
+
]);
|
|
118
|
+
expect(buf.slice(0, 8)).toEqual(pngSignature);
|
|
119
|
+
expect(buf.length).toBeGreaterThan(100); // Should be a non-trivial PNG
|
|
120
|
+
timings.png = Date.now() - pngStart;
|
|
121
|
+
console.log('Timing: /image endpoint:', `${timings.png}ms`);
|
|
122
|
+
}, 60000);
|
|
123
|
+
|
|
124
|
+
it('should stream content from /stream endpoint', async () => {
|
|
125
|
+
const startTime = Date.now();
|
|
126
|
+
const url = 'https://example.com';
|
|
127
|
+
const res = await fetch(`${baseUrl}/stream?url=${encodeURIComponent(url)}`);
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
// Get the response as text
|
|
130
|
+
const text = await res.text();
|
|
131
|
+
expect(text).toMatch(/<html/i);
|
|
132
|
+
expect(text).toMatch(/Example Domain/i);
|
|
133
|
+
const endTime = Date.now();
|
|
134
|
+
console.log(`Timing: /stream endpoint: ${endTime - startTime}ms`);
|
|
135
|
+
}, 20000);
|
|
136
|
+
|
|
137
|
+
it('should return content from /fetch endpoint', async () => {
|
|
138
|
+
const startTime = Date.now();
|
|
139
|
+
const url = 'https://example.com';
|
|
140
|
+
const res = await fetch(`${baseUrl}/fetch?url=${encodeURIComponent(url)}`);
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
const text = await res.text();
|
|
143
|
+
expect(text).toMatch(/<html/i);
|
|
144
|
+
expect(text).toMatch(/Example Domain/i);
|
|
145
|
+
const endTime = Date.now();
|
|
146
|
+
console.log(`Timing: /fetch endpoint: ${endTime - startTime}ms`);
|
|
147
|
+
}, 20000);
|
|
148
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import fetch from 'node-fetch';
|
|
3
|
+
import getPort from 'get-port';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const WAIT_FOR_READY = 5000; // ms
|
|
7
|
+
let serverProcess;
|
|
8
|
+
let baseUrl;
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
const port = await getPort();
|
|
12
|
+
baseUrl = `http://localhost:${port}`;
|
|
13
|
+
|
|
14
|
+
serverProcess = spawn(
|
|
15
|
+
'node',
|
|
16
|
+
[path.resolve('bin/web-capture.js'), '--serve', '--port', port.toString()],
|
|
17
|
+
{
|
|
18
|
+
env: process.env,
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// Wait for the server to be ready (simple delay or poll)
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
25
|
+
const timeout = setTimeout(() => {
|
|
26
|
+
reject(new Error('Server did not start in time'));
|
|
27
|
+
}, WAIT_FOR_READY);
|
|
28
|
+
|
|
29
|
+
let serverStarted = false;
|
|
30
|
+
serverProcess.stdout.on('data', (data) => {
|
|
31
|
+
if (
|
|
32
|
+
data.toString().includes('listening') ||
|
|
33
|
+
data.toString().includes('Server running')
|
|
34
|
+
) {
|
|
35
|
+
serverStarted = true;
|
|
36
|
+
clearTimeout(timeout);
|
|
37
|
+
resolve();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
serverProcess.on('error', (error) => {
|
|
42
|
+
clearTimeout(timeout);
|
|
43
|
+
reject(error);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
serverProcess.on('exit', (code, signal) => {
|
|
47
|
+
if (!serverStarted) {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
reject(
|
|
50
|
+
new Error(`Server process exited with code ${code}, signal ${signal}`)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Fallback: resolve after WAIT_FOR_READY
|
|
56
|
+
setTimeout(resolve, WAIT_FOR_READY);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
if (serverProcess) {
|
|
62
|
+
serverProcess.kill();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('E2E: Web Capture Microservice', () => {
|
|
67
|
+
it('should return HTML from /html endpoint', async () => {
|
|
68
|
+
const url = 'https://example.com';
|
|
69
|
+
const res = await fetch(`${baseUrl}/html?url=${encodeURIComponent(url)}`);
|
|
70
|
+
expect(res.status).toBe(200);
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
expect(text).toMatch(/<html/i);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return Markdown from /markdown endpoint', async () => {
|
|
76
|
+
const url = 'https://example.com';
|
|
77
|
+
const res = await fetch(
|
|
78
|
+
`${baseUrl}/markdown?url=${encodeURIComponent(url)}`
|
|
79
|
+
);
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
const text = await res.text();
|
|
82
|
+
expect(text).toMatch(/example/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return PNG from /image endpoint', async () => {
|
|
86
|
+
const url = 'https://example.com';
|
|
87
|
+
const res = await fetch(`${baseUrl}/image?url=${encodeURIComponent(url)}`);
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
expect(res.headers.get('content-type')).toMatch(/^image\/png/);
|
|
90
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
91
|
+
// PNG signature check
|
|
92
|
+
const pngSignature = Buffer.from([
|
|
93
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
94
|
+
]);
|
|
95
|
+
expect(buf.slice(0, 8)).toEqual(pngSignature);
|
|
96
|
+
expect(buf.length).toBeGreaterThan(100); // Should be a non-trivial PNG
|
|
97
|
+
}, 60000);
|
|
98
|
+
|
|
99
|
+
it('should stream content from /stream endpoint', async () => {
|
|
100
|
+
const url = 'https://example.com';
|
|
101
|
+
const res = await fetch(`${baseUrl}/stream?url=${encodeURIComponent(url)}`);
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
// Get the response as text
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
expect(text).toMatch(/<html/i);
|
|
106
|
+
expect(text).toMatch(/Example Domain/i);
|
|
107
|
+
}, 20000);
|
|
108
|
+
|
|
109
|
+
it('should return content from /fetch endpoint', async () => {
|
|
110
|
+
const url = 'https://example.com';
|
|
111
|
+
const res = await fetch(`${baseUrl}/fetch?url=${encodeURIComponent(url)}`);
|
|
112
|
+
expect(res.status).toBe(200);
|
|
113
|
+
const text = await res.text();
|
|
114
|
+
expect(text).toMatch(/<html/i);
|
|
115
|
+
expect(text).toMatch(/Example Domain/i);
|
|
116
|
+
}, 20000);
|
|
117
|
+
});
|