← back to Trademarks Copyright
src/lib/websearch.ts
140 lines
/**
* Multi-engine web search with graceful fallback.
* Tries DDG HTML → DDG Lite → Brave Search HTML and returns the first engine that yields results.
* All three are free, keyless, and public-facing; we rotate when one rate-limits.
*/
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
const COMMON_HEADERS = {
"user-agent": UA,
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "en-US,en;q=0.9",
};
export type Snippet = { title: string; url: string; body: string };
export type SearchResult = {
engine: "ddg" | "ddg_lite" | "brave" | null;
snippets: Snippet[];
error?: string;
};
const strip = (s: string) =>
s
.replace(/<[^>]+>/g, "")
.replace(/&/g, "&")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, " ")
.replace(/\s+/g, " ")
.trim();
async function tryDdg(q: string): Promise<Snippet[]> {
const form = new URLSearchParams();
form.set("q", q);
const r = await fetch("https://html.duckduckgo.com/html/", {
method: "POST",
headers: {
...COMMON_HEADERS,
"content-type": "application/x-www-form-urlencoded",
referer: "https://html.duckduckgo.com/",
},
body: form.toString(),
signal: AbortSignal.timeout(15000),
});
const body = await r.text();
if (r.status === 202 || /\banomaly\b/i.test(body)) throw new Error("ddg rate-limited");
if (!r.ok) throw new Error(`ddg HTTP ${r.status}`);
const titles = [...body.matchAll(/<a[^>]*class="result__a"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => strip(m[1]));
const urls = [...body.matchAll(/class="result__url"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => strip(m[1]));
const snips = [...body.matchAll(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => strip(m[1]));
const out: Snippet[] = [];
for (let i = 0; i < titles.length && i < 10; i++) {
if (!titles[i]) continue;
out.push({ title: titles[i], url: urls[i] ?? "", body: snips[i] ?? "" });
}
if (!out.length) throw new Error("ddg returned zero parseable results");
return out;
}
async function tryDdgLite(q: string): Promise<Snippet[]> {
const r = await fetch(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(q)}`, {
headers: COMMON_HEADERS,
signal: AbortSignal.timeout(15000),
});
const body = await r.text();
if (r.status === 202 || /\banomaly\b/i.test(body)) throw new Error("ddg_lite rate-limited");
if (!r.ok) throw new Error(`ddg_lite HTTP ${r.status}`);
const rowRe = /<tr[^>]*>[\s\S]*?<\/tr>/g;
const rows = body.match(rowRe) ?? [];
const out: Snippet[] = [];
// DDG Lite alternates: row with result-link (title), row with result-snippet (body), row with link-text (url).
let cur: Partial<Snippet> = {};
for (const row of rows) {
const titleMatch = /<a[^>]*class=['"]result-link['"][^>]*>([\s\S]*?)<\/a>/.exec(row);
const snipMatch = /class=['"]result-snippet['"][^>]*>([\s\S]*?)<\/td>/.exec(row);
const urlMatch = /class=['"]link-text['"][^>]*>([\s\S]*?)<\/span>/.exec(row);
if (titleMatch) {
if (cur.title) out.push({ title: cur.title!, url: cur.url ?? "", body: cur.body ?? "" });
cur = { title: strip(titleMatch[1]) };
} else if (snipMatch && cur.title !== undefined) {
cur.body = strip(snipMatch[1]);
} else if (urlMatch && cur.title !== undefined) {
cur.url = strip(urlMatch[1]);
}
if (out.length >= 10) break;
}
if (cur.title) out.push({ title: cur.title, url: cur.url ?? "", body: cur.body ?? "" });
if (!out.length) throw new Error("ddg_lite returned zero parseable results");
return out;
}
async function tryBrave(q: string): Promise<Snippet[]> {
const r = await fetch(`https://search.brave.com/search?q=${encodeURIComponent(q)}`, {
headers: COMMON_HEADERS,
signal: AbortSignal.timeout(15000),
});
const body = await r.text();
if (r.status === 429) throw new Error("brave rate-limited");
if (!r.ok) throw new Error(`brave HTTP ${r.status}`);
// Brave renders server-side via Svelte. Each result has:
// title="TITLE TEXT" inside a snippet-title element, and a following snippet-description.
const titleMatches = [...body.matchAll(/class="[^"]*snippet-title[^"]*"[^>]*title="([^"]+)"/g)].map((m) => strip(m[1]));
const descMatches = [...body.matchAll(/class="[^"]*snippet-description[^"]*"[^>]*>([\s\S]{0,500}?)<\/div>/g)].map((m) => strip(m[1]));
const urlMatches = [...body.matchAll(/<cite[^>]*class="[^"]*snippet-url[^"]*"[^>]*>([\s\S]{0,200}?)<\/cite>/g)].map((m) => strip(m[1]));
const out: Snippet[] = [];
for (let i = 0; i < titleMatches.length && i < 10; i++) {
out.push({ title: titleMatches[i], url: urlMatches[i] ?? "", body: descMatches[i] ?? "" });
}
if (!out.length) throw new Error("brave returned zero parseable results");
return out;
}
/**
* Rotates starting engine across calls so we don't always hammer DDG first.
* Persists a tiny round-robin counter in-process.
*/
let rrCounter = 0;
export async function webSearch(query: string): Promise<SearchResult> {
const engines: { name: SearchResult["engine"]; run: () => Promise<Snippet[]> }[] = [
{ name: "ddg", run: () => tryDdg(query) },
{ name: "ddg_lite", run: () => tryDdgLite(query) },
{ name: "brave", run: () => tryBrave(query) },
];
// Round-robin start.
const start = rrCounter++ % engines.length;
const ordered = [...engines.slice(start), ...engines.slice(0, start)];
const errors: string[] = [];
for (const e of ordered) {
try {
const snippets = await e.run();
return { engine: e.name, snippets };
} catch (err) {
errors.push(`${e.name}: ${(err as Error).message}`);
}
}
return { engine: null, snippets: [], error: errors.join(" | ") };
}