Keyurjotaniya007 commited on
Commit
2a1fd7b
·
1 Parent(s): 0a36812

web search: use Tavily instead of DuckDuckGo

Browse files
Files changed (1) hide show
  1. src/search.js +64 -205
src/search.js CHANGED
@@ -1,239 +1,98 @@
1
  // ----------------------------------------------------------------------------
2
- // Web search provider (server-side) DuckDuckGo ONLY, no API keys.
3
  //
4
- // This is the Node port of the Python reference (duckduckgo_search + fact
5
- // extraction):
6
- // 1. Query DuckDuckGo: POST https://html.duckduckgo.com/html/
7
- // (fallback: POST https://lite.duckduckgo.com/lite/)
8
- // 2. Parse result links/snippets TOLERANTLY. This is the bug fix: the old
9
- // regex required class="result-link" to appear BEFORE href, with double
10
- // quotes. DDG's real markup is <a rel="nofollow" href="..."
11
- // class='result-link'> (href first, SINGLE quotes), so the old parser
12
- // matched 0 results every time and the app said search "failed".
13
- // 3. Fetch the top pages, split them into sentences, and keep only the
14
- // sentences that score well against the query keywords the model
15
- // receives facts, not huge noisy pages (same scoring as the Python code).
16
- // 4. Emit numbered source-note blocks the model can cite as [1], [2], ...
 
17
  // ----------------------------------------------------------------------------
18
 
19
- import { convert as htmlToText } from 'html-to-text';
20
-
21
- const UA =
22
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
23
 
24
- async function fetchWithTimeout(url, init = {}, timeoutMs = 10000) {
25
  const controller = new AbortController();
26
  const id = setTimeout(() => controller.abort(), timeoutMs);
27
  try {
28
- return await fetch(url, { redirect: 'follow', ...init, signal: controller.signal });
29
  } finally {
30
  clearTimeout(id);
31
  }
32
  }
33
 
34
- // ---- DuckDuckGo search ------------------------------------------------------
 
 
35
 
 
36
  export async function webSearch(query, maxResults = 6) {
37
  const q = String(query || '').trim();
38
  if (!q) return [];
39
-
40
- let results = [];
41
- try {
42
- results = await ddgSearch('https://html.duckduckgo.com/html/', q);
43
- } catch (e) {
44
- console.warn('[search] html endpoint failed:', e.message);
45
- }
46
- if (results.length === 0) {
47
- try {
48
- results = await ddgSearch('https://lite.duckduckgo.com/lite/', q);
49
- } catch (e) {
50
- console.warn('[search] lite endpoint failed:', e.message);
51
- }
52
- }
53
- return results.slice(0, maxResults);
54
- }
55
-
56
- async function ddgSearch(endpoint, query) {
57
- const res = await fetchWithTimeout(endpoint, {
58
- method: 'POST',
59
- headers: {
60
- 'User-Agent': UA,
61
- 'Content-Type': 'application/x-www-form-urlencoded',
62
- Accept: 'text/html,application/xhtml+xml',
63
- 'Accept-Language': 'en-US,en;q=0.9',
64
- },
65
- body: `q=${encodeURIComponent(query)}&kl=wt-wt`,
66
- });
67
- if (!res.ok) throw new Error(`ddg HTTP ${res.status}`);
68
- return parseDdgHtml(await res.text());
69
- }
70
-
71
- // Tolerant parser for BOTH DDG layouts (exported so it can be unit-tested):
72
- // html.duckduckgo.com -> <a class="result__a" href="..."> + class="result__snippet"
73
- // lite.duckduckgo.com -> <a href="..." class='result-link'> + class='result-snippet'
74
- // Attribute order and quote style are NOT assumed.
75
- export function parseDdgHtml(html) {
76
- const snippets = [];
77
- const snipRe =
78
- /class=["'][^"']*(?:result__snippet|result-snippet)[^"']*["'][^>]*>([\s\S]*?)<\/(?:a|td|div|span)>/gi;
79
- let sm;
80
- while ((sm = snipRe.exec(html))) snippets.push(clean(sm[1]));
81
-
82
- const results = [];
83
- const seen = new Set();
84
- const aRe = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
85
- let m;
86
- while ((m = aRe.exec(html))) {
87
- const attrs = m[1];
88
- if (!/class=["'][^"']*(?:result__a|result-link)[^"']*["']/i.test(attrs)) continue;
89
- const hrefMatch = attrs.match(/href=["']([^"']+)["']/i);
90
- if (!hrefMatch) continue;
91
- if (/duckduckgo\.com\/y\.js|ad_provider=|ad_domain=/i.test(hrefMatch[1])) continue; // skip ads
92
- const url = decodeDdgUrl(hrefMatch[1]);
93
- if (!url || seen.has(url)) continue;
94
- seen.add(url);
95
- results.push({ title: clean(m[2]), url, snippet: snippets[results.length] ?? '' });
96
  }
97
- return results;
98
- }
99
-
100
- function decodeDdgUrl(href) {
101
- // result links point at //duckduckgo.com/l/?uddg=<real-url-encoded>&rut=...
102
- const m = href.match(/[?&]uddg=([^&]+)/);
103
- if (m) {
104
- try {
105
- return decodeURIComponent(m[1]);
106
- } catch {
107
- /* fall through */
108
- }
109
- }
110
- if (href.startsWith('//')) return 'https:' + href;
111
- return href.startsWith('http') ? href : '';
112
- }
113
-
114
- function clean(s) {
115
- return String(s)
116
- .replace(/<[^>]+>/g, '')
117
- .replace(/&nbsp;/g, ' ')
118
- .replace(/&#x27;/g, "'")
119
- .replace(/&#39;/g, "'")
120
- .replace(/&quot;/g, '"')
121
- .replace(/&lt;/g, '<')
122
- .replace(/&gt;/g, '>')
123
- .replace(/&amp;/g, '&')
124
- .replace(/\s+/g, ' ')
125
- .trim();
126
- }
127
-
128
- export function domainOf(url) {
129
- return String(url).replace(/^https?:\/\//, '').split('/')[0];
130
- }
131
-
132
- // ---- fact extraction (port of the Python logic) -----------------------------
133
 
134
- const STOP_WORDS = new Set([
135
- 'the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'about',
136
- 'give', 'me', 'tell', 'current', 'latest', 'recent', 'what', 'who', 'is', 'are',
137
- 'their', 'its', 'this', 'that', 'from', 'by', 'as', 'at', 'be', 'was', 'were',
138
- ]);
139
-
140
- function simpleWords(text) {
141
- const words = String(text).toLowerCase().match(/[a-z0-9][a-z0-9\-._]*/g) ?? [];
142
- return words.filter((w) => !STOP_WORDS.has(w) && w.length > 2);
143
- }
144
-
145
- function splitSentences(text) {
146
- return String(text)
147
- .replace(/\s+/g, ' ')
148
- .trim()
149
- .split(/(?<=[.!?])\s+/)
150
- .map((p) => p.trim())
151
- .filter((p) => p.length >= 40 && p.length <= 400);
152
- }
153
-
154
- async function fetchPageText(url, maxChars = 5000) {
155
  try {
156
- const res = await fetchWithTimeout(url, { headers: { 'User-Agent': UA } }, 8000);
157
- const ct = (res.headers.get('content-type') || '').toLowerCase();
158
- if (!ct.includes('text/html') && !ct.includes('application/xhtml')) return '';
159
- const text = htmlToText(await res.text(), {
160
- wordwrap: false,
161
- selectors: [
162
- { selector: 'a', options: { ignoreHref: true } },
163
- { selector: 'img', format: 'skip' },
164
- { selector: 'nav', format: 'skip' },
165
- { selector: 'footer', format: 'skip' },
166
- { selector: 'header', format: 'skip' },
167
- { selector: 'form', format: 'skip' },
168
- ],
169
  });
170
- return text.replace(/\s+/g, ' ').trim().slice(0, maxChars);
171
- } catch {
172
- return '';
173
- }
174
- }
175
-
176
- // Keep only the sentences most related to the query (same scoring as Python).
177
- function extractRelevantFacts(query, title, snippet, pageText, maxSentences = 4) {
178
- const keywords = new Set(simpleWords(query));
179
- const titleWords = new Set(simpleWords(title));
180
-
181
- const candidates = [];
182
- if (snippet) candidates.push(snippet);
183
- candidates.push(...splitSentences(pageText));
184
 
185
- const scored = [];
186
- for (const sent of candidates) {
187
- const lower = sent.toLowerCase();
188
- const words = new Set(simpleWords(sent));
189
- let score = 0;
190
- for (const w of words) if (keywords.has(w)) score += 3; // keyword overlap
191
- for (const w of words) if (titleWords.has(w)) score += 1; // title overlap
192
- if (/\b20\d{2}\b/.test(sent)) score += 2; // dates
193
- if (/\bv?\d+\.\d+(\.\d+)?\b/.test(lower)) score += 3; // versions
194
- if (/\$|billion|million|net worth|released|version|latest|rank|source of wealth/.test(lower))
195
- score += 2;
196
- if (/reuters|bbc|al jazeera|forbes|pypi|github|hugging face|official/.test(lower)) score += 1;
197
- if (score > 0) scored.push({ score, sent });
198
- }
199
- scored.sort((a, b) => b.score - a.score);
200
-
201
- const selected = [];
202
- const seen = new Set();
203
- for (const { sent } of scored) {
204
- const key = sent.slice(0, 120).toLowerCase();
205
- if (!seen.has(key)) {
206
- selected.push(sent);
207
- seen.add(key);
208
  }
209
- if (selected.length >= maxSentences) break;
 
 
 
 
 
 
 
 
 
 
210
  }
211
- return selected;
212
  }
213
 
214
- // Fetch the top pages and turn everything into compact numbered "source notes"
215
- // for the on-device model. NOTE: now takes the query first (needed for scoring).
 
216
  export async function buildSourceNotes(
217
  query,
218
  results,
219
- fetchTopK = 4,
220
- maxFactsPerSource = 4,
221
  maxTotalChars = 9000,
222
  ) {
223
- const blocks = [];
224
- for (let i = 0; i < results.length; i++) {
225
- const r = results[i];
226
- let pageText = '';
227
- if (i < fetchTopK && String(r.url).startsWith('http')) {
228
- pageText = await fetchPageText(r.url);
229
- }
230
- let facts = extractRelevantFacts(query, r.title, r.snippet, pageText, maxFactsPerSource);
231
- if (facts.length === 0 && r.snippet) facts = [r.snippet];
232
-
233
- blocks.push(
234
  `[${i + 1}] Title: ${r.title}\nURL: ${r.url}\nExtracted facts:\n` +
235
- facts.map((f) => `- ${f}`).join('\n'),
236
  );
237
- }
238
  return blocks.join('\n\n').slice(0, maxTotalChars);
239
- }
 
1
  // ----------------------------------------------------------------------------
2
+ // Web search provider — Tavily (https://tavily.com).
3
  //
4
+ // WHY THIS CHANGED: the old version scraped DuckDuckGo directly. DuckDuckGo
5
+ // blocks datacenter IPs (Hugging Face, Render, Fly, etc.), so in production it
6
+ // returned ZERO results ({"sources":[],"notes":""}). Tavily is a search API
7
+ // built for LLMs: it returns clean, relevant content snippets per result, so we
8
+ // no longer scrape or fetch pages ourselves it's faster and reliable.
9
+ //
10
+ // SETUP (one-time):
11
+ // 1. Get a free API key at https://app.tavily.com (1,000 searches/month,
12
+ // no credit card). It looks like: tvly-xxxxxxxxxxxxxxxx
13
+ // 2. Add it to your Hugging Face Space as a SECRET named TAVILY_API_KEY
14
+ // (Space Settings Variables and secrets New secret).
15
+ //
16
+ // The exported interface (webSearch, buildSourceNotes, domainOf) is unchanged,
17
+ // so server.js keeps working without edits.
18
  // ----------------------------------------------------------------------------
19
 
20
+ const TAVILY_API_KEY = process.env.TAVILY_API_KEY || '';
21
+ const TAVILY_URL = 'https://api.tavily.com/search';
 
 
22
 
23
+ async function fetchWithTimeout(url, init = {}, timeoutMs = 15000) {
24
  const controller = new AbortController();
25
  const id = setTimeout(() => controller.abort(), timeoutMs);
26
  try {
27
+ return await fetch(url, { ...init, signal: controller.signal });
28
  } finally {
29
  clearTimeout(id);
30
  }
31
  }
32
 
33
+ export function domainOf(url) {
34
+ return String(url).replace(/^https?:\/\//, '').split('/')[0];
35
+ }
36
 
37
+ // Query Tavily -> [{ title, url, snippet }]
38
  export async function webSearch(query, maxResults = 6) {
39
  const q = String(query || '').trim();
40
  if (!q) return [];
41
+ if (!TAVILY_API_KEY) {
42
+ console.warn('[search] TAVILY_API_KEY is not set — web search disabled.');
43
+ return [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  try {
47
+ const res = await fetchWithTimeout(TAVILY_URL, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ Authorization: `Bearer ${TAVILY_API_KEY}`,
52
+ },
53
+ body: JSON.stringify({
54
+ query: q,
55
+ max_results: Math.min(Math.max(maxResults, 1), 10),
56
+ search_depth: 'basic',
57
+ include_answer: false,
58
+ include_raw_content: false,
59
+ }),
60
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
+ if (!res.ok) {
63
+ const text = await res.text().catch(() => '');
64
+ throw new Error(`tavily HTTP ${res.status} ${text.slice(0, 200)}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  }
66
+
67
+ const data = await res.json();
68
+ const results = Array.isArray(data.results) ? data.results : [];
69
+ return results.map((r) => ({
70
+ title: r.title || domainOf(r.url || ''),
71
+ url: r.url || '',
72
+ snippet: String(r.content || '').trim(),
73
+ }));
74
+ } catch (e) {
75
+ console.warn('[search] tavily failed:', e.message);
76
+ return [];
77
  }
 
78
  }
79
 
80
+ // Turn results into numbered "source notes" the on-device model cites as [1],[2].
81
+ // Tavily already returns the relevant content per result, so there is nothing to
82
+ // fetch — we just format. (Extra args kept for signature compatibility.)
83
  export async function buildSourceNotes(
84
  query,
85
  results,
86
+ _fetchTopK = 4,
87
+ _maxFactsPerSource = 4,
88
  maxTotalChars = 9000,
89
  ) {
90
+ const blocks = (results || []).map((r, i) => {
91
+ const facts = String(r.snippet || '').replace(/\s+/g, ' ').trim();
92
+ return (
 
 
 
 
 
 
 
 
93
  `[${i + 1}] Title: ${r.title}\nURL: ${r.url}\nExtracted facts:\n` +
94
+ `- ${facts || '(no snippet returned)'}`
95
  );
96
+ });
97
  return blocks.join('\n\n').slice(0, maxTotalChars);
98
+ }