@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.
- package/CHANGELOG.md +21 -0
- package/README.md +93 -5
- package/bin/web-capture.js +55 -1
- package/package.json +2 -1
- package/src/index.js +10 -0
- package/src/kreuzberg.js +128 -0
- package/src/lib.js +16 -9
- package/src/markdown.js +49 -1
- package/src/search.js +409 -0
- package/tests/integration/api-endpoints.test.js +63 -0
- package/tests/integration/search-endpoint.test.js +88 -0
- package/tests/unit/kreuzberg.test.js +209 -0
- package/tests/unit/search.test.js +268 -0
package/src/search.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured search-provider capture (issue #130).
|
|
3
|
+
*
|
|
4
|
+
* Turns a query + provider into a normalized, machine-readable result set so
|
|
5
|
+
* that browser, CLI, and server callers all consume one consistent contract
|
|
6
|
+
* instead of each reimplementing provider-specific scraping. Server-side and
|
|
7
|
+
* CLI callers fetch provider pages directly (no CORS restriction), so this
|
|
8
|
+
* module defaults to the `fetch` capture mode. Providers that expose a native
|
|
9
|
+
* CORS/JSON API (Wikipedia) are preferred; HTML search engines are parsed
|
|
10
|
+
* best-effort and report CAPTCHA/blocking through `diagnostics`.
|
|
11
|
+
*
|
|
12
|
+
* Normalized result shape:
|
|
13
|
+
* {
|
|
14
|
+
* query, provider, captureMode, capturedAt,
|
|
15
|
+
* results: [{ rank, title, url, snippet }],
|
|
16
|
+
* diagnostics: { status, blockedByCors, blockedByCaptcha, sourceUrl }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* @module search
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fetch from 'node-fetch';
|
|
23
|
+
import * as cheerio from 'cheerio';
|
|
24
|
+
import he from 'he';
|
|
25
|
+
import { URL } from 'url';
|
|
26
|
+
|
|
27
|
+
/** Providers understood by the search contract. */
|
|
28
|
+
export const SEARCH_PROVIDERS = [
|
|
29
|
+
'wikipedia',
|
|
30
|
+
'duckduckgo',
|
|
31
|
+
'google',
|
|
32
|
+
'bing',
|
|
33
|
+
'brave',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Default provider when none is supplied. */
|
|
37
|
+
export const DEFAULT_PROVIDER = 'wikipedia';
|
|
38
|
+
|
|
39
|
+
/** Default number of results requested/returned. */
|
|
40
|
+
export const DEFAULT_LIMIT = 10;
|
|
41
|
+
|
|
42
|
+
const USER_AGENT =
|
|
43
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
|
44
|
+
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize whitespace and decode HTML entities in extracted text.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} text - Raw text, possibly with markup/entities
|
|
50
|
+
* @returns {string} Cleaned single-line text
|
|
51
|
+
*/
|
|
52
|
+
function cleanText(text) {
|
|
53
|
+
if (!text) {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
// Strip any residual tags, decode entities, collapse whitespace.
|
|
57
|
+
const stripped = String(text).replace(/<[^>]*>/g, ' ');
|
|
58
|
+
return he.decode(stripped).replace(/\s+/g, ' ').trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the provider-native source URL for a query.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} provider - One of {@link SEARCH_PROVIDERS}
|
|
65
|
+
* @param {string} query - Search query
|
|
66
|
+
* @param {number} limit - Desired result count (used by APIs that support it)
|
|
67
|
+
* @returns {string} Absolute source URL
|
|
68
|
+
*/
|
|
69
|
+
export function buildSearchUrl(provider, query, limit = DEFAULT_LIMIT) {
|
|
70
|
+
const q = encodeURIComponent(query);
|
|
71
|
+
switch (provider) {
|
|
72
|
+
case 'wikipedia':
|
|
73
|
+
return `https://en.wikipedia.org/w/rest.php/v1/search/page?q=${q}&limit=${limit}`;
|
|
74
|
+
case 'duckduckgo':
|
|
75
|
+
return `https://html.duckduckgo.com/html/?q=${q}`;
|
|
76
|
+
case 'google':
|
|
77
|
+
return `https://www.google.com/search?q=${q}&num=${limit}`;
|
|
78
|
+
case 'bing':
|
|
79
|
+
return `https://www.bing.com/search?q=${q}&count=${limit}`;
|
|
80
|
+
case 'brave':
|
|
81
|
+
return `https://search.brave.com/search?q=${q}`;
|
|
82
|
+
default:
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Unknown search provider "${provider}". Supported: ${SEARCH_PROVIDERS.join(', ')}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detect provider CAPTCHA / bot-block interstitials in an HTML body.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} html - Raw HTML response body
|
|
93
|
+
* @returns {boolean} True when the page looks like a block/CAPTCHA wall
|
|
94
|
+
*/
|
|
95
|
+
export function looksLikeCaptcha(html) {
|
|
96
|
+
if (!html) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return /captcha|unusual traffic|are you a robot|verify you('|')?re a human|\/sorry\/index|automated queries/i.test(
|
|
100
|
+
html
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decode a DuckDuckGo redirect href (`//duckduckgo.com/l/?uddg=...`) back to
|
|
106
|
+
* the real destination URL. Non-redirect hrefs are returned as-is.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} href - Raw anchor href
|
|
109
|
+
* @returns {string} The resolved destination URL
|
|
110
|
+
*/
|
|
111
|
+
function resolveDuckDuckGoHref(href) {
|
|
112
|
+
if (!href) {
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
let normalized = href;
|
|
116
|
+
if (normalized.startsWith('//')) {
|
|
117
|
+
normalized = `https:${normalized}`;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const parsed = new URL(normalized, 'https://duckduckgo.com');
|
|
121
|
+
const uddg = parsed.searchParams.get('uddg');
|
|
122
|
+
if (uddg) {
|
|
123
|
+
return uddg;
|
|
124
|
+
}
|
|
125
|
+
return parsed.toString();
|
|
126
|
+
} catch {
|
|
127
|
+
return href;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse the Wikipedia REST search JSON into normalized results. */
|
|
132
|
+
function parseWikipedia(body, limit) {
|
|
133
|
+
let data;
|
|
134
|
+
try {
|
|
135
|
+
data = typeof body === 'string' ? JSON.parse(body) : body;
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const pages = Array.isArray(data?.pages) ? data.pages : [];
|
|
140
|
+
return pages.slice(0, limit).map((page, i) => ({
|
|
141
|
+
rank: i + 1,
|
|
142
|
+
title: cleanText(page.title || page.key || ''),
|
|
143
|
+
url: `https://en.wikipedia.org/wiki/${encodeURIComponent(page.key || page.title || '')}`,
|
|
144
|
+
snippet: cleanText(page.excerpt || page.description || ''),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Parse DuckDuckGo HTML (html.duckduckgo.com/html) into normalized results. */
|
|
149
|
+
function parseDuckDuckGo($, limit) {
|
|
150
|
+
const results = [];
|
|
151
|
+
// `.result__body` wraps each organic result; fall back to `.web-result`
|
|
152
|
+
// for older markup. Selecting a single container avoids counting the same
|
|
153
|
+
// result twice when both classes are present on nested elements.
|
|
154
|
+
let containers = $('.result__body');
|
|
155
|
+
if (containers.length === 0) {
|
|
156
|
+
containers = $('.web-result');
|
|
157
|
+
}
|
|
158
|
+
containers.each((_, el) => {
|
|
159
|
+
if (results.length >= limit) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const anchor = $(el).find('a.result__a').first();
|
|
163
|
+
const title = cleanText(anchor.text());
|
|
164
|
+
const url = resolveDuckDuckGoHref(anchor.attr('href'));
|
|
165
|
+
const snippet = cleanText($(el).find('.result__snippet').first().text());
|
|
166
|
+
if (title && url) {
|
|
167
|
+
results.push({ rank: results.length + 1, title, url, snippet });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return results;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Parse Google results HTML into normalized results (best-effort). */
|
|
174
|
+
function parseGoogle($, limit) {
|
|
175
|
+
const results = [];
|
|
176
|
+
$('div.g, div.tF2Cxc, div.MjjYud').each((_, el) => {
|
|
177
|
+
if (results.length >= limit) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const anchor = $(el).find('a[href^="http"]').first();
|
|
181
|
+
const url = anchor.attr('href');
|
|
182
|
+
const title = cleanText($(el).find('h3').first().text());
|
|
183
|
+
const snippet = cleanText(
|
|
184
|
+
$(el).find('div[data-sncf], .VwiC3b, .IsZvec').first().text()
|
|
185
|
+
);
|
|
186
|
+
if (title && url) {
|
|
187
|
+
results.push({ rank: results.length + 1, title, url, snippet });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Parse Bing results HTML into normalized results. */
|
|
194
|
+
function parseBing($, limit) {
|
|
195
|
+
const results = [];
|
|
196
|
+
$('li.b_algo').each((_, el) => {
|
|
197
|
+
if (results.length >= limit) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const anchor = $(el).find('h2 a').first();
|
|
201
|
+
const url = anchor.attr('href');
|
|
202
|
+
const title = cleanText(anchor.text());
|
|
203
|
+
const snippet = cleanText($(el).find('.b_caption p, p').first().text());
|
|
204
|
+
if (title && url) {
|
|
205
|
+
results.push({ rank: results.length + 1, title, url, snippet });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return results;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parse Brave Search results HTML into normalized results. */
|
|
212
|
+
function parseBrave($, limit) {
|
|
213
|
+
const results = [];
|
|
214
|
+
$('div.snippet[data-type="web"], div.snippet').each((_, el) => {
|
|
215
|
+
if (results.length >= limit) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const anchor = $(el).find('a[href^="http"]').first();
|
|
219
|
+
const url = anchor.attr('href');
|
|
220
|
+
const title = cleanText(
|
|
221
|
+
$(el).find('.snippet-title, .title').first().text() || anchor.text()
|
|
222
|
+
);
|
|
223
|
+
const snippet = cleanText(
|
|
224
|
+
$(el).find('.snippet-description, .snippet-content').first().text()
|
|
225
|
+
);
|
|
226
|
+
if (title && url) {
|
|
227
|
+
results.push({ rank: results.length + 1, title, url, snippet });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
return results;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parse a provider response body into normalized result rows. Pure function
|
|
235
|
+
* (no network) so it can be unit-tested against fixtures.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} provider - One of {@link SEARCH_PROVIDERS}
|
|
238
|
+
* @param {string} body - Raw response body (JSON for wikipedia, HTML otherwise)
|
|
239
|
+
* @param {{limit?: number}} [options] - Parse options
|
|
240
|
+
* @returns {{results: Array, blockedByCaptcha: boolean}}
|
|
241
|
+
*/
|
|
242
|
+
export function parseSearchResults(
|
|
243
|
+
provider,
|
|
244
|
+
body,
|
|
245
|
+
{ limit = DEFAULT_LIMIT } = {}
|
|
246
|
+
) {
|
|
247
|
+
if (provider === 'wikipedia') {
|
|
248
|
+
return { results: parseWikipedia(body, limit), blockedByCaptcha: false };
|
|
249
|
+
}
|
|
250
|
+
const blockedByCaptcha = looksLikeCaptcha(body);
|
|
251
|
+
const $ = cheerio.load(body || '');
|
|
252
|
+
let results;
|
|
253
|
+
switch (provider) {
|
|
254
|
+
case 'duckduckgo':
|
|
255
|
+
results = parseDuckDuckGo($, limit);
|
|
256
|
+
break;
|
|
257
|
+
case 'google':
|
|
258
|
+
results = parseGoogle($, limit);
|
|
259
|
+
break;
|
|
260
|
+
case 'bing':
|
|
261
|
+
results = parseBing($, limit);
|
|
262
|
+
break;
|
|
263
|
+
case 'brave':
|
|
264
|
+
results = parseBrave($, limit);
|
|
265
|
+
break;
|
|
266
|
+
default:
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Unknown search provider "${provider}". Supported: ${SEARCH_PROVIDERS.join(', ')}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return { results, blockedByCaptcha };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Render a normalized search result as Markdown.
|
|
276
|
+
*
|
|
277
|
+
* @param {Object} result - Normalized search result object
|
|
278
|
+
* @returns {string} Markdown document
|
|
279
|
+
*/
|
|
280
|
+
export function formatSearchAsMarkdown(result) {
|
|
281
|
+
const lines = [];
|
|
282
|
+
lines.push(`# Search results for "${result.query}"`);
|
|
283
|
+
lines.push('');
|
|
284
|
+
lines.push(`- Provider: \`${result.provider}\``);
|
|
285
|
+
lines.push(`- Capture mode: \`${result.captureMode}\``);
|
|
286
|
+
lines.push(`- Captured at: ${result.capturedAt}`);
|
|
287
|
+
lines.push(`- Source: ${result.diagnostics.sourceUrl}`);
|
|
288
|
+
if (result.diagnostics.blockedByCaptcha) {
|
|
289
|
+
lines.push('- ⚠️ Provider returned a CAPTCHA / bot-block page.');
|
|
290
|
+
}
|
|
291
|
+
lines.push('');
|
|
292
|
+
if (!result.results.length) {
|
|
293
|
+
lines.push('_No results._');
|
|
294
|
+
return lines.join('\n');
|
|
295
|
+
}
|
|
296
|
+
for (const item of result.results) {
|
|
297
|
+
lines.push(`${item.rank}. [${item.title}](${item.url})`);
|
|
298
|
+
if (item.snippet) {
|
|
299
|
+
lines.push(` ${item.snippet}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return lines.join('\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Capture structured search results for a query from a provider.
|
|
307
|
+
*
|
|
308
|
+
* @param {Object} options - Search options
|
|
309
|
+
* @param {string} options.query - Search query (required)
|
|
310
|
+
* @param {string} [options.provider=wikipedia] - Provider id
|
|
311
|
+
* @param {number} [options.limit=10] - Max results to return
|
|
312
|
+
* @param {string} [options.captureMode=fetch] - Reported capture mode
|
|
313
|
+
* @param {Function} [options.fetchImpl=fetch] - Fetch implementation (injectable for tests)
|
|
314
|
+
* @param {Function} [options.now] - Clock returning an ISO timestamp (injectable for tests)
|
|
315
|
+
* @returns {Promise<Object>} Normalized search result object
|
|
316
|
+
*/
|
|
317
|
+
export async function search({
|
|
318
|
+
query,
|
|
319
|
+
provider = DEFAULT_PROVIDER,
|
|
320
|
+
limit = DEFAULT_LIMIT,
|
|
321
|
+
captureMode = 'fetch',
|
|
322
|
+
fetchImpl = fetch,
|
|
323
|
+
now = () => new Date().toISOString(),
|
|
324
|
+
} = {}) {
|
|
325
|
+
if (!query || !String(query).trim()) {
|
|
326
|
+
throw new Error('Missing `query` parameter');
|
|
327
|
+
}
|
|
328
|
+
if (!SEARCH_PROVIDERS.includes(provider)) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Unknown search provider "${provider}". Supported: ${SEARCH_PROVIDERS.join(', ')}`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const sourceUrl = buildSearchUrl(provider, query, limit);
|
|
335
|
+
const capturedAt = now();
|
|
336
|
+
const diagnostics = {
|
|
337
|
+
status: 0,
|
|
338
|
+
blockedByCors: false,
|
|
339
|
+
blockedByCaptcha: false,
|
|
340
|
+
sourceUrl,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
let results = [];
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetchImpl(sourceUrl, {
|
|
346
|
+
headers: {
|
|
347
|
+
'User-Agent': USER_AGENT,
|
|
348
|
+
Accept:
|
|
349
|
+
provider === 'wikipedia'
|
|
350
|
+
? 'application/json'
|
|
351
|
+
: 'text/html,application/xhtml+xml',
|
|
352
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
diagnostics.status = response.status;
|
|
356
|
+
const body = await response.text();
|
|
357
|
+
const parsed = parseSearchResults(provider, body, { limit });
|
|
358
|
+
results = parsed.results;
|
|
359
|
+
diagnostics.blockedByCaptcha = parsed.blockedByCaptcha;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
// A network/transport failure from a server context is reported as an
|
|
362
|
+
// error status; browser CORS failures surface the same way and are flagged.
|
|
363
|
+
diagnostics.status = diagnostics.status || 0;
|
|
364
|
+
diagnostics.error = err.message;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
query,
|
|
369
|
+
provider,
|
|
370
|
+
captureMode,
|
|
371
|
+
capturedAt,
|
|
372
|
+
results,
|
|
373
|
+
diagnostics,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Express handler for `GET /search?q=<query>&provider=<p>&format=json|markdown`.
|
|
379
|
+
*
|
|
380
|
+
* @param {import('express').Request} req
|
|
381
|
+
* @param {import('express').Response} res
|
|
382
|
+
*/
|
|
383
|
+
export async function searchHandler(req, res) {
|
|
384
|
+
const query = req.query.q || req.query.query;
|
|
385
|
+
if (!query) {
|
|
386
|
+
return res.status(400).send('Missing `q` parameter');
|
|
387
|
+
}
|
|
388
|
+
const provider = req.query.provider || DEFAULT_PROVIDER;
|
|
389
|
+
if (!SEARCH_PROVIDERS.includes(provider)) {
|
|
390
|
+
return res
|
|
391
|
+
.status(400)
|
|
392
|
+
.send(
|
|
393
|
+
`Unknown provider "${provider}". Supported: ${SEARCH_PROVIDERS.join(', ')}`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
const limit = Number.parseInt(req.query.limit, 10) || DEFAULT_LIMIT;
|
|
397
|
+
const format = (req.query.format || 'json').toLowerCase();
|
|
398
|
+
try {
|
|
399
|
+
const result = await search({ query, provider, limit });
|
|
400
|
+
if (format === 'markdown' || format === 'md') {
|
|
401
|
+
res.type('text/markdown').send(formatSearchAsMarkdown(result));
|
|
402
|
+
} else {
|
|
403
|
+
res.json(result);
|
|
404
|
+
}
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error('Search error:', err);
|
|
407
|
+
res.status(500).send(`Error performing search: ${err.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the GET /search endpoint (issue #130).
|
|
3
|
+
*
|
|
4
|
+
* Provider HTTP calls are mocked with nock so the test is fast and offline.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import request from 'supertest';
|
|
8
|
+
import nock from 'nock';
|
|
9
|
+
import { app } from '../../src/index.js';
|
|
10
|
+
|
|
11
|
+
const WIKI_JSON = {
|
|
12
|
+
pages: [
|
|
13
|
+
{
|
|
14
|
+
id: 1,
|
|
15
|
+
key: 'Formal_methods',
|
|
16
|
+
title: 'Formal methods',
|
|
17
|
+
excerpt: 'the study of <b>formal</b> methods',
|
|
18
|
+
description: 'rigorous techniques',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
nock.cleanAll();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('GET /search', () => {
|
|
28
|
+
it('returns structured JSON for the wikipedia provider', async () => {
|
|
29
|
+
nock('https://en.wikipedia.org')
|
|
30
|
+
.get('/w/rest.php/v1/search/page')
|
|
31
|
+
.query(true)
|
|
32
|
+
.reply(200, WIKI_JSON);
|
|
33
|
+
|
|
34
|
+
const res = await request(app)
|
|
35
|
+
.get('/search')
|
|
36
|
+
.query({ q: 'formal-ai', provider: 'wikipedia', limit: 5 })
|
|
37
|
+
.expect(200)
|
|
38
|
+
.expect('Content-Type', /json/);
|
|
39
|
+
|
|
40
|
+
expect(res.body.query).toBe('formal-ai');
|
|
41
|
+
expect(res.body.provider).toBe('wikipedia');
|
|
42
|
+
expect(res.body.results).toHaveLength(1);
|
|
43
|
+
expect(res.body.results[0].url).toBe(
|
|
44
|
+
'https://en.wikipedia.org/wiki/Formal_methods'
|
|
45
|
+
);
|
|
46
|
+
expect(res.body.diagnostics.status).toBe(200);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns markdown when format=markdown', async () => {
|
|
50
|
+
nock('https://en.wikipedia.org')
|
|
51
|
+
.get('/w/rest.php/v1/search/page')
|
|
52
|
+
.query(true)
|
|
53
|
+
.reply(200, WIKI_JSON);
|
|
54
|
+
|
|
55
|
+
const res = await request(app)
|
|
56
|
+
.get('/search')
|
|
57
|
+
.query({ q: 'formal-ai', provider: 'wikipedia', format: 'markdown' })
|
|
58
|
+
.expect(200)
|
|
59
|
+
.expect('Content-Type', /markdown/);
|
|
60
|
+
|
|
61
|
+
expect(res.text).toContain('# Search results for "formal-ai"');
|
|
62
|
+
expect(res.text).toContain('[Formal methods]');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('defaults to the wikipedia provider', async () => {
|
|
66
|
+
nock('https://en.wikipedia.org')
|
|
67
|
+
.get('/w/rest.php/v1/search/page')
|
|
68
|
+
.query(true)
|
|
69
|
+
.reply(200, WIKI_JSON);
|
|
70
|
+
|
|
71
|
+
const res = await request(app)
|
|
72
|
+
.get('/search')
|
|
73
|
+
.query({ q: 'formal-ai' })
|
|
74
|
+
.expect(200);
|
|
75
|
+
expect(res.body.provider).toBe('wikipedia');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects a missing query', async () => {
|
|
79
|
+
await request(app).get('/search').expect(400);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects an unknown provider', async () => {
|
|
83
|
+
await request(app)
|
|
84
|
+
.get('/search')
|
|
85
|
+
.query({ q: 'x', provider: 'yahoo' })
|
|
86
|
+
.expect(400);
|
|
87
|
+
});
|
|
88
|
+
});
|