@link-assistant/web-capture 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +19 -5
- package/package.json +2 -1
- package/src/kreuzberg.js +128 -0
- package/src/lib.js +16 -9
- package/src/markdown.js +49 -1
- package/tests/integration/api-endpoints.test.js +63 -0
- package/tests/integration/wikipedia-download.test.js +142 -0
- package/tests/unit/kreuzberg.test.js +209 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.10.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 31b93a8: Add a live integration test that downloads the Wikipedia page
|
|
8
|
+
(https://en.wikipedia.org/wiki/Wikipedia) as both Markdown and an image (PNG
|
|
9
|
+
screenshot) using every supported browser engine — Puppeteer and Playwright
|
|
10
|
+
(issue #8). The suite is gated behind `WIKIPEDIA_INTEGRATION=true` so default
|
|
11
|
+
offline runs stay deterministic, and is wired into CI as a dedicated live step.
|
|
12
|
+
|
|
13
|
+
## 1.10.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- b892585: Add kreuzberg html-to-markdown as high-performance alternative converter with structured results
|
|
18
|
+
|
|
3
19
|
## 1.9.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -141,11 +141,13 @@ GET /markdown?url=<URL>
|
|
|
141
141
|
|
|
142
142
|
Converts the HTML content of the specified URL to Markdown format. By default, original remote image URLs are preserved and base64 data URIs are stripped (clean single-file output). Use `keepOriginalLinks=false` to strip all images, or `embedImages=true` to keep base64 images inline.
|
|
143
143
|
|
|
144
|
-
| Parameter | Required | Description
|
|
145
|
-
| ------------------- | -------- |
|
|
146
|
-
| `url` | Yes | URL to fetch
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
144
|
+
| Parameter | Required | Description | Default |
|
|
145
|
+
| ------------------- | -------- | ------------------------------------------------------------------------- | -------- |
|
|
146
|
+
| `url` | Yes | URL to fetch | - |
|
|
147
|
+
| `converter` | No | Converter: `turndown` or `kreuzberg` | turndown |
|
|
148
|
+
| `format` | No | Response format: `text` or `json` (`json` requires `converter=kreuzberg`) | text |
|
|
149
|
+
| `embedImages` | No | Keep base64 images inline (`true`/`false`) | `false` |
|
|
150
|
+
| `keepOriginalLinks` | No | Keep original remote URLs, strip base64 | `true` |
|
|
149
151
|
|
|
150
152
|
### Image Endpoint
|
|
151
153
|
|
|
@@ -454,6 +456,18 @@ npm run test:e2e # Run end-to-end tests
|
|
|
454
456
|
npm run test:all # Run all tests including build
|
|
455
457
|
```
|
|
456
458
|
|
|
459
|
+
Some integration suites hit live servers and are skipped by default. Enable them
|
|
460
|
+
with environment variables:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
# Download the Wikipedia page (markdown + image) in every supported engine
|
|
464
|
+
WIKIPEDIA_INTEGRATION=true npm test -- --testPathPattern="wikipedia-download"
|
|
465
|
+
|
|
466
|
+
# Habr articles and public Google Docs live suites
|
|
467
|
+
HABR_INTEGRATION=true npm test -- --testPathPattern="habr-article"
|
|
468
|
+
GDOCS_INTEGRATION=true npm test -- --testPathPattern="gdocs-public-doc"
|
|
469
|
+
```
|
|
470
|
+
|
|
457
471
|
## Built With
|
|
458
472
|
|
|
459
473
|
- Express.js for the web server
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/web-capture",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"changeset:status": "changeset status --since=origin/main"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@kreuzberg/html-to-markdown-node": "^3.5.5",
|
|
35
36
|
"archiver": "^7.0.1",
|
|
36
37
|
"browser-commander": "^0.8.0",
|
|
37
38
|
"cheerio": "^1.0.0",
|
package/src/kreuzberg.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kreuzberg html-to-markdown integration module.
|
|
3
|
+
*
|
|
4
|
+
* Provides high-performance HTML to Markdown conversion using the
|
|
5
|
+
* @kreuzberg/html-to-markdown-node library (Rust-powered, 150-280 MB/s).
|
|
6
|
+
*
|
|
7
|
+
* This converter is available as an alternative to the default Turndown-based
|
|
8
|
+
* converter, selectable via the `converter=kreuzberg` query parameter.
|
|
9
|
+
*
|
|
10
|
+
* @module kreuzberg
|
|
11
|
+
* @see https://github.com/kreuzberg-dev/html-to-markdown
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { convertRelativeUrls } from './lib.js';
|
|
15
|
+
|
|
16
|
+
let _initPromise = null;
|
|
17
|
+
let _convert = null;
|
|
18
|
+
|
|
19
|
+
async function ensureLoaded() {
|
|
20
|
+
if (_convert) {
|
|
21
|
+
return _convert;
|
|
22
|
+
}
|
|
23
|
+
if (_initPromise) {
|
|
24
|
+
return _initPromise;
|
|
25
|
+
}
|
|
26
|
+
_initPromise = import('@kreuzberg/html-to-markdown-node')
|
|
27
|
+
.then((mod) => {
|
|
28
|
+
_convert = mod.convert || mod.default?.convert || null;
|
|
29
|
+
return _convert;
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
_convert = null;
|
|
33
|
+
return null;
|
|
34
|
+
});
|
|
35
|
+
return await _initPromise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toSnakeCase(key) {
|
|
39
|
+
return key.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeStructuredKeys(value) {
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return value.map(normalizeStructuredKeys);
|
|
45
|
+
}
|
|
46
|
+
if (!value || typeof value !== 'object') {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Object.fromEntries(
|
|
51
|
+
Object.entries(value).map(([key, entry]) => [
|
|
52
|
+
toSnakeCase(key),
|
|
53
|
+
normalizeStructuredKeys(entry),
|
|
54
|
+
])
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if the kreuzberg converter is available.
|
|
60
|
+
*
|
|
61
|
+
* @returns {Promise<boolean>} Whether the converter is available
|
|
62
|
+
*/
|
|
63
|
+
export async function isKreuzbergAvailable() {
|
|
64
|
+
const fn = await ensureLoaded();
|
|
65
|
+
return fn !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert HTML to Markdown using the kreuzberg html-to-markdown library.
|
|
70
|
+
*
|
|
71
|
+
* Returns a structured result with content, metadata, tables, images, and warnings.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} html - HTML content to convert
|
|
74
|
+
* @param {Object} [options] - Conversion options
|
|
75
|
+
* @param {string} [options.baseUrl] - Base URL for resolving relative URLs
|
|
76
|
+
* @param {string} [options.headingStyle='Atx'] - Heading style ('Atx' or 'Setext')
|
|
77
|
+
* @param {string} [options.bulletListMarker] - Bullet character ('-', '*', '+')
|
|
78
|
+
* @param {string} [options.codeBlockStyle] - Code block style ('Fenced' or 'Indented')
|
|
79
|
+
* @returns {Promise<Object>} Structured conversion result
|
|
80
|
+
* @returns {string} result.content - The converted markdown content
|
|
81
|
+
* @returns {Object|null} result.metadata - Extracted metadata (title, links, headings, images, etc.)
|
|
82
|
+
* @returns {Array} result.tables - Extracted table data
|
|
83
|
+
* @returns {Array} result.images - Extracted image data
|
|
84
|
+
* @returns {Array} result.warnings - Non-fatal conversion warnings
|
|
85
|
+
* @throws {Error} If the kreuzberg converter is not available
|
|
86
|
+
*/
|
|
87
|
+
export async function convertWithKreuzberg(html, options = {}) {
|
|
88
|
+
const convert = await ensureLoaded();
|
|
89
|
+
if (!convert) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
'Kreuzberg html-to-markdown is not installed. ' +
|
|
92
|
+
'Run: npm install @kreuzberg/html-to-markdown-node'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const convertOptions = {
|
|
97
|
+
headingStyle: 'Atx',
|
|
98
|
+
codeBlockStyle: 'Backticks',
|
|
99
|
+
...options,
|
|
100
|
+
};
|
|
101
|
+
delete convertOptions.baseUrl;
|
|
102
|
+
|
|
103
|
+
const processedHtml = options.baseUrl
|
|
104
|
+
? convertRelativeUrls(html, options.baseUrl)
|
|
105
|
+
: html;
|
|
106
|
+
const result = convert(processedHtml, convertOptions);
|
|
107
|
+
|
|
108
|
+
// Parse the metadata JSON string into an object
|
|
109
|
+
let metadata = null;
|
|
110
|
+
if (result.metadata) {
|
|
111
|
+
try {
|
|
112
|
+
metadata =
|
|
113
|
+
typeof result.metadata === 'string'
|
|
114
|
+
? JSON.parse(result.metadata)
|
|
115
|
+
: result.metadata;
|
|
116
|
+
} catch {
|
|
117
|
+
metadata = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
content: result.content || '',
|
|
123
|
+
metadata: normalizeStructuredKeys(metadata),
|
|
124
|
+
tables: normalizeStructuredKeys(result.tables || []),
|
|
125
|
+
images: normalizeStructuredKeys(result.images || []),
|
|
126
|
+
warnings: result.warnings || [],
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/lib.js
CHANGED
|
@@ -411,6 +411,20 @@ function selectedHtml($, selector) {
|
|
|
411
411
|
return $selected.length ? $.html($selected) : null;
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
export function scopeHtmlForMarkdown(html, options = {}) {
|
|
415
|
+
const { contentSelector, bodySelector } = options;
|
|
416
|
+
const $ = cheerio.load(html);
|
|
417
|
+
const bodyHtml = selectedHtml($, bodySelector);
|
|
418
|
+
const contentHtml = selectedHtml($, contentSelector);
|
|
419
|
+
if (!bodyHtml && !contentHtml) {
|
|
420
|
+
return html;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const titleSelector = contentSelector ? `${contentSelector} h1, h1` : 'h1';
|
|
424
|
+
const titleHtml = bodyHtml ? selectedHtml($, titleSelector) : null;
|
|
425
|
+
return [titleHtml, bodyHtml || contentHtml].filter(Boolean).join('\n');
|
|
426
|
+
}
|
|
427
|
+
|
|
414
428
|
// Convert relative URLs to absolute URLs in HTML content
|
|
415
429
|
export function convertRelativeUrls(html, baseUrl) {
|
|
416
430
|
const base = new URL(baseUrl);
|
|
@@ -581,15 +595,8 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
581
595
|
metadata = extractMetadata($);
|
|
582
596
|
}
|
|
583
597
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (bodyHtml || contentHtml) {
|
|
587
|
-
const titleSelector = contentSelector ? `${contentSelector} h1, h1` : 'h1';
|
|
588
|
-
const titleHtml = bodyHtml ? selectedHtml($, titleSelector) : null;
|
|
589
|
-
$ = cheerio.load(
|
|
590
|
-
[titleHtml, bodyHtml || contentHtml].filter(Boolean).join('\n')
|
|
591
|
-
);
|
|
592
|
-
}
|
|
598
|
+
html = scopeHtmlForMarkdown(html, { contentSelector, bodySelector });
|
|
599
|
+
$ = cheerio.load(html);
|
|
593
600
|
|
|
594
601
|
// Remove unwanted elements
|
|
595
602
|
$('style, script, noscript').remove();
|
package/src/markdown.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
fetchHtml,
|
|
3
|
+
convertHtmlToMarkdownEnhanced,
|
|
4
|
+
scopeHtmlForMarkdown,
|
|
5
|
+
} from './lib.js';
|
|
6
|
+
import { convertWithKreuzberg, isKreuzbergAvailable } from './kreuzberg.js';
|
|
2
7
|
import { applyImageMode } from './extract-images.js';
|
|
3
8
|
|
|
4
9
|
export async function markdownHandler(req, res) {
|
|
@@ -6,9 +11,52 @@ export async function markdownHandler(req, res) {
|
|
|
6
11
|
if (!url) {
|
|
7
12
|
return res.status(400).send('Missing `url` parameter');
|
|
8
13
|
}
|
|
14
|
+
|
|
15
|
+
const converter = (req.query.converter || 'turndown').toLowerCase();
|
|
16
|
+
const format = (req.query.format || 'text').toLowerCase();
|
|
9
17
|
const embedImages = req.query.embedImages === 'true';
|
|
18
|
+
|
|
19
|
+
if (!['turndown', 'kreuzberg'].includes(converter)) {
|
|
20
|
+
return res.status(400).send('Unsupported `converter` parameter');
|
|
21
|
+
}
|
|
22
|
+
if (!['text', 'json'].includes(format)) {
|
|
23
|
+
return res.status(400).send('Unsupported `format` parameter');
|
|
24
|
+
}
|
|
25
|
+
if (format === 'json' && converter !== 'kreuzberg') {
|
|
26
|
+
return res
|
|
27
|
+
.status(400)
|
|
28
|
+
.send('`format=json` is only supported with `converter=kreuzberg`');
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
try {
|
|
11
32
|
const html = await fetchHtml(url);
|
|
33
|
+
|
|
34
|
+
if (converter === 'kreuzberg') {
|
|
35
|
+
const available = await isKreuzbergAvailable();
|
|
36
|
+
if (!available) {
|
|
37
|
+
return res
|
|
38
|
+
.status(501)
|
|
39
|
+
.send(
|
|
40
|
+
'Kreuzberg converter not available. Install @kreuzberg/html-to-markdown-node.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const scopedHtml = scopeHtmlForMarkdown(html, {
|
|
45
|
+
contentSelector: req.query.contentSelector,
|
|
46
|
+
bodySelector: req.query.bodySelector,
|
|
47
|
+
});
|
|
48
|
+
const result = await convertWithKreuzberg(scopedHtml, { baseUrl: url });
|
|
49
|
+
const imageResult = await applyImageMode(result.content, {
|
|
50
|
+
mode: embedImages ? 'embed' : 'default',
|
|
51
|
+
});
|
|
52
|
+
result.content = imageResult.markdown;
|
|
53
|
+
|
|
54
|
+
if (format === 'json') {
|
|
55
|
+
return res.json(result);
|
|
56
|
+
}
|
|
57
|
+
return res.type('text/markdown').send(result.content);
|
|
58
|
+
}
|
|
59
|
+
|
|
12
60
|
const { markdown } = convertHtmlToMarkdownEnhanced(html, url, {
|
|
13
61
|
contentSelector: req.query.contentSelector,
|
|
14
62
|
bodySelector: req.query.bodySelector,
|
|
@@ -11,6 +11,7 @@ import request from 'supertest';
|
|
|
11
11
|
import nock from 'nock';
|
|
12
12
|
import unzipper from 'unzipper';
|
|
13
13
|
import { app } from '../../src/index.js';
|
|
14
|
+
import { isKreuzbergAvailable } from '../../src/kreuzberg.js';
|
|
14
15
|
|
|
15
16
|
jest.setTimeout(60000);
|
|
16
17
|
|
|
@@ -68,6 +69,68 @@ describe('API Endpoint Tests', () => {
|
|
|
68
69
|
expect(res.text).toContain('Test Page');
|
|
69
70
|
expect(res.text).toContain('Hello world');
|
|
70
71
|
});
|
|
72
|
+
|
|
73
|
+
it('rejects unsupported converter names', async () => {
|
|
74
|
+
await request(app)
|
|
75
|
+
.get('/markdown')
|
|
76
|
+
.query({
|
|
77
|
+
url: 'https://example.com/md-test',
|
|
78
|
+
converter: 'unknown',
|
|
79
|
+
})
|
|
80
|
+
.expect(400);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects JSON format without the kreuzberg converter', async () => {
|
|
84
|
+
await request(app)
|
|
85
|
+
.get('/markdown')
|
|
86
|
+
.query({
|
|
87
|
+
url: 'https://example.com/md-test',
|
|
88
|
+
format: 'json',
|
|
89
|
+
})
|
|
90
|
+
.expect(400);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns structured kreuzberg JSON with selectors and absolute links', async () => {
|
|
94
|
+
if (!(await isKreuzbergAvailable())) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
nock('https://example.com')
|
|
99
|
+
.get('/kreuzberg-json')
|
|
100
|
+
.reply(
|
|
101
|
+
200,
|
|
102
|
+
`<!DOCTYPE html><html><body>
|
|
103
|
+
<main>
|
|
104
|
+
<h1>Article Title</h1>
|
|
105
|
+
<nav>Navigation</nav>
|
|
106
|
+
<article><p>Wanted body with <a href="/about">relative link</a>.</p></article>
|
|
107
|
+
</main>
|
|
108
|
+
</body></html>`,
|
|
109
|
+
{ 'content-type': 'text/html' }
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const res = await request(app)
|
|
113
|
+
.get('/markdown')
|
|
114
|
+
.query({
|
|
115
|
+
url: 'https://example.com/kreuzberg-json',
|
|
116
|
+
converter: 'kreuzberg',
|
|
117
|
+
format: 'json',
|
|
118
|
+
contentSelector: 'main',
|
|
119
|
+
bodySelector: 'article',
|
|
120
|
+
})
|
|
121
|
+
.expect(200);
|
|
122
|
+
|
|
123
|
+
expect(res.headers['content-type']).toMatch(/application\/json/);
|
|
124
|
+
expect(res.body).toHaveProperty('content');
|
|
125
|
+
expect(res.body).toHaveProperty('metadata');
|
|
126
|
+
expect(res.body).toHaveProperty('tables');
|
|
127
|
+
expect(res.body).toHaveProperty('images');
|
|
128
|
+
expect(res.body).toHaveProperty('warnings');
|
|
129
|
+
expect(res.body.content).toContain('Article Title');
|
|
130
|
+
expect(res.body.content).toContain('Wanted body');
|
|
131
|
+
expect(res.body.content).toContain('https://example.com/about');
|
|
132
|
+
expect(res.body.content).not.toContain('Navigation');
|
|
133
|
+
});
|
|
71
134
|
});
|
|
72
135
|
|
|
73
136
|
describe('GET /archive', () => {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for downloading the Wikipedia page (issue #8).
|
|
3
|
+
*
|
|
4
|
+
* Verifies that web-capture can actually download the Wikipedia article about
|
|
5
|
+
* Wikipedia itself (https://en.wikipedia.org/wiki/Wikipedia) as both Markdown
|
|
6
|
+
* and an image (PNG screenshot) using every supported browser engine
|
|
7
|
+
* (Puppeteer and Playwright).
|
|
8
|
+
*
|
|
9
|
+
* These tests hit the live Wikipedia servers. Set WIKIPEDIA_INTEGRATION=true to
|
|
10
|
+
* run them. In CI they are enabled via the workflow environment variable. When
|
|
11
|
+
* the variable is unset the suite is skipped so default/offline runs stay
|
|
12
|
+
* deterministic.
|
|
13
|
+
*
|
|
14
|
+
* Navigation uses retry with exponential backoff to absorb transient network
|
|
15
|
+
* failures, mirroring the Habr integration suite.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { jest } from '@jest/globals';
|
|
19
|
+
import { createBrowser } from '../../src/browser.js';
|
|
20
|
+
import { convertHtmlToMarkdown } from '../../src/lib.js';
|
|
21
|
+
import { retry } from '../../src/retry.js';
|
|
22
|
+
|
|
23
|
+
const WIKIPEDIA_URL = 'https://en.wikipedia.org/wiki/Wikipedia';
|
|
24
|
+
|
|
25
|
+
// PNG magic number (89 50 4E 47 0D 0A 1A 0A).
|
|
26
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
27
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const SKIP_LIVE =
|
|
31
|
+
!process.env.WIKIPEDIA_INTEGRATION ||
|
|
32
|
+
process.env.WIKIPEDIA_INTEGRATION === 'false';
|
|
33
|
+
const describeIfLive = SKIP_LIVE ? describe.skip : describe;
|
|
34
|
+
|
|
35
|
+
// These tests hit live servers and may take a while.
|
|
36
|
+
jest.setTimeout(120000);
|
|
37
|
+
|
|
38
|
+
// Navigate with retry + exponential backoff, then give the page a moment to
|
|
39
|
+
// settle so late-loading content and images are present before we capture.
|
|
40
|
+
async function navigateWithRetry(page, url) {
|
|
41
|
+
await retry(
|
|
42
|
+
async () => {
|
|
43
|
+
await page.goto(url, {
|
|
44
|
+
waitUntil: 'networkidle0',
|
|
45
|
+
timeout: 60000,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
retries: 3,
|
|
50
|
+
baseDelay: 2000,
|
|
51
|
+
onRetry: (err, attempt, delay) => {
|
|
52
|
+
console.log(
|
|
53
|
+
`Navigation retry ${attempt} for ${url} after ${delay}ms: ${err.message}`
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Run the full matrix (Markdown + image) once per supported engine.
|
|
62
|
+
describe.each(['puppeteer', 'playwright'])(
|
|
63
|
+
'Wikipedia Page Download (%s engine)',
|
|
64
|
+
(engine) => {
|
|
65
|
+
describeIfLive(`${engine}`, () => {
|
|
66
|
+
let browser;
|
|
67
|
+
let page;
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
browser = await createBrowser(engine);
|
|
71
|
+
page = await browser.newPage();
|
|
72
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
73
|
+
await navigateWithRetry(page, WIKIPEDIA_URL);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
if (browser) {
|
|
78
|
+
await browser.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('downloads the Wikipedia page as Markdown', async () => {
|
|
83
|
+
const html = await page.content();
|
|
84
|
+
expect(html).toMatch(/<html/i);
|
|
85
|
+
expect(html.length).toBeGreaterThan(1000);
|
|
86
|
+
|
|
87
|
+
const markdown = convertHtmlToMarkdown(html, WIKIPEDIA_URL);
|
|
88
|
+
|
|
89
|
+
// Markdown should carry the page's real content...
|
|
90
|
+
expect(markdown).toContain('Wikipedia');
|
|
91
|
+
expect(markdown.length).toBeGreaterThan(500);
|
|
92
|
+
// ...and should contain markdown structure (headings and/or links)...
|
|
93
|
+
expect(markdown).toMatch(/(^#{1,6}\s)|(\[.*?\]\(.*?\))/m);
|
|
94
|
+
// ...but no longer be raw HTML document scaffolding.
|
|
95
|
+
expect(markdown).not.toMatch(/<html/i);
|
|
96
|
+
expect(markdown).not.toMatch(/<head[\s>]/i);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('downloads the Wikipedia page as an image (PNG screenshot)', async () => {
|
|
100
|
+
const screenshot = await page.screenshot({ type: 'png' });
|
|
101
|
+
|
|
102
|
+
expect(screenshot).toBeInstanceOf(Buffer);
|
|
103
|
+
expect(screenshot.length).toBeGreaterThan(1000);
|
|
104
|
+
// Verify the PNG signature so we know it is a real image.
|
|
105
|
+
expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
describeIfLive('Wikipedia Page Download (engine parity)', () => {
|
|
112
|
+
it('both engines download Markdown and an image for the Wikipedia page', async () => {
|
|
113
|
+
const engines = ['puppeteer', 'playwright'];
|
|
114
|
+
const results = [];
|
|
115
|
+
|
|
116
|
+
for (const engine of engines) {
|
|
117
|
+
const browser = await createBrowser(engine);
|
|
118
|
+
try {
|
|
119
|
+
const page = await browser.newPage();
|
|
120
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
121
|
+
await navigateWithRetry(page, WIKIPEDIA_URL);
|
|
122
|
+
|
|
123
|
+
const html = await page.content();
|
|
124
|
+
const markdown = convertHtmlToMarkdown(html, WIKIPEDIA_URL);
|
|
125
|
+
const screenshot = await page.screenshot({ type: 'png' });
|
|
126
|
+
|
|
127
|
+
results.push({ engine, markdown, screenshot });
|
|
128
|
+
} finally {
|
|
129
|
+
await browser.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const { markdown, screenshot } of results) {
|
|
134
|
+
// Markdown captured from both engines.
|
|
135
|
+
expect(markdown).toContain('Wikipedia');
|
|
136
|
+
expect(markdown.length).toBeGreaterThan(500);
|
|
137
|
+
// Image captured from both engines.
|
|
138
|
+
expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
|
|
139
|
+
expect(screenshot.length).toBeGreaterThan(1000);
|
|
140
|
+
}
|
|
141
|
+
}, 180000);
|
|
142
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
convertWithKreuzberg,
|
|
3
|
+
isKreuzbergAvailable,
|
|
4
|
+
} from '../../src/kreuzberg.js';
|
|
5
|
+
|
|
6
|
+
let available;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
available = await isKreuzbergAvailable();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function skipIfUnavailable() {
|
|
13
|
+
if (!available) {
|
|
14
|
+
return 'kreuzberg native binding not installed';
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('kreuzberg html-to-markdown integration', () => {
|
|
20
|
+
it('reports availability correctly', async () => {
|
|
21
|
+
expect(typeof available).toBe('boolean');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('converts basic HTML to Markdown', async () => {
|
|
25
|
+
const skip = skipIfUnavailable();
|
|
26
|
+
if (skip) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const result = await convertWithKreuzberg(
|
|
30
|
+
'<h1>Hello World</h1><p>This is a test.</p>'
|
|
31
|
+
);
|
|
32
|
+
expect(result.content).toContain('# Hello World');
|
|
33
|
+
expect(result.content).toContain('This is a test.');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns structured result with all fields', async () => {
|
|
37
|
+
const skip = skipIfUnavailable();
|
|
38
|
+
if (skip) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const result = await convertWithKreuzberg('<p>Test</p>');
|
|
42
|
+
expect(result).toHaveProperty('content');
|
|
43
|
+
expect(result).toHaveProperty('metadata');
|
|
44
|
+
expect(result).toHaveProperty('tables');
|
|
45
|
+
expect(result).toHaveProperty('images');
|
|
46
|
+
expect(result).toHaveProperty('warnings');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('extracts metadata from HTML', async () => {
|
|
50
|
+
const skip = skipIfUnavailable();
|
|
51
|
+
if (skip) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const html = `<html>
|
|
55
|
+
<head>
|
|
56
|
+
<title>Test Page</title>
|
|
57
|
+
<meta property="og:description" content="A test page">
|
|
58
|
+
</head>
|
|
59
|
+
<body><h1>Hello</h1></body>
|
|
60
|
+
</html>`;
|
|
61
|
+
const result = await convertWithKreuzberg(html);
|
|
62
|
+
expect(result.metadata).toBeTruthy();
|
|
63
|
+
expect(result.metadata.document.title).toBe('Test Page');
|
|
64
|
+
expect(result.metadata.document.open_graph.description).toBe('A test page');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('extracts headings from metadata', async () => {
|
|
68
|
+
const skip = skipIfUnavailable();
|
|
69
|
+
if (skip) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const html = '<h1>First</h1><h2>Second</h2><h3>Third</h3>';
|
|
73
|
+
const result = await convertWithKreuzberg(html);
|
|
74
|
+
expect(result.metadata).toBeTruthy();
|
|
75
|
+
expect(result.metadata.headers.length).toBe(3);
|
|
76
|
+
expect(result.metadata.headers[0].text).toBe('First');
|
|
77
|
+
expect(result.metadata.headers[0].level).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('extracts links from metadata', async () => {
|
|
81
|
+
const skip = skipIfUnavailable();
|
|
82
|
+
if (skip) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const html =
|
|
86
|
+
'<a href="https://example.com">Example</a><a href="mailto:test@test.com">Email</a>';
|
|
87
|
+
const result = await convertWithKreuzberg(html);
|
|
88
|
+
expect(result.metadata).toBeTruthy();
|
|
89
|
+
expect(result.metadata.links.length).toBeGreaterThanOrEqual(1);
|
|
90
|
+
const externalLink = result.metadata.links.find(
|
|
91
|
+
(l) => l.href === 'https://example.com'
|
|
92
|
+
);
|
|
93
|
+
expect(externalLink).toBeTruthy();
|
|
94
|
+
expect(externalLink.text).toBe('Example');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('converts tables to Markdown', async () => {
|
|
98
|
+
const skip = skipIfUnavailable();
|
|
99
|
+
if (skip) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const html = `<table>
|
|
103
|
+
<thead><tr><th>Name</th><th>Value</th></tr></thead>
|
|
104
|
+
<tbody><tr><td>A</td><td>1</td></tr><tr><td>B</td><td>2</td></tr></tbody>
|
|
105
|
+
</table>`;
|
|
106
|
+
const result = await convertWithKreuzberg(html);
|
|
107
|
+
expect(result.content).toContain('| Name | Value |');
|
|
108
|
+
expect(result.content).toMatch(/\| A\s+\| 1\s+\|/);
|
|
109
|
+
expect(result.content).toMatch(/\| B\s+\| 2\s+\|/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('converts links correctly', async () => {
|
|
113
|
+
const skip = skipIfUnavailable();
|
|
114
|
+
if (skip) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const html = '<a href="https://example.com">Click here</a>';
|
|
118
|
+
const result = await convertWithKreuzberg(html);
|
|
119
|
+
expect(result.content).toContain('[Click here](https://example.com)');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('resolves relative links with baseUrl', async () => {
|
|
123
|
+
const skip = skipIfUnavailable();
|
|
124
|
+
if (skip) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const html = '<a href="/about">About</a>';
|
|
128
|
+
const result = await convertWithKreuzberg(html, {
|
|
129
|
+
baseUrl: 'https://example.com/docs/page',
|
|
130
|
+
});
|
|
131
|
+
expect(result.content).toContain('[About](https://example.com/about)');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('converts bold and italic correctly', async () => {
|
|
135
|
+
const skip = skipIfUnavailable();
|
|
136
|
+
if (skip) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const html = '<p>This is <strong>bold</strong> and <em>italic</em></p>';
|
|
140
|
+
const result = await convertWithKreuzberg(html);
|
|
141
|
+
expect(result.content).toContain('**bold**');
|
|
142
|
+
expect(result.content).toContain('*italic*');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('converts code blocks correctly', async () => {
|
|
146
|
+
const skip = skipIfUnavailable();
|
|
147
|
+
if (skip) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const html = '<pre><code class="language-js">const x = 1;</code></pre>';
|
|
151
|
+
const result = await convertWithKreuzberg(html);
|
|
152
|
+
expect(result.content).toContain('```');
|
|
153
|
+
expect(result.content).toContain('const x = 1;');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('handles empty HTML gracefully', async () => {
|
|
157
|
+
const skip = skipIfUnavailable();
|
|
158
|
+
if (skip) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const result = await convertWithKreuzberg('');
|
|
162
|
+
expect(result.content).toBeDefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('removes script tags from output', async () => {
|
|
166
|
+
const skip = skipIfUnavailable();
|
|
167
|
+
if (skip) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const html = '<p>Content</p><script>alert("xss")</script>';
|
|
171
|
+
const result = await convertWithKreuzberg(html);
|
|
172
|
+
expect(result.content).toContain('Content');
|
|
173
|
+
expect(result.content).not.toContain('alert');
|
|
174
|
+
expect(result.content).not.toContain('script');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('removes style tags from output', async () => {
|
|
178
|
+
const skip = skipIfUnavailable();
|
|
179
|
+
if (skip) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const html = '<style>body { color: red; }</style><p>Content</p>';
|
|
183
|
+
const result = await convertWithKreuzberg(html);
|
|
184
|
+
expect(result.content).toContain('Content');
|
|
185
|
+
expect(result.content).not.toContain('color: red');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('handles GFM strikethrough', async () => {
|
|
189
|
+
const skip = skipIfUnavailable();
|
|
190
|
+
if (skip) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const html = '<p>This is <del>deleted</del> text</p>';
|
|
194
|
+
const result = await convertWithKreuzberg(html);
|
|
195
|
+
expect(result.content).toContain('~~deleted~~');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('handles lists correctly', async () => {
|
|
199
|
+
const skip = skipIfUnavailable();
|
|
200
|
+
if (skip) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const html = '<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>';
|
|
204
|
+
const result = await convertWithKreuzberg(html);
|
|
205
|
+
expect(result.content).toContain('Item 1');
|
|
206
|
+
expect(result.content).toContain('Item 2');
|
|
207
|
+
expect(result.content).toContain('Item 3');
|
|
208
|
+
});
|
|
209
|
+
});
|