@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 +21 -0
- package/README.md +86 -0
- package/bin/web-capture.js +55 -1
- package/package.json +1 -1
- package/src/index.js +10 -0
- package/src/search.js +409 -0
- package/tests/integration/search-endpoint.test.js +88 -0
- package/tests/unit/search.test.js +268 -0
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):
|
package/bin/web-capture.js
CHANGED
|
@@ -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 (
|
|
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
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('|')?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&rut=abc">First & 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
|
+
});
|