@link-assistant/web-capture 1.8.0 → 1.9.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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3f01d90: Add a structured search-provider capture contract (issue #130). New
8
+ `search({ query, provider, limit, captureMode })` library API, `GET /search?q=&provider=&format=json|markdown`
9
+ HTTP endpoint, and `web-capture search "<query>" --provider <provider> --format json|markdown`
10
+ CLI subcommand. Supported providers: `wikipedia` (default, CORS-friendly REST
11
+ API), `duckduckgo`, `google`, `bing`, and `brave` (HTML parsed server-side).
12
+ Results are normalized to a single machine-readable shape —
13
+ `{ query, provider, captureMode, capturedAt, results: [{ rank, title, url, snippet }], diagnostics: { status, blockedByCors, blockedByCaptcha, sourceUrl } }`
14
+ — and blocked or CAPTCHA-gated pages are surfaced through `diagnostics`
15
+ instead of failing silently. The same contract is mirrored in the Rust crate
16
+ so both implementations stay aligned.
17
+
18
+ ## 1.8.1
19
+
20
+ ### Patch Changes
21
+
22
+ - Auto-release unreleased changes
23
+
3
24
  ## 1.8.0
4
25
 
5
26
  ### Minor Changes
package/README.md CHANGED
@@ -88,6 +88,9 @@ web-capture https://example.com --extract-images --images-dir assets -o page.md
88
88
  # Disable specific features
89
89
  web-capture https://example.com --no-extract-latex --no-post-process -o page.md
90
90
 
91
+ # Structured search-provider capture (JSON by default)
92
+ web-capture search "formal methods" --provider wikipedia
93
+
91
94
  # Start as API server
92
95
  web-capture --serve
93
96
 
@@ -232,6 +235,50 @@ GET /stream?url=<URL>
232
235
 
233
236
  Proxy fetch and streaming proxy endpoints.
234
237
 
238
+ ### Search Endpoint
239
+
240
+ ```
241
+ GET /search?q=<QUERY>&provider=<PROVIDER>&format=json|markdown
242
+ ```
243
+
244
+ Captures structured results from a search provider and returns them in a
245
+ normalized, machine-readable shape. `wikipedia` (the default) uses the
246
+ CORS-friendly REST API; the HTML engines (`duckduckgo`, `google`, `bing`,
247
+ `brave`) are parsed server-side. Blocked or CAPTCHA-gated pages are reported
248
+ through `diagnostics` instead of failing.
249
+
250
+ | Parameter | Required | Description | Default |
251
+ | ---------- | -------- | ---------------------------------------------------- | ----------- |
252
+ | `q` | Yes | Search query (`query` is accepted as an alias) | - |
253
+ | `provider` | No | `wikipedia`, `duckduckgo`, `google`, `bing`, `brave` | `wikipedia` |
254
+ | `limit` | No | Maximum number of results | `10` |
255
+ | `format` | No | Response format: `json` or `markdown` | `json` |
256
+
257
+ Example response (`format=json`):
258
+
259
+ ```json
260
+ {
261
+ "query": "formal methods",
262
+ "provider": "wikipedia",
263
+ "captureMode": "fetch",
264
+ "capturedAt": "2026-05-18T20:30:00.000Z",
265
+ "results": [
266
+ {
267
+ "rank": 1,
268
+ "title": "Formal methods",
269
+ "url": "https://en.wikipedia.org/wiki/Formal_methods",
270
+ "snippet": "mathematically rigorous techniques for the specification..."
271
+ }
272
+ ],
273
+ "diagnostics": {
274
+ "status": 200,
275
+ "blockedByCors": false,
276
+ "blockedByCaptcha": false,
277
+ "sourceUrl": "https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal%20methods&limit=10"
278
+ }
279
+ }
280
+ ```
281
+
235
282
  ## CLI Reference
236
283
 
237
284
  ### Server Mode
@@ -291,6 +338,45 @@ web-capture <url> [options]
291
338
  | `docx` | Word document with embedded images |
292
339
  | `archive` / `zip` | ZIP archive with markdown + local images |
293
340
 
341
+ ### Search Mode
342
+
343
+ ```bash
344
+ web-capture search "<query>" [options]
345
+ ```
346
+
347
+ Captures structured search-provider results. Output defaults to JSON; pass
348
+ `--format markdown` for a human-readable document.
349
+
350
+ ```bash
351
+ # Search Wikipedia (default provider), JSON output
352
+ web-capture search "formal methods"
353
+
354
+ # Search DuckDuckGo, limit to 5 results
355
+ web-capture search "formal methods" --provider duckduckgo --limit 5
356
+
357
+ # Render the results as Markdown
358
+ web-capture search "formal methods" --format markdown
359
+ ```
360
+
361
+ | Option | Short | Description | Default |
362
+ | ------------ | ----- | ---------------------------------------------------- | ----------- |
363
+ | `--provider` | | `wikipedia`, `duckduckgo`, `google`, `bing`, `brave` | `wikipedia` |
364
+ | `--limit` | | Maximum number of results | `10` |
365
+ | `--format` | `-f` | Output format: `json` or `markdown` | `json` |
366
+
367
+ The library API exposes the same contract:
368
+
369
+ ```js
370
+ import { search } from '@link-assistant/web-capture';
371
+
372
+ const result = await search({
373
+ query: 'formal methods',
374
+ provider: 'wikipedia',
375
+ limit: 5,
376
+ });
377
+ console.log(result.results[0].title);
378
+ ```
379
+
294
380
  ## Configuration
295
381
 
296
382
  Configuration values are resolved with the following priority (highest to lowest):
@@ -40,6 +40,26 @@ const config = makeConfig({
40
40
  description: 'URL to capture',
41
41
  })
42
42
  )
43
+ .command(
44
+ 'search <query>',
45
+ 'Capture structured search-provider results',
46
+ (yargs) =>
47
+ yargs.positional('query', {
48
+ type: 'string',
49
+ description: 'Search query',
50
+ })
51
+ )
52
+ .option('provider', {
53
+ type: 'string',
54
+ description:
55
+ 'Search provider: wikipedia, duckduckgo, google, bing, brave',
56
+ default: 'wikipedia',
57
+ })
58
+ .option('limit', {
59
+ type: 'number',
60
+ description: 'Maximum number of search results',
61
+ default: 10,
62
+ })
43
63
  .option('serve', {
44
64
  alias: 's',
45
65
  type: 'boolean',
@@ -1234,10 +1254,44 @@ async function captureUrl(url, options) {
1234
1254
  }
1235
1255
  }
1236
1256
 
1257
+ async function runSearch(query) {
1258
+ const { search, formatSearchAsMarkdown } = await import('../src/search.js');
1259
+ // `--format` defaults to `markdown` for capture mode, but search results are
1260
+ // primarily structured JSON. Only honor the format flag when the user passed
1261
+ // it explicitly; otherwise default search output to JSON.
1262
+ const formatPassedExplicitly = process.argv.some(
1263
+ (arg) => arg === '-f' || arg === '--format' || arg.startsWith('--format=')
1264
+ );
1265
+ const format = (
1266
+ formatPassedExplicitly ? config.format : 'json'
1267
+ ).toLowerCase();
1268
+ const result = await search({
1269
+ query,
1270
+ provider: config.provider,
1271
+ limit: config.limit,
1272
+ });
1273
+ if (format === 'markdown' || format === 'md') {
1274
+ console.log(formatSearchAsMarkdown(result));
1275
+ } else {
1276
+ console.log(JSON.stringify(result, null, 2));
1277
+ }
1278
+ }
1279
+
1237
1280
  async function main() {
1238
1281
  const url = getUrlArgument();
1282
+ // The `query` key is only present when the `search <query>` command matched.
1283
+ const isSearch = Object.prototype.hasOwnProperty.call(config, 'query');
1239
1284
 
1240
- if (config.serve) {
1285
+ if (isSearch) {
1286
+ // Search mode
1287
+ const query = typeof config.query === 'string' ? config.query : '';
1288
+ if (!query.trim()) {
1289
+ console.error('Error: Missing search query');
1290
+ console.error('Usage: web-capture search "<query>" [--provider <p>]');
1291
+ process.exit(1);
1292
+ }
1293
+ await runSearch(query);
1294
+ } else if (config.serve) {
1241
1295
  // Server mode
1242
1296
  await startServer(config.port);
1243
1297
  } else if (url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ import { docxHandler } from './docx.js';
11
11
  import { animationHandler } from './animation.js';
12
12
  import { figuresHandler } from './figures.js';
13
13
  import { themedImageHandler } from './themed-image.js';
14
+ import { searchHandler } from './search.js';
14
15
 
15
16
  const app = express();
16
17
  const port = process.env.PORT || 3000;
@@ -27,6 +28,7 @@ app.get('/docx', docxHandler);
27
28
  app.get('/animation', animationHandler);
28
29
  app.get('/figures', figuresHandler);
29
30
  app.get('/themed-image', themedImageHandler);
31
+ app.get('/search', searchHandler);
30
32
 
31
33
  // Start the server if this is the main module
32
34
  const isMainModule =
@@ -66,3 +68,11 @@ if (isMainModule) {
66
68
  }
67
69
 
68
70
  export { app };
71
+ export {
72
+ search,
73
+ searchHandler,
74
+ parseSearchResults,
75
+ buildSearchUrl,
76
+ formatSearchAsMarkdown,
77
+ SEARCH_PROVIDERS,
78
+ } from './search.js';
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
+ }
@@ -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
+ });
@@ -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
+ });