@yeaft/webchat-agent 0.1.664 → 0.1.665
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/package.json +1 -1
- package/unify/tools/web-search.js +216 -40
package/package.json
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* web-search.js — Web search tool.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Strategy (in order):
|
|
5
|
+
* 1. Tavily API (default; configured via ~/.yeaft/config.json → search.tavilyApiKey)
|
|
6
|
+
* 2. Generic searchApiUrl (legacy; user-supplied JSON-returning endpoint)
|
|
7
|
+
* 3. HTML-scrape fallback: DuckDuckGo lite then Bing
|
|
8
|
+
* (works on residential IPs; cloud IPs are usually flagged as bots)
|
|
9
|
+
*
|
|
10
|
+
* Config shape in ~/.yeaft/config.json:
|
|
11
|
+
* {
|
|
12
|
+
* "search": {
|
|
13
|
+
* "tavilyApiKey": "tvly-...",
|
|
14
|
+
* "searchApiUrl": "https://...", // optional, alternative JSON endpoint
|
|
15
|
+
* "disableHtmlFallback": false // optional, opt-out of scraping
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The result is JSON-stringified so the LLM can parse it. We intentionally
|
|
20
|
+
* keep the output shape consistent across providers: { provider, query,
|
|
21
|
+
* answer?, results: [{title, url, snippet}] }.
|
|
6
22
|
*/
|
|
7
23
|
|
|
8
24
|
import { defineTool } from './types.js';
|
|
@@ -36,44 +52,204 @@ Guidelines:
|
|
|
36
52
|
isReadOnly: () => true,
|
|
37
53
|
async execute(input, ctx) {
|
|
38
54
|
const { query, limit = 5 } = input;
|
|
39
|
-
if (!query
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Fallback: no search provider configured
|
|
70
|
-
return JSON.stringify({
|
|
71
|
-
error: 'No web search provider configured.',
|
|
72
|
-
hint: 'Configure searchApiUrl in ~/.yeaft/config.json or use an LLM provider with built-in search.',
|
|
73
|
-
});
|
|
74
|
-
} catch (err) {
|
|
75
|
-
if (err.name === 'AbortError') return JSON.stringify({ error: 'Search cancelled' });
|
|
76
|
-
return JSON.stringify({ error: `Web search failed: ${err.message}` });
|
|
55
|
+
if (!query || typeof query !== 'string') {
|
|
56
|
+
return JSON.stringify({ error: 'query is required' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const search = ctx?.config?.search || {};
|
|
60
|
+
const signal = ctx?.signal;
|
|
61
|
+
const errors = [];
|
|
62
|
+
|
|
63
|
+
// 1. Tavily — default, fast, structured.
|
|
64
|
+
if (search.tavilyApiKey) {
|
|
65
|
+
const r = await tryTavily(query, limit, search.tavilyApiKey, signal);
|
|
66
|
+
if (r.ok) return JSON.stringify(r.data, null, 2);
|
|
67
|
+
errors.push(`tavily: ${r.error}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Generic JSON endpoint (legacy escape hatch — SearXNG, custom proxy, etc).
|
|
71
|
+
const genericUrl = search.searchApiUrl || ctx?.config?.searchApiUrl;
|
|
72
|
+
if (genericUrl) {
|
|
73
|
+
const r = await tryGenericApi(query, limit, genericUrl, signal);
|
|
74
|
+
if (r.ok) return JSON.stringify(r.data, null, 2);
|
|
75
|
+
errors.push(`searchApiUrl: ${r.error}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. HTML-scrape fallback. Often blocked on cloud IPs; useful for
|
|
79
|
+
// self-hosted / residential setups with no API key.
|
|
80
|
+
if (!search.disableHtmlFallback) {
|
|
81
|
+
const r = await tryHtmlScrape(query, limit, signal);
|
|
82
|
+
if (r.ok) return JSON.stringify(r.data, null, 2);
|
|
83
|
+
errors.push(`html: ${r.error}`);
|
|
77
84
|
}
|
|
85
|
+
|
|
86
|
+
return JSON.stringify({
|
|
87
|
+
error: 'No web search backend succeeded.',
|
|
88
|
+
attempted: errors,
|
|
89
|
+
hint: 'Set search.tavilyApiKey in ~/.yeaft/config.json (free tier: https://tavily.com).',
|
|
90
|
+
});
|
|
78
91
|
},
|
|
79
92
|
});
|
|
93
|
+
|
|
94
|
+
// ─── Backend implementations ────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async function tryTavily(query, limit, apiKey, signal) {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch('https://api.tavily.com/search', {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
signal,
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
api_key: apiKey,
|
|
104
|
+
query,
|
|
105
|
+
max_results: Math.max(1, Math.min(limit, 10)),
|
|
106
|
+
include_answer: true,
|
|
107
|
+
search_depth: 'basic',
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const text = await res.text().catch(() => '');
|
|
112
|
+
return { ok: false, error: `${res.status} ${res.statusText} ${text.slice(0, 200)}` };
|
|
113
|
+
}
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
data: {
|
|
118
|
+
provider: 'tavily',
|
|
119
|
+
query,
|
|
120
|
+
answer: data.answer || null,
|
|
121
|
+
results: (data.results || []).slice(0, limit).map((r) => ({
|
|
122
|
+
title: r.title,
|
|
123
|
+
url: r.url,
|
|
124
|
+
snippet: r.content,
|
|
125
|
+
score: r.score,
|
|
126
|
+
})),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
|
|
131
|
+
return { ok: false, error: err.message || String(err) };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function tryGenericApi(query, limit, urlStr, signal) {
|
|
136
|
+
try {
|
|
137
|
+
const url = new URL(urlStr);
|
|
138
|
+
url.searchParams.set('q', query);
|
|
139
|
+
url.searchParams.set('limit', String(limit));
|
|
140
|
+
const res = await fetch(url.toString(), {
|
|
141
|
+
signal,
|
|
142
|
+
headers: { 'User-Agent': 'Yeaft/1.0', Accept: 'application/json' },
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) return { ok: false, error: `${res.status} ${res.statusText}` };
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
return { ok: true, data: { provider: 'generic', query, ...data } };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
|
|
149
|
+
return { ok: false, error: err.message || String(err) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* HTML-scrape fallback. Tries DuckDuckGo's lite HTML endpoint first
|
|
155
|
+
* (smaller markup, but more aggressive bot detection on cloud IPs),
|
|
156
|
+
* then Bing. We intentionally keep the regex-based parsers minimal —
|
|
157
|
+
* they break less than full DOM selectors when sites tweak markup.
|
|
158
|
+
*/
|
|
159
|
+
async function tryHtmlScrape(query, limit, signal) {
|
|
160
|
+
const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
161
|
+
const headers = { 'User-Agent': ua, 'Accept-Language': 'en-US,en;q=0.9' };
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const ddg = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, { signal, headers });
|
|
165
|
+
if (ddg.ok) {
|
|
166
|
+
const html = await ddg.text();
|
|
167
|
+
const results = parseDdgHtml(html, limit);
|
|
168
|
+
if (results.length) return { ok: true, data: { provider: 'duckduckgo-html', query, results } };
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const bing = await fetch(`https://www.bing.com/search?q=${encodeURIComponent(query)}`, { signal, headers });
|
|
176
|
+
if (bing.ok) {
|
|
177
|
+
const html = await bing.text();
|
|
178
|
+
const results = parseBingHtml(html, limit);
|
|
179
|
+
if (results.length) return { ok: true, data: { provider: 'bing-html', query, results } };
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { ok: false, error: 'all HTML scrape backends returned 0 results (likely bot-blocked)' };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Parse DDG lite HTML. Each result is wrapped in
|
|
190
|
+
* <a class="result__a" href="…">title</a>
|
|
191
|
+
* <a class="result__snippet">snippet</a>
|
|
192
|
+
* Hash classes are not used here, so plain regex is fine.
|
|
193
|
+
*/
|
|
194
|
+
function parseDdgHtml(html, limit) {
|
|
195
|
+
const results = [];
|
|
196
|
+
const linkRe = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
197
|
+
const snippetRe = /<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
|
198
|
+
const links = [...html.matchAll(linkRe)];
|
|
199
|
+
const snippets = [...html.matchAll(snippetRe)];
|
|
200
|
+
for (let i = 0; i < links.length && results.length < limit; i++) {
|
|
201
|
+
const url = decodeDdgUrl(links[i][1]);
|
|
202
|
+
const title = stripTags(links[i][2]).trim();
|
|
203
|
+
const snippet = snippets[i] ? stripTags(snippets[i][1]).trim() : '';
|
|
204
|
+
if (url && title) results.push({ title, url, snippet });
|
|
205
|
+
}
|
|
206
|
+
return results;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* DDG often wraps outbound URLs in `/l/?uddg=…` redirects. Unwrap.
|
|
211
|
+
*/
|
|
212
|
+
function decodeDdgUrl(href) {
|
|
213
|
+
try {
|
|
214
|
+
if (href.startsWith('//')) href = 'https:' + href;
|
|
215
|
+
const u = new URL(href, 'https://duckduckgo.com');
|
|
216
|
+
const target = u.searchParams.get('uddg');
|
|
217
|
+
return target ? decodeURIComponent(target) : u.toString();
|
|
218
|
+
} catch {
|
|
219
|
+
return href;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Parse Bing search HTML. Result blocks: <li class="b_algo"> with
|
|
225
|
+
* <h2><a href="…">title</a></h2> and <p>snippet</p>. The class names
|
|
226
|
+
* have been stable for years; if Bing rotates them this will fail
|
|
227
|
+
* gracefully (no results extracted) and we'll surface the error upstream.
|
|
228
|
+
*/
|
|
229
|
+
function parseBingHtml(html, limit) {
|
|
230
|
+
const results = [];
|
|
231
|
+
const blockRe = /<li[^>]+class="[^"]*\bb_algo\b[^"]*"[^>]*>([\s\S]*?)<\/li>/g;
|
|
232
|
+
for (const m of html.matchAll(blockRe)) {
|
|
233
|
+
if (results.length >= limit) break;
|
|
234
|
+
const block = m[1];
|
|
235
|
+
const linkM = block.match(/<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/);
|
|
236
|
+
if (!linkM) continue;
|
|
237
|
+
const url = linkM[1];
|
|
238
|
+
const title = stripTags(linkM[2]).trim();
|
|
239
|
+
const pM = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
|
|
240
|
+
const snippet = pM ? stripTags(pM[1]).trim() : '';
|
|
241
|
+
if (url && title) results.push({ title, url, snippet });
|
|
242
|
+
}
|
|
243
|
+
return results;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function stripTags(s) {
|
|
247
|
+
return s
|
|
248
|
+
.replace(/<[^>]+>/g, '')
|
|
249
|
+
.replace(/&/g, '&')
|
|
250
|
+
.replace(/</g, '<')
|
|
251
|
+
.replace(/>/g, '>')
|
|
252
|
+
.replace(/"/g, '"')
|
|
253
|
+
.replace(/'/g, "'")
|
|
254
|
+
.replace(/ /g, ' ');
|
|
255
|
+
}
|