alexa-ai 2.1.1 → 2.1.2

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.
@@ -141,6 +141,12 @@ class WebAnswer {
141
141
  /**
142
142
  * The user turn sent to DeepAI.
143
143
  *
144
+ * With `results` (the engine's own web search) the model is a writer, not
145
+ * a researcher: it gets numbered search results as material and must
146
+ * build the report from them, citing the numbers. Without results it
147
+ * falls back to DeepAI's server-side search, which is unreliable on free
148
+ * models and frequently invents sources.
149
+ *
144
150
  * The long form hands the model a fill-in-the-blanks template rather than
145
151
  * a description of one: small models (`gpt-4o-mini`, `standard`) follow a
146
152
  * visible layout far more reliably than "write several sections".
@@ -148,14 +154,16 @@ class WebAnswer {
148
154
  * @param {string} question
149
155
  * @param {object} [opts]
150
156
  * @param {'short'|'long'} [opts.detail='long']
157
+ * @param {Array<{title?:string,url:string,description?:string,date?:string}>} [opts.results]
151
158
  * @param {string} [opts.language] "Sinhala", "Tamil", … (default: the language of the query)
152
159
  * @param {string} [opts.instructions] extra guidance ("focus on Sri Lanka")
153
160
  * @param {Date} [opts.now]
154
161
  * @returns {string}
155
162
  */
156
- static prompt(question, { detail = 'long', language = '', instructions = '', now = new Date() } = {}) {
163
+ static prompt(question, { detail = 'long', results = null, language = '', instructions = '', now = new Date() } = {}) {
157
164
  const topic = String(question ?? '').trim();
158
165
  const today = WebAnswer._isoDate(now);
166
+ const grounded = Array.isArray(results) && results.length > 0;
159
167
  const extras = [];
160
168
  if (String(language ?? '').trim()) extras.push(`Write the entire answer in ${String(language).trim()}.`);
161
169
  if (String(instructions ?? '').trim()) extras.push(String(instructions).trim());
@@ -168,27 +176,43 @@ class WebAnswer {
168
176
  'Do not add notes about training data, knowledge cut-offs or being unable to browse the web, and do not ' +
169
177
  'describe yourself or your origins — just present the findings. Never reply with a one-word command.';
170
178
 
179
+ const material = grounded ? WebAnswer.formatResults(results) : '';
180
+ const opening = grounded
181
+ ? 'Below are web search results for the topic. Use them as your material: ' +
182
+ 'build the answer from what they say, and put the result number in square brackets after each fact you take from one, like [2]. ' +
183
+ 'You may add well-known background knowledge, but do not invent events, figures, dates or quotes.'
184
+ : 'Use your web search tool to research the topic below, then write';
185
+ const sourcesRule = grounded
186
+ ? 'Do NOT write a "Sources" list — the sources are attached automatically from the search results. Never write a URL.'
187
+ : 'End with a line that says exactly "Sources:" followed by the pages you used, one per line, written as "title (url)".';
188
+
171
189
  if (WebAnswer.detailOf(detail) === 'short') {
172
190
  return (
173
- 'Use your web search tool to look up the topic below, then answer directly in 2 to 4 sentences ' +
174
- 'with the most important current facts (figures, names, dates).\n\n' +
175
- `Topic: ${topic}\nToday's date: ${today}\n\n` +
176
- `Rules:\n1. ${formatting}\n` +
177
- '2. End with a line that says exactly "Sources:" followed by the pages you used, one per line, ' +
178
- 'written as "title (url)".\n' +
179
- `3. ${conduct}` +
191
+ (grounded
192
+ ? `${opening}\n\nTopic: ${topic}\nToday's date: ${today}\n\nSearch results:\n${material}\n\n` +
193
+ 'Answer directly in 2 to 4 sentences with the most important current facts (figures, names, dates).\n\n'
194
+ : `${opening.replace(', then write', '')}, then answer directly in 2 to 4 sentences ` +
195
+ `with the most important current facts (figures, names, dates).\n\nTopic: ${topic}\nToday's date: ${today}\n\n`) +
196
+ `Rules:\n1. ${formatting}\n2. ${sourcesRule}\n3. ${conduct}` +
180
197
  extra
181
198
  );
182
199
  }
183
200
 
201
+ const layoutSources = grounded ? '' : 'Sources:\n<Page title> (<url>)\n<Page title> (<url>)\n<Page title> (<url>)\n\n';
202
+ const layoutPoint = grounded
203
+ ? '<Two sentences with specific facts, names, figures and dates from the results.> [<result number>]'
204
+ : '<Two sentences with specific facts, names, figures and dates from your search.>';
205
+
184
206
  return (
185
- 'Use your web search tool to research the topic below, then write a detailed, well-organised report ' +
186
- 'on it for a WhatsApp reader.\n\n' +
187
- `Topic: ${topic}\nToday's date: ${today}\n\n` +
207
+ (grounded
208
+ ? `${opening}\n\nTopic: ${topic}\nToday's date: ${today}\n\nSearch results:\n${material}\n\n` +
209
+ 'Write a detailed, well-organised report on the topic for a WhatsApp reader.\n\n'
210
+ : `${opening} a detailed, well-organised report on it for a WhatsApp reader.\n\n` +
211
+ `Topic: ${topic}\nToday's date: ${today}\n\n`) +
188
212
  'Required layout — follow it exactly, replacing every <placeholder> (do not print the angle brackets):\n\n' +
189
213
  '<One or two sentences that directly answer or introduce the topic.>\n\n' +
190
214
  '*<Section heading 1>:*\n' +
191
- '1. *<Headline>*: <Two sentences with specific facts, names, figures and dates from your search.>\n' +
215
+ `1. *<Headline>*: ${layoutPoint}\n` +
192
216
  '2. *<Headline>*: <…>\n' +
193
217
  '3. *<Headline>*: <…>\n' +
194
218
  '4. *<Headline>*: <…>\n\n' +
@@ -201,10 +225,7 @@ class WebAnswer {
201
225
  '1. *<Headline>*: <…>\n' +
202
226
  '2. *<Headline>*: <…>\n' +
203
227
  '3. *<Headline>*: <…>\n\n' +
204
- 'Sources:\n' +
205
- '<Page title> (<url>)\n' +
206
- '<Page title> (<url>)\n' +
207
- '<Page title> (<url>)\n\n' +
228
+ layoutSources +
208
229
  'Rules:\n' +
209
230
  '1. Write 3 to 5 sections with 3 to 4 numbered points each — about 300 to 450 words in total. ' +
210
231
  'Never stop after one paragraph.\n' +
@@ -213,14 +234,122 @@ class WebAnswer {
213
234
  '*Recent Movement:* and *What Is Driving It:*; for a person or company, *Latest News:*, *Background:* ' +
214
235
  'and *Key Facts:*.\n' +
215
236
  '3. Every point must carry concrete, current information (numbers, names, places, dates) — no filler ' +
216
- 'and no repetition.\n' +
237
+ 'and no repetition.' +
238
+ (grounded
239
+ ? ' If the results contain little about the topic, say so in the intro and cover what they do contain — never fill the gap with invented details.\n'
240
+ : '\n') +
217
241
  `4. ${formatting}\n` +
218
- '5. The "Sources:" list comes last, one page per line, at least 3 pages if you can.\n' +
242
+ `5. ${sourcesRule}${grounded ? '' : ' List at least 3 pages if you can.'}\n` +
219
243
  `6. ${conduct}` +
220
244
  extra
221
245
  );
222
246
  }
223
247
 
248
+ /** Numbered search results as prompt material. */
249
+ static formatResults(results, { maxDescription = 300 } = {}) {
250
+ return (Array.isArray(results) ? results : [])
251
+ .map((r, i) => {
252
+ const title = String(r.title || '').trim() || WebAnswer._hostOf(r.url) || 'Untitled';
253
+ const date = r.date ? ` (${r.date})` : '';
254
+ const host = WebAnswer._hostOf(r.url);
255
+ const desc = String(r.description || '').replace(/\s+/g, ' ').trim();
256
+ const body = desc ? `\n ${desc.length > maxDescription ? `${desc.slice(0, maxDescription - 1).trim()}…` : desc}` : '';
257
+ return `[${i + 1}] ${title}${date}${host ? ` — ${host}` : ''}${body}`;
258
+ })
259
+ .join('\n');
260
+ }
261
+
262
+ /**
263
+ * Plain list of the search results — the reply when the model is
264
+ * unavailable but the search worked.
265
+ */
266
+ static digest(results, { max = 6 } = {}) {
267
+ const items = (Array.isArray(results) ? results : []).slice(0, max);
268
+ if (!items.length) return '';
269
+ const lines = items.map((r, i) => {
270
+ const title = String(r.title || '').trim() || WebAnswer._hostOf(r.url) || 'Untitled';
271
+ const date = r.date ? ` _(${r.date})_` : '';
272
+ const desc = String(r.description || '').replace(/\s+/g, ' ').trim();
273
+ return `${i + 1}. *${title}*${date}${desc ? `: ${desc}` : ''}`;
274
+ });
275
+ return `Here is what I found:\n\n${lines.join('\n')}`;
276
+ }
277
+
278
+ /** @private */
279
+ static _hostOf(url) {
280
+ try {
281
+ // `URL` above is the regex source; use the WHATWG constructor.
282
+ return new globalThis.URL(String(url)).hostname.replace(/^www\./, '');
283
+ } catch {
284
+ return '';
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Turn `[2]` / `[2, 3]` / `[2][3]` citation markers into nothing, and
290
+ * report which result numbers were actually cited (1-based).
291
+ * WhatsApp readers get clean prose; the numbers decide the order of the
292
+ * sources block.
293
+ */
294
+ static extractCitations(text, count) {
295
+ const cited = [];
296
+ const out = String(text ?? '')
297
+ // "[2]", "[2, 3]", "[2-4]", "[Source 2]", "(source: 2)", "(result 3)", "[refs 1, 2]"
298
+ .replace(/\s*[[(](?:(?:sources?|results?|refs?|references?)\s*:?\s*)?(\d{1,2}(?:\s*[,;–-]\s*\d{1,2})*)[\])]/gi, (m, list) => {
299
+ // "(2024)" / "(12)" style numbers in prose are not citations: only
300
+ // bare square brackets or an explicit "source/result" word count.
301
+ if (m.trim().startsWith('(') && !/^\(\s*(?:sources?|results?|refs?|references?)/i.test(m.trim())) return m;
302
+ for (const part of list.split(/\s*[,;]\s*/)) {
303
+ const range = part.match(/^(\d{1,2})\s*[–-]\s*(\d{1,2})$/);
304
+ const nums = range ? WebAnswer._range(Number(range[1]), Number(range[2])) : [Number(part)];
305
+ for (const n of nums) if (n >= 1 && n <= count && !cited.includes(n)) cited.push(n);
306
+ }
307
+ return '';
308
+ })
309
+ .replace(/[ \t]+([.,;:!?])/g, '$1')
310
+ .replace(/[ \t]{2,}/g, ' ');
311
+ return { text: out, cited };
312
+ }
313
+
314
+ /**
315
+ * Grounded replies must not contain URLs the model typed itself. A URL
316
+ * that matches one of the search results becomes a citation marker for
317
+ * it; any other URL is removed (with its "(see …)" wrapper).
318
+ */
319
+ static stripUrls(text, results) {
320
+ const keys = new Map();
321
+ (Array.isArray(results) ? results : []).forEach((r, i) => {
322
+ if (r && r.url) keys.set(WebAnswer.urlKey(WebAnswer.cleanUrl(r.url) || r.url), i + 1);
323
+ });
324
+ const swap = (url) => {
325
+ const n = keys.get(WebAnswer.urlKey(WebAnswer.cleanUrl(url) || url));
326
+ return n ? ` [${n}]` : '';
327
+ };
328
+ return String(text ?? '')
329
+ // "(see https://…)", "(source: https://…, https://…)", "(https://…)"
330
+ .replace(new RegExp(`\\s*\\((?:[^()\\n]{0,20}?:?\\s*)?(${URL}(?:\\s*[,;]\\s*${URL})*)\\s*\\)`, 'gi'), (_m, urls) =>
331
+ String(urls)
332
+ .split(/\s*[,;]\s*/)
333
+ .map(swap)
334
+ .join('')
335
+ )
336
+ // "Title (https://…)" style leftovers and bare URLs; sentence punctuation after the URL survives
337
+ .replace(new RegExp(`\\s*(?:\\b(?:at|see|via|from|source|link|read more(?: at)?):?\\s+)?(${URL})`, 'gi'), (_m, url) => {
338
+ const trail = url.match(/[.,;:!?]+$/);
339
+ return swap(trail ? url.slice(0, -trail[0].length) : url) + (trail ? trail[0] : '');
340
+ })
341
+ .replace(/[ \t]+([.,;:!?])/g, '$1')
342
+ .replace(/[ \t]{2,}/g, ' ');
343
+ }
344
+
345
+ /** @private */
346
+ static _range(a, b) {
347
+ const out = [];
348
+ if (b < a) [a, b] = [b, a];
349
+ for (let n = a; n <= b && out.length < 30; n++) out.push(n);
350
+ return out;
351
+ }
352
+
224
353
  /**
225
354
  * Split a formatted reply into the prose answer and the sources it listed.
226
355
  *
@@ -339,14 +468,16 @@ class WebAnswer {
339
468
  * @param {string} question
340
469
  * @param {number} words how long the first attempt was
341
470
  */
342
- static expandPrompt(question, words) {
471
+ static expandPrompt(question, words, { grounded = false } = {}) {
343
472
  return (
344
473
  `Your reply was only ${words} words and did not follow the required layout. Rewrite it in full now for ` +
345
474
  `the topic "${String(question ?? '').trim()}": one or two intro sentences, then 3 to 5 sections — each a ` +
346
475
  'bold *Heading:* line followed by 3 to 4 numbered points written as *Headline*: two sentences of specific, ' +
347
- 'current facts — at least 300 words in total, then the "Sources:" list with one "title (url)" per line. ' +
348
- 'Use your web search tool for current details. Output only the report: no apology, no preamble, no notes ' +
349
- 'about your abilities or training data.'
476
+ 'current facts — at least 300 words in total' +
477
+ (grounded
478
+ ? ', built from the search results above with the result number in square brackets after each fact. Do not write URLs or a Sources list. '
479
+ : ', then the "Sources:" list with one "title (url)" per line. Use your web search tool for current details. ') +
480
+ 'Output only the report: no apology, no preamble, no notes about your abilities or training data.'
350
481
  );
351
482
  }
352
483
 
@@ -0,0 +1,428 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WebSearch
5
+ * ---------
6
+ * The engine's own web search, used by `AlexaAI.searchWeb()`.
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * DeepAI's chat endpoint accepts `web_access_enabled` / `search` flags, but on
11
+ * the models available to free keys the search runs unreliably — often not at
12
+ * all. Observed live with `gpt-4o-mini`: two consecutive requests about the
13
+ * same topic produced two contradictory "reports", one without any sources
14
+ * and one whose sources were invented URLs. A model cannot be asked to be its
15
+ * own source of truth.
16
+ *
17
+ * So the engine searches first, using free public endpoints that need no API
18
+ * key, and gives the results to the model as material to write from. The
19
+ * URLs the bot shows come from these results, never from the model.
20
+ *
21
+ * Providers (all run in parallel, each with its own timeout):
22
+ *
23
+ * bing general web results bing.com/search?format=rss
24
+ * bing-news recent news bing.com/news RSS
25
+ * wikipedia background en.wikipedia.org API
26
+ * google-news recent news news.google.com RSS
27
+ * duckduckgo general web results lite.duckduckgo.com, then html.duckduckgo.com
28
+ *
29
+ * Verified live on 2026-09-06: the four feeds above answered from a
30
+ * data-centre IP; DuckDuckGo's html endpoint served a bot challenge while
31
+ * the lite endpoint answered, hence the order.
32
+ *
33
+ * A host application with a proper search API (Brave, Serper, Tavily, …)
34
+ * can replace the built-ins with `webSearchProvider: async (query) => results`
35
+ * in the constructor options, or pass `results` straight into `searchWeb()`.
36
+ *
37
+ * Every provider is best-effort: a failure or an empty page yields no results
38
+ * from that provider and never throws out of `search()`.
39
+ */
40
+
41
+ const PROVIDERS = ['bing', 'bing-news', 'wikipedia', 'google-news', 'duckduckgo'];
42
+
43
+ /**
44
+ * Hosts that are redirectors or ads, never a source. `news.google.com`
45
+ * article links are kept: they are real (redirecting) links to the story.
46
+ */
47
+ const JUNK_HOST = /(?:^|\.)(?:duckduckgo\.com|bing\.com|googleadservices\.com|doubleclick\.net)$|^(?:www\.)?google\.com$/i;
48
+
49
+ /** DuckDuckGo's "bots use DuckDuckGo too" interstitial. */
50
+ const DDG_CHALLENGE = /bots use duckduckgo|anomaly-modal|class="anomaly/i;
51
+
52
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“' };
53
+
54
+ class WebSearch {
55
+ /**
56
+ * @param {object} [config]
57
+ * @param {boolean} [config.webSearch=true]
58
+ * @param {string[]} [config.webSearchProviders]
59
+ * @param {number} [config.webSearchTimeout=8000] per provider, ms
60
+ * @param {number} [config.webSearchResults=8] results handed to the model
61
+ * @param {Function} [config.webSearchProvider] custom `(query, opts) => results`
62
+ * @param {string} [config.userAgent]
63
+ * @param {object} [config.logger]
64
+ * @param {Function} [config.fetch] injectable for tests
65
+ */
66
+ constructor(config = {}) {
67
+ this.enabled = config.webSearch !== false;
68
+ this.providers = Array.isArray(config.webSearchProviders) && config.webSearchProviders.length ? config.webSearchProviders : PROVIDERS;
69
+ this.timeout = Number.isFinite(config.webSearchTimeout) ? config.webSearchTimeout : 8000;
70
+ this.maxResults = Number.isFinite(config.webSearchResults) ? config.webSearchResults : 8;
71
+ this.custom = typeof config.webSearchProvider === 'function' ? config.webSearchProvider : null;
72
+ this.userAgent =
73
+ config.userAgent ||
74
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
75
+ this.log = config.logger || console;
76
+ this.debug = Boolean(config.debug);
77
+ this._fetch = config.fetch || ((...args) => fetch(...args));
78
+ }
79
+
80
+ static get PROVIDERS() {
81
+ return [...PROVIDERS];
82
+ }
83
+
84
+ /**
85
+ * @param {string} query
86
+ * @param {object} [opts]
87
+ * @param {string[]} [opts.providers]
88
+ * @param {number} [opts.maxResults]
89
+ * @param {AbortSignal} [opts.signal]
90
+ * @returns {Promise<{results: Array<{title:string|null,url:string,description:string|null,date:string|null,provider:string}>, providers: string[], errors: Array<{provider:string,message:string}>}>}
91
+ */
92
+ async search(query, opts = {}) {
93
+ const q = String(query ?? '').trim();
94
+ const limit = Number.isFinite(opts.maxResults) ? Math.max(1, opts.maxResults) : this.maxResults;
95
+ const empty = { results: [], providers: [], errors: [] };
96
+ if (!q || !this.enabled) return empty;
97
+
98
+ if (this.custom) {
99
+ try {
100
+ const list = await this.custom(q, { maxResults: limit, signal: opts.signal });
101
+ const results = WebSearch.normalise(list, 'custom').slice(0, limit);
102
+ return { results, providers: results.length ? ['custom'] : [], errors: [] };
103
+ } catch (err) {
104
+ return { ...empty, errors: [{ provider: 'custom', message: err.message }] };
105
+ }
106
+ }
107
+
108
+ const names = (Array.isArray(opts.providers) && opts.providers.length ? opts.providers : this.providers).filter((n) =>
109
+ PROVIDERS.includes(n)
110
+ );
111
+ const settled = await Promise.allSettled(names.map((name) => this._run(name, q, opts.signal)));
112
+
113
+ const perProvider = [];
114
+ const providers = [];
115
+ const errors = [];
116
+ settled.forEach((outcome, i) => {
117
+ const name = names[i];
118
+ if (outcome.status === 'fulfilled' && outcome.value.length) {
119
+ perProvider.push(outcome.value);
120
+ providers.push(name);
121
+ } else if (outcome.status === 'rejected') {
122
+ errors.push({ provider: name, message: outcome.reason?.message || String(outcome.reason) });
123
+ if (this.debug) this.log.debug?.(`[AlexaAI] web search ${name} failed: ${errors[errors.length - 1].message}`);
124
+ }
125
+ });
126
+
127
+ return { results: WebSearch.interleave(perProvider, limit), providers, errors };
128
+ }
129
+
130
+ /** @private */
131
+ async _run(name, query, signal) {
132
+ switch (name) {
133
+ case 'bing':
134
+ return WebSearch.parseRss(
135
+ await this._get(`https://www.bing.com/search?q=${encodeURIComponent(query)}&format=rss&setlang=en`, signal),
136
+ 'bing'
137
+ );
138
+ case 'duckduckgo':
139
+ return this._duckduckgo(query, signal);
140
+ case 'bing-news':
141
+ return WebSearch.parseRss(
142
+ await this._get(`https://www.bing.com/news/search?q=${encodeURIComponent(query)}&format=rss&setlang=en`, signal),
143
+ 'bing-news'
144
+ );
145
+ case 'google-news':
146
+ return WebSearch.parseRss(
147
+ await this._get(
148
+ `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`,
149
+ signal
150
+ ),
151
+ 'google-news'
152
+ );
153
+ case 'wikipedia':
154
+ return WebSearch.parseWikipedia(
155
+ await this._get(
156
+ 'https://en.wikipedia.org/w/api.php?action=query&list=search&format=json&utf8=1&srlimit=3' +
157
+ `&srprop=snippet%7Ctimestamp&srsearch=${encodeURIComponent(query)}`,
158
+ signal,
159
+ { 'user-agent': 'alexa-ai (https://github.com/AlexaInc/deepai)', accept: 'application/json' }
160
+ )
161
+ );
162
+ default:
163
+ return [];
164
+ }
165
+ }
166
+
167
+ /** @private lite endpoint first, html endpoint when it yields nothing. */
168
+ async _duckduckgo(query, signal) {
169
+ const q = encodeURIComponent(query);
170
+ let results = [];
171
+ try {
172
+ results = WebSearch.parseDuckDuckGo(await this._get(`https://lite.duckduckgo.com/lite/?q=${q}&kl=wt-wt`, signal));
173
+ } catch (err) {
174
+ if (this.debug) this.log.debug?.(`[AlexaAI] duckduckgo lite failed: ${err.message}`);
175
+ }
176
+ if (!results.length) {
177
+ results = WebSearch.parseDuckDuckGo(await this._get(`https://html.duckduckgo.com/html/?q=${q}&kl=wt-wt`, signal));
178
+ }
179
+ return results;
180
+ }
181
+
182
+ /** @private GET with a timeout; rejects on HTTP errors. */
183
+ async _get(url, signal, headers = {}) {
184
+ const controller = new AbortController();
185
+ const timer = setTimeout(() => controller.abort(), this.timeout);
186
+ if (signal) {
187
+ if (signal.aborted) controller.abort();
188
+ else signal.addEventListener?.('abort', () => controller.abort(), { once: true });
189
+ }
190
+ try {
191
+ const response = await this._fetch(url, {
192
+ method: 'GET',
193
+ redirect: 'follow',
194
+ signal: controller.signal,
195
+ headers: {
196
+ 'user-agent': this.userAgent,
197
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
198
+ 'accept-language': 'en-US,en;q=0.8',
199
+ ...headers,
200
+ },
201
+ });
202
+ const body = await response.text();
203
+ if (response.status > 299) throw new Error(`HTTP ${response.status}`);
204
+ return body;
205
+ } catch (err) {
206
+ if (err.name === 'AbortError') throw new Error(`timed out after ${this.timeout}ms`);
207
+ throw err;
208
+ } finally {
209
+ clearTimeout(timer);
210
+ }
211
+ }
212
+
213
+ // ------------------------------------------------------------ parsers --
214
+
215
+ /** html.duckduckgo.com and lite.duckduckgo.com result pages. */
216
+ static parseDuckDuckGo(html) {
217
+ const page = String(html ?? '');
218
+ const results = [];
219
+ const seen = new Set();
220
+ if (DDG_CHALLENGE.test(page)) return results;
221
+
222
+ // html endpoint: <a class="result__a" href="…">Title</a> … <a class="result__snippet" …>snippet</a>
223
+ // lite endpoint: <a rel="nofollow" href="…" class='result-link'>Title</a> … <td class='result-snippet'>snippet</td>
224
+ const anchor = /<a\b[^>]*class=["'](?:result__a|result-link)["'][^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>|<a\b[^>]*href=["']([^"']+)["'][^>]*class=["'](?:result__a|result-link)["'][^>]*>([\s\S]*?)<\/a>/gi;
225
+ const matches = [];
226
+ let m;
227
+ while ((m = anchor.exec(page))) {
228
+ matches.push({ href: m[1] || m[3], title: m[2] || m[4], index: m.index, end: anchor.lastIndex });
229
+ }
230
+
231
+ matches.forEach((hit, i) => {
232
+ const url = WebSearch.unwrapRedirect(hit.href);
233
+ if (!url || WebSearch.isJunk(url)) return;
234
+ const key = url.replace(/\/+$/, '').toLowerCase();
235
+ if (seen.has(key)) return;
236
+ seen.add(key);
237
+
238
+ // The snippet sits between this anchor and the next one.
239
+ const segment = page.slice(hit.end, matches[i + 1] ? matches[i + 1].index : hit.end + 4000);
240
+ const snippet =
241
+ segment.match(/<a\b[^>]*class=["']result__snippet["'][^>]*>([\s\S]*?)<\/a>/i) ||
242
+ segment.match(/<td\b[^>]*class=["']result-snippet["'][^>]*>([\s\S]*?)<\/td>/i);
243
+
244
+ results.push({
245
+ title: WebSearch.text(hit.title) || null,
246
+ url,
247
+ description: snippet ? WebSearch.text(snippet[1]) || null : null,
248
+ date: null,
249
+ provider: 'duckduckgo',
250
+ });
251
+ });
252
+ return results;
253
+ }
254
+
255
+ /** RSS 2.0 from Bing News / Google News. */
256
+ static parseRss(xml, provider = 'rss') {
257
+ const feed = String(xml ?? '');
258
+ const results = [];
259
+ const items = feed.match(/<item\b[\s\S]*?<\/item>/gi) || [];
260
+ for (const item of items) {
261
+ let url = WebSearch.tag(item, 'link') || (item.match(/<link\b[^>]*href=["']([^"']+)["']/i) || [])[1] || '';
262
+ url = WebSearch.unwrapRedirect(WebSearch.decodeEntities(url).trim());
263
+ if (!url || !/^https?:\/\//i.test(url) || WebSearch.isJunk(url)) continue;
264
+
265
+ let title = WebSearch.text(WebSearch.tag(item, 'title'));
266
+ const source = WebSearch.text(WebSearch.tag(item, 'source') || WebSearch.tag(item, 'News:Source'));
267
+ // Google News: "Headline - Publisher"
268
+ if (source && title && title.toLowerCase().endsWith(` - ${source.toLowerCase()}`)) {
269
+ title = title.slice(0, -(source.length + 3)).trim();
270
+ }
271
+ let description = WebSearch.text(WebSearch.tag(item, 'description'));
272
+ if (description && title && description.toLowerCase().startsWith(title.toLowerCase())) {
273
+ // Google's description is the headline again plus the publisher.
274
+ const rest = description.slice(title.length).replace(/^[\s\-–—|:]+/, '').trim();
275
+ description = rest && rest.toLowerCase() !== (source || '').toLowerCase() ? rest : null;
276
+ }
277
+ if (source && !description) description = source;
278
+ else if (source && description && !description.toLowerCase().includes(source.toLowerCase())) description = `${source}: ${description}`;
279
+
280
+ results.push({
281
+ title: title || null,
282
+ url,
283
+ description: description || null,
284
+ date: WebSearch.isoDate(WebSearch.tag(item, 'pubDate')),
285
+ provider,
286
+ });
287
+ }
288
+ return results;
289
+ }
290
+
291
+ /** MediaWiki `list=search` JSON. */
292
+ static parseWikipedia(json) {
293
+ let data = json;
294
+ if (typeof json === 'string') {
295
+ try {
296
+ data = JSON.parse(json);
297
+ } catch {
298
+ return [];
299
+ }
300
+ }
301
+ const hits = data?.query?.search;
302
+ if (!Array.isArray(hits)) return [];
303
+ return hits
304
+ .filter((h) => h && typeof h.title === 'string' && !/\(disambiguation\)$/i.test(h.title))
305
+ .map((h) => ({
306
+ title: `${h.title} - Wikipedia`,
307
+ url: `https://en.wikipedia.org/wiki/${encodeURIComponent(h.title.replace(/ /g, '_'))}`,
308
+ description: WebSearch.text(h.snippet) || null,
309
+ date: WebSearch.isoDate(h.timestamp),
310
+ provider: 'wikipedia',
311
+ }));
312
+ }
313
+
314
+ // ------------------------------------------------------------ helpers --
315
+
316
+ /** Round-robin across providers so news does not crowd out background, capped at `limit`. */
317
+ static interleave(lists, limit) {
318
+ const out = [];
319
+ const seen = new Set();
320
+ const queues = lists.map((l) => [...l]);
321
+ while (out.length < limit && queues.some((q) => q.length)) {
322
+ for (const q of queues) {
323
+ while (q.length) {
324
+ const item = q.shift();
325
+ const key = WebSearch.urlKey(item.url);
326
+ const titleKey = item.title ? item.title.toLowerCase().replace(/\s+-\s+[^-]+$/, '').trim() : null;
327
+ if (seen.has(key) || (titleKey && seen.has(`t:${titleKey}`))) continue;
328
+ seen.add(key);
329
+ if (titleKey) seen.add(`t:${titleKey}`);
330
+ out.push(item);
331
+ break;
332
+ }
333
+ if (out.length >= limit) break;
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+
339
+ /** Accept caller-supplied results in loose shapes. */
340
+ static normalise(list, provider = 'custom') {
341
+ if (!Array.isArray(list)) return [];
342
+ return list
343
+ .map((r) => {
344
+ if (typeof r === 'string') return { title: null, url: r, description: null, date: null, provider };
345
+ if (!r || typeof r !== 'object') return null;
346
+ const url = r.url || r.link || r.href;
347
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
348
+ return {
349
+ title: r.title || r.name || null,
350
+ url,
351
+ description: r.description || r.snippet || r.content || r.summary || null,
352
+ date: WebSearch.isoDate(r.date || r.published || r.pubDate || r.publishedAt) || null,
353
+ provider: r.provider || provider,
354
+ };
355
+ })
356
+ .filter(Boolean);
357
+ }
358
+
359
+ /** `//duckduckgo.com/l/?uddg=<url>` and `bing.com/news/apiclick.aspx?…&url=<url>` → the real URL. */
360
+ static unwrapRedirect(href) {
361
+ let url = String(href ?? '').trim();
362
+ if (!url) return '';
363
+ if (url.startsWith('//')) url = `https:${url}`;
364
+ try {
365
+ const u = new URL(url);
366
+ if (/(?:^|\.)duckduckgo\.com$/i.test(u.hostname)) {
367
+ const inner = u.searchParams.get('uddg') || u.searchParams.get('u3');
368
+ if (inner) return WebSearch.unwrapRedirect(inner);
369
+ }
370
+ if (/(?:^|\.)bing\.com$/i.test(u.hostname)) {
371
+ const inner = u.searchParams.get('url') || u.searchParams.get('r');
372
+ if (inner) return WebSearch.unwrapRedirect(inner);
373
+ }
374
+ return u.href;
375
+ } catch {
376
+ return '';
377
+ }
378
+ }
379
+
380
+ static isJunk(url) {
381
+ try {
382
+ return JUNK_HOST.test(new URL(url).hostname);
383
+ } catch {
384
+ return true;
385
+ }
386
+ }
387
+
388
+ static urlKey(url) {
389
+ return String(url ?? '')
390
+ .trim()
391
+ .toLowerCase()
392
+ .replace(/^https?:\/\//, '')
393
+ .replace(/^www\./, '')
394
+ .replace(/[#?].*$/, '')
395
+ .replace(/\/+$/, '');
396
+ }
397
+
398
+ /** First `<tag>…</tag>` in a block, CDATA unwrapped, raw. */
399
+ static tag(block, name) {
400
+ const re = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, 'i');
401
+ const m = String(block ?? '').match(re);
402
+ if (!m) return '';
403
+ return m[1].replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, '$1');
404
+ }
405
+
406
+ /** Entity-decode, strip tags, collapse whitespace. Handles double-encoded HTML in RSS descriptions. */
407
+ static text(raw) {
408
+ let s = WebSearch.decodeEntities(String(raw ?? ''));
409
+ s = s.replace(/<[^>]+>/g, ' ');
410
+ s = WebSearch.decodeEntities(s);
411
+ return s.replace(/\s+/g, ' ').replace(/\s+([…,.;:!?])/g, '$1').trim();
412
+ }
413
+
414
+ static decodeEntities(s) {
415
+ return String(s ?? '')
416
+ .replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)))
417
+ .replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d)))
418
+ .replace(/&([a-z]+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m);
419
+ }
420
+
421
+ static isoDate(value) {
422
+ if (!value) return null;
423
+ const d = new Date(String(value).trim());
424
+ return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
425
+ }
426
+ }
427
+
428
+ module.exports = WebSearch;