/** * DuckDuckGo search provider — the keyless default, Instant Answer JSON API. * * `https://api.duckduckgo.com/?q=…&format=json&no_html=1&skip_disambig=1` is an * official, keyless JSON endpoint returning a direct answer and related topics. * It is deliberately the fallback for when nothing else is configured. * * Honest limitation: the Instant Answer API is not a general web-results list — * it has no pagination and no "blue links". Many queries return only an answer, * or nothing. Full result lists come from the SearXNG or Brave providers. */ import { stripTagsAndDecode } from "../../html.ts"; import type { HttpRunner } from "../../http.ts"; import type { WebSearchConfig } from "../config.ts"; import { asRecord, asString, collectResults, requestJson } from "../json.ts"; import type { ProviderResult, SearchProvider } from "../provider.ts"; import type { SearchRequest } from "../types.ts"; const IA_ENDPOINT = "https://api.duckduckgo.com/"; /** Build the Instant Answer request URL. */ export function buildDuckDuckGoUrl(query: string): URL { const url = new URL(IA_ENDPOINT); url.searchParams.set("q", query); url.searchParams.set("format", "json"); url.searchParams.set("no_html", "1"); url.searchParams.set("skip_disambig", "1"); return url; } /** Strip tags, decode entities, collapse whitespace, and trim. */ export function sanitizeText(raw: string): string { return stripTagsAndDecode(raw, " ").replace(/\s+/g, " ").trim(); } /** * Split an Instant Answer `Text` value into a title and a snippet. Related * topics arrive as HTML (`Titledescription`), so the anchor text is the * title and whatever follows is the snippet. */ export function splitTitleAndSnippet(raw: string): { title: string; snippet: string } { const anchor = /]*>([\s\S]*?)<\/a>/i.exec(raw); if (anchor) { const title = sanitizeText(anchor[1] ?? ""); const snippet = sanitizeText(raw.slice(anchor.index + anchor[0].length)); return { title: title || snippet, snippet }; } const text = sanitizeText(raw); return { title: text, snippet: "" }; } /** Turn an Instant Answer response into an answer plus de-duplicated results. */ export function parseDuckDuckGo(json: unknown, maxResults: number): ProviderResult { const data = asRecord(json); const answer = [asString(data.Answer), asString(data.AbstractText), asString(data.Definition)].find(Boolean) ?? ""; // `Results` first, then flattened `RelatedTopics`, preserving provider order. const entries: unknown[] = Array.isArray(data.Results) ? [...data.Results] : []; const walk = (entry: unknown) => { const record = asRecord(entry); if (Array.isArray(record.Topics)) { for (const nested of record.Topics) walk(nested); } else { entries.push(entry); } }; if (Array.isArray(data.RelatedTopics)) for (const topic of data.RelatedTopics) walk(topic); const results = collectResults(entries, maxResults, (record) => { const { title, snippet } = splitTitleAndSnippet(asString(record.Text)); return { title, url: asString(record.FirstURL), snippet }; }); return { answer, results }; } /** Fetch and parse one Instant Answer search. */ export async function searchDuckDuckGo( request: SearchRequest, config: WebSearchConfig, http: HttpRunner, signal: AbortSignal | undefined, _apiKey?: string, ): Promise { const url = buildDuckDuckGoUrl(request.query); const parsed = await requestJson( http, { url: url.toString(), headers: { Accept: "application/json", "User-Agent": config.userAgent }, timeoutMs: config.timeoutMs, maxBytes: config.maxBytes, }, signal, "DuckDuckGo", ); return parseDuckDuckGo(parsed, request.maxResults); } export const duckDuckGoProvider: SearchProvider = { id: "duckduckgo", label: "DuckDuckGo", // Keyless and always available, so it is the provider of last resort. isConfigured: () => true, async search(ctx) { return searchDuckDuckGo(ctx.request, ctx.config, ctx.http, ctx.signal, ctx.apiKey); }, };