@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/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('|&#39;)?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
+ });