/**
* Free, no-API-key web search via DuckDuckGo's HTML "lite" endpoint
* (free-claude-code port). Used as a LAST-RESORT fallback search provider
* (`duckduckgo-free`, `fallbackOnly`) when no credentialed search provider is
* configured — see open-sse/config/searchRegistry.ts.
*
* Best-effort HTML scraping: the lite endpoint's markup can drift, so the parser
* is tolerant (quote styles, attribute order, `` highlights, entities) and the
* unit test pins the contract against a real captured response. The network call
* goes through `safeOutboundFetch` with the public-only SSRF guard.
*/
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
export interface FreeSearchResult {
title: string;
url: string;
snippet: string;
}
const DUCKDUCKGO_LITE_URL = "https://lite.duckduckgo.com/lite/";
// A browser-like UA — the lite endpoint rejects obvious bot agents.
const DDG_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0 Safari/537.36";
// Real lite shape: `Title` (href usually
// before class; quotes may be single or double) and `
… | `.
// The inner-content captures are HARD-BOUNDED ({0,N}?) and the whole body is truncated
// (MAX_HTML_BYTES) so adversarial/unclosed HTML can't cause catastrophic backtracking
// (ReDoS) — real titles/snippets are short. See CLAUDE.md PII learnings §1.
const ANCHOR_RE = /]*?class=['"][^'"]*result-link[^'"]*['"][^>]*)>([\s\S]{0,512}?)<\/a>/gi;
const HREF_RE = /href=['"]([^'"]+)['"]/i;
const SNIPPET_RE =
/]*?class=['"][^'"]*result-snippet[^'"]*['"][^>]*>([\s\S]{0,2048}?)<\/td>/gi;
// Generous cap for the lite endpoint (~50 KB real) — bounds the regex input size.
const MAX_HTML_BYTES = 256 * 1024;
function decodeEntities(text: string): string {
return (
text
.replace(/ /gi, " ")
.replace(/</gi, "<")
.replace(/>/gi, ">")
.replace(/"/gi, '"')
.replace(/*39;|*27;|'/gi, "'")
// Decode & LAST so an already-escaped entity like "<" survives as the
// literal text "<" instead of being double-unescaped into "<" — CodeQL
// js/double-escaping. When unescaping, & must resolve after every other entity
// it could otherwise re-create.
.replace(/&/gi, "&")
);
}
function stripTags(html: string): string {
// Decode entities FIRST so entity-encoded markup (e.g. "<script>") becomes real
// markup, then remove every tag. Looping to a fixpoint plus dropping a leftover
// unclosed "<…" guarantees the result can't carry " |