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.
- package/CHANGELOG.md +37 -0
- package/README.md +76 -20
- package/index.js +2 -0
- package/package.json +2 -1
- package/src/AlexaAI.js +115 -32
- package/src/core/Config.js +9 -0
- package/src/services/WebAnswer.js +153 -22
- package/src/services/WebSearch.js +428 -0
- package/test/fakes.js +171 -0
- package/test/run-tests.js +1035 -0
- package/test/wrapper-methods.js +986 -0
|
@@ -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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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;
|