@link-assistant/web-capture 1.8.1 → 1.10.0

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.
@@ -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
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Unit tests for the structured search-provider capture module (issue #130).
3
+ *
4
+ * Parsers are pure functions tested against fixtures; the orchestrating
5
+ * `search()` is exercised with an injected fetch so no network is required.
6
+ */
7
+
8
+ import {
9
+ search,
10
+ parseSearchResults,
11
+ buildSearchUrl,
12
+ looksLikeCaptcha,
13
+ formatSearchAsMarkdown,
14
+ SEARCH_PROVIDERS,
15
+ DEFAULT_PROVIDER,
16
+ } from '../../src/search.js';
17
+
18
+ const WIKI_JSON = JSON.stringify({
19
+ pages: [
20
+ {
21
+ id: 1,
22
+ key: 'Formal_methods',
23
+ title: 'Formal methods',
24
+ excerpt: 'the <span class="searchmatch">study</span> of <b>formal</b>',
25
+ description: 'mathematically rigorous techniques',
26
+ },
27
+ {
28
+ id: 2,
29
+ key: 'Formal_system',
30
+ title: 'Formal system',
31
+ excerpt: 'an abstract structure',
32
+ description: '',
33
+ },
34
+ ],
35
+ });
36
+
37
+ const DDG_HTML = `
38
+ <div class="result__body">
39
+ <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&amp;rut=abc">First &amp; Best</a>
40
+ <div class="result__snippet">Snippet about the <b>first</b> result</div>
41
+ </div>
42
+ <div class="result__body">
43
+ <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fb">Second result</a>
44
+ <div class="result__snippet">Snippet two</div>
45
+ </div>
46
+ `;
47
+
48
+ const BING_HTML = `
49
+ <ol id="b_results">
50
+ <li class="b_algo">
51
+ <h2><a href="https://bing-result.example/1">Bing One</a></h2>
52
+ <div class="b_caption"><p>Bing snippet one</p></div>
53
+ </li>
54
+ </ol>
55
+ `;
56
+
57
+ describe('search module (#130)', () => {
58
+ describe('buildSearchUrl', () => {
59
+ it('builds the Wikipedia REST API url with limit', () => {
60
+ expect(buildSearchUrl('wikipedia', 'formal ai', 5)).toBe(
61
+ 'https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal%20ai&limit=5'
62
+ );
63
+ });
64
+
65
+ it('builds the DuckDuckGo HTML url', () => {
66
+ expect(buildSearchUrl('duckduckgo', 'a b')).toContain(
67
+ 'https://html.duckduckgo.com/html/?q=a%20b'
68
+ );
69
+ });
70
+
71
+ it('throws for an unknown provider', () => {
72
+ expect(() => buildSearchUrl('yahoo', 'x')).toThrow(
73
+ /Unknown search provider/
74
+ );
75
+ });
76
+ });
77
+
78
+ describe('parseSearchResults', () => {
79
+ it('normalizes Wikipedia REST JSON and strips markup', () => {
80
+ const { results } = parseSearchResults('wikipedia', WIKI_JSON, {
81
+ limit: 10,
82
+ });
83
+ expect(results).toHaveLength(2);
84
+ expect(results[0]).toEqual({
85
+ rank: 1,
86
+ title: 'Formal methods',
87
+ url: 'https://en.wikipedia.org/wiki/Formal_methods',
88
+ snippet: 'the study of formal',
89
+ });
90
+ expect(results[1].url).toBe(
91
+ 'https://en.wikipedia.org/wiki/Formal_system'
92
+ );
93
+ });
94
+
95
+ it('respects the limit', () => {
96
+ const { results } = parseSearchResults('wikipedia', WIKI_JSON, {
97
+ limit: 1,
98
+ });
99
+ expect(results).toHaveLength(1);
100
+ });
101
+
102
+ it('decodes DuckDuckGo redirect hrefs and entities', () => {
103
+ const { results } = parseSearchResults('duckduckgo', DDG_HTML, {
104
+ limit: 10,
105
+ });
106
+ expect(results).toHaveLength(2);
107
+ expect(results[0]).toEqual({
108
+ rank: 1,
109
+ title: 'First & Best',
110
+ url: 'https://example.com/a',
111
+ snippet: 'Snippet about the first result',
112
+ });
113
+ expect(results[1].url).toBe('https://example.org/b');
114
+ });
115
+
116
+ it('parses Bing algorithmic results', () => {
117
+ const { results } = parseSearchResults('bing', BING_HTML, { limit: 10 });
118
+ expect(results).toEqual([
119
+ {
120
+ rank: 1,
121
+ title: 'Bing One',
122
+ url: 'https://bing-result.example/1',
123
+ snippet: 'Bing snippet one',
124
+ },
125
+ ]);
126
+ });
127
+
128
+ it('returns empty results for malformed JSON', () => {
129
+ const { results } = parseSearchResults('wikipedia', 'not json', {
130
+ limit: 10,
131
+ });
132
+ expect(results).toEqual([]);
133
+ });
134
+
135
+ it('flags CAPTCHA pages', () => {
136
+ const { blockedByCaptcha } = parseSearchResults(
137
+ 'google',
138
+ '<html>Our systems have detected unusual traffic</html>',
139
+ { limit: 10 }
140
+ );
141
+ expect(blockedByCaptcha).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe('looksLikeCaptcha', () => {
146
+ it('detects common bot-block phrases', () => {
147
+ expect(looksLikeCaptcha('Please solve the CAPTCHA')).toBe(true);
148
+ expect(looksLikeCaptcha('normal results page')).toBe(false);
149
+ expect(looksLikeCaptcha('')).toBe(false);
150
+ });
151
+ });
152
+
153
+ describe('search()', () => {
154
+ const fixedNow = () => '2026-05-30T00:00:00.000Z';
155
+
156
+ it('returns the normalized contract for a provider', async () => {
157
+ const fetchImpl = async (url) => {
158
+ expect(url).toContain('en.wikipedia.org');
159
+ return { status: 200, text: async () => WIKI_JSON };
160
+ };
161
+ const result = await search({
162
+ query: 'formal-ai',
163
+ provider: 'wikipedia',
164
+ limit: 2,
165
+ fetchImpl,
166
+ now: fixedNow,
167
+ });
168
+ expect(result).toMatchObject({
169
+ query: 'formal-ai',
170
+ provider: 'wikipedia',
171
+ captureMode: 'fetch',
172
+ capturedAt: '2026-05-30T00:00:00.000Z',
173
+ });
174
+ expect(result.results).toHaveLength(2);
175
+ expect(result.diagnostics).toEqual({
176
+ status: 200,
177
+ blockedByCors: false,
178
+ blockedByCaptcha: false,
179
+ sourceUrl:
180
+ 'https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal-ai&limit=2',
181
+ });
182
+ });
183
+
184
+ it('defaults to the Wikipedia provider', async () => {
185
+ const fetchImpl = async () => ({
186
+ status: 200,
187
+ text: async () => WIKI_JSON,
188
+ });
189
+ const result = await search({ query: 'x', fetchImpl, now: fixedNow });
190
+ expect(result.provider).toBe(DEFAULT_PROVIDER);
191
+ expect(DEFAULT_PROVIDER).toBe('wikipedia');
192
+ });
193
+
194
+ it('records transport failures in diagnostics without throwing', async () => {
195
+ const fetchImpl = async () => {
196
+ throw new Error('network down');
197
+ };
198
+ const result = await search({
199
+ query: 'x',
200
+ provider: 'google',
201
+ fetchImpl,
202
+ now: fixedNow,
203
+ });
204
+ expect(result.results).toEqual([]);
205
+ expect(result.diagnostics.error).toBe('network down');
206
+ });
207
+
208
+ it('rejects an empty query', async () => {
209
+ await expect(search({ query: ' ' })).rejects.toThrow(/Missing .query/);
210
+ });
211
+
212
+ it('rejects an unknown provider', async () => {
213
+ await expect(search({ query: 'x', provider: 'yahoo' })).rejects.toThrow(
214
+ /Unknown search provider/
215
+ );
216
+ });
217
+ });
218
+
219
+ describe('formatSearchAsMarkdown', () => {
220
+ it('renders a markdown document with ranked links', () => {
221
+ const md = formatSearchAsMarkdown({
222
+ query: 'formal-ai',
223
+ provider: 'wikipedia',
224
+ captureMode: 'fetch',
225
+ capturedAt: '2026-05-30T00:00:00.000Z',
226
+ results: [
227
+ {
228
+ rank: 1,
229
+ title: 'Formal methods',
230
+ url: 'https://en.wikipedia.org/wiki/Formal_methods',
231
+ snippet: 'study of formal',
232
+ },
233
+ ],
234
+ diagnostics: {
235
+ sourceUrl: 'https://example.com',
236
+ blockedByCaptcha: false,
237
+ },
238
+ });
239
+ expect(md).toContain('# Search results for "formal-ai"');
240
+ expect(md).toContain(
241
+ '1. [Formal methods](https://en.wikipedia.org/wiki/Formal_methods)'
242
+ );
243
+ expect(md).toContain('study of formal');
244
+ });
245
+
246
+ it('reports no results', () => {
247
+ const md = formatSearchAsMarkdown({
248
+ query: 'nothing',
249
+ provider: 'bing',
250
+ captureMode: 'fetch',
251
+ capturedAt: 't',
252
+ results: [],
253
+ diagnostics: { sourceUrl: 'u', blockedByCaptcha: false },
254
+ });
255
+ expect(md).toContain('_No results._');
256
+ });
257
+ });
258
+
259
+ it('exposes the documented provider list', () => {
260
+ expect(SEARCH_PROVIDERS).toEqual([
261
+ 'wikipedia',
262
+ 'duckduckgo',
263
+ 'google',
264
+ 'bing',
265
+ 'brave',
266
+ ]);
267
+ });
268
+ });