import { parseHTML } from "linkedom"; import { decodeEntities } from "./html.js"; import { getJson, getText } from "./handlers/handler.js"; export interface SearchResult { title: string; url: string; snippet?: string; source: string; } export type SearchSource = "ddg" | "wikipedia" | "hn" | "context7"; /** Exported separately so the parser is testable offline. */ export function parseDdgLite(html: string): SearchResult[] { const { document } = parseHTML(html); const links = Array.from(document.querySelectorAll("a")).filter((a: any) => { const href = a.getAttribute("href") ?? ""; return href.includes("uddg=") || /^https?:\/\//.test(href); }); const snippets = Array.from(document.querySelectorAll("td.result-snippet")).map((td: any) => (td.textContent ?? "").trim(), ); const results: SearchResult[] = []; for (const a of links as any[]) { let href: string = a.getAttribute("href") ?? ""; const m = href.match(/uddg=([^&]+)/); if (m) href = decodeURIComponent(m[1]); if (!/^https?:\/\//.test(href)) continue; if (/duckduckgo\.com/.test(href)) continue; // ads / internal const title = decodeEntities((a.textContent ?? "").trim()); if (!title) continue; results.push({ title, url: href, snippet: snippets[results.length], source: "ddg" }); if (results.length >= 8) break; } return results; } async function searchDdg(query: string, signal?: AbortSignal): Promise { const { text } = await getText( `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`, signal, ); return parseDdgLite(text); } async function searchWikipedia(query: string, signal?: AbortSignal): Promise { const d = await getJson( `https://en.wikipedia.org/w/api.php?action=query&list=search&srlimit=8&format=json&formatversion=2&srsearch=${encodeURIComponent(query)}`, signal, ); return (d.query?.search ?? []).map((s: any) => ({ title: s.title, url: `https://en.wikipedia.org/wiki/${encodeURIComponent(s.title.replace(/ /g, "_"))}`, snippet: decodeEntities(String(s.snippet ?? "").replace(/<[^>]+>/g, "")), source: "wikipedia", })); } async function searchHn(query: string, signal?: AbortSignal): Promise { const d = await getJson( `https://hn.algolia.com/api/v1/search?hitsPerPage=8&query=${encodeURIComponent(query)}`, signal, ); return (d.hits ?? []) .filter((h: any) => h.title) .map((h: any) => ({ title: h.title, url: h.url || `https://news.ycombinator.com/item?id=${h.objectID}`, snippet: `${h.points ?? 0} points, ${h.num_comments ?? 0} comments | https://news.ycombinator.com/item?id=${h.objectID}`, source: "hn", })); } /** Exported separately so the mapping is testable offline. */ export function parseContext7(payload: unknown): SearchResult[] { const rows = (payload as { results?: unknown[] })?.results ?? []; const out: SearchResult[] = []; for (const row of rows as Array>) { // An id is the doc path; without snippets there is nothing to fetch. if (typeof row?.id !== "string" || !(row.totalSnippets > 0)) continue; out.push({ title: String(row.title ?? row.id), // The plaintext API, not the human page: the site itself renders through javascript, while this returns docs the default handler stores as-is. url: `https://context7.com/api/v1${row.id}?type=txt`, snippet: [row.description, `${row.totalSnippets} snippets`, row.trustScore ? `trust ${row.trustScore}` : ""] .filter(Boolean) .join(" | "), source: "context7", }); if (out.length >= 8) break; } return out; } /** * Library documentation by name. * Context7 indexes versioned docs for thousands of libraries and answers without an API key, which keeps magpi's no-key rule intact. * Narrower than the others, so the model should ask for it by name when it wants API docs rather than pages about a library. */ async function searchContext7(query: string, signal?: AbortSignal): Promise { return parseContext7( await getJson(`https://context7.com/api/v1/search?query=${encodeURIComponent(query)}`, signal), ); } const SOURCES: Record Promise> = { ddg: searchDdg, wikipedia: searchWikipedia, hn: searchHn, context7: searchContext7, }; export interface SearchOutcome { results: SearchResult[]; errors: string[]; } /** * auto: ddg first (broadest), then wikipedia, then hn, then context7; stop at the first source that returns anything. * All are rate-limited free endpoints; failures are expected and reported, not fatal. * * context7 sits last because it only knows libraries, so on a general query it would answer confidently and wrongly. * It earns the last slot by being the steadiest of the four: when the other three are down it still replies. * Ask for it by name to search library docs directly. */ export async function webSearch( query: string, source: SearchSource | "auto", signal?: AbortSignal, onSource?: (source: SearchSource) => void, ): Promise { const order: SearchSource[] = source === "auto" ? ["ddg", "wikipedia", "hn", "context7"] : [source]; const errors: string[] = []; for (const s of order) { onSource?.(s); try { const results = await SOURCES[s](query, signal); if (results.length > 0) return { results, errors }; errors.push(`${s}: no results`); } catch (e) { errors.push(`${s}: ${e instanceof Error ? e.message : String(e)}`); } } return { results: [], errors }; } export function formatResults(results: SearchResult[]): string { return results .map((r, i) => { const lines = [`${i + 1}. ${r.title} [${r.source}]`, ` ${r.url}`]; if (r.snippet) lines.push(` ${r.snippet}`); return lines.join("\n"); }) .join("\n"); }