src / search.ts
export interface SearchResult {
title: string;
url: string;
snippet: string;
source: string;
published_date: string | null;
}
function hostnameOf(url: string): string {
try {
return new URL(url).hostname;
} catch {
return "";
}
}
async function fetchWithTimeout(url: string, timeoutMs: number, init?: RequestInit) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function searchSearxng(
baseUrl: string,
query: string,
count: number,
timeoutMs: number,
): Promise<SearchResult[]> {
const url = new URL(baseUrl);
url.pathname = "/search";
url.searchParams.set("q", query);
url.searchParams.set("format", "json");
const response = await fetchWithTimeout(url.toString(), timeoutMs);
if (!response.ok) {
throw new Error(`SearXNG returned HTTP ${response.status}`);
}
const data = await response.json();
const results: any[] = data.results ?? [];
return results.slice(0, count).map((result) => ({
title: result.title ?? "",
url: result.url ?? "",
snippet: result.content ?? "",
source: hostnameOf(result.url ?? ""),
published_date: result.publishedDate ?? null,
}));
}
export async function searchBrave(
baseUrl: string,
apiKey: string,
query: string,
count: number,
timeoutMs: number,
): Promise<SearchResult[]> {
const url = new URL(baseUrl);
url.pathname = "/res/v1/web/search";
url.searchParams.set("q", query);
url.searchParams.set("count", String(count));
const response = await fetchWithTimeout(url.toString(), timeoutMs, {
headers: {
Accept: "application/json",
"X-Subscription-Token": apiKey,
},
});
if (!response.ok) {
throw new Error(`Brave Search returned HTTP ${response.status}`);
}
const data = await response.json();
const results: any[] = data.web?.results ?? [];
return results.slice(0, count).map((result) => ({
title: result.title ?? "",
url: result.url ?? "",
snippet: result.description ?? "",
source: hostnameOf(result.url ?? ""),
published_date: result.page_age ?? result.age ?? null,
}));
}
export interface WebSearchOutcome {
provider: "searxng" | "brave";
results: SearchResult[];
warnings: string[];
}
export async function searchWeb(options: {
searxngBaseUrl: string;
braveBaseUrl: string;
braveApiKey: string;
query: string;
count: number;
timeoutMs: number;
}): Promise<WebSearchOutcome> {
const { searxngBaseUrl, braveBaseUrl, braveApiKey, query, count, timeoutMs } = options;
const warnings: string[] = [];
try {
const results = await searchSearxng(searxngBaseUrl, query, count, timeoutMs);
return { provider: "searxng", results, warnings };
} catch (error) {
warnings.push(`SearXNG search failed: ${(error as Error).message}`);
}
if (!braveApiKey) {
throw new Error(
`${warnings.join(" ")} No Brave API key configured, so no fallback is available.`,
);
}
try {
const results = await searchBrave(braveBaseUrl, braveApiKey, query, count, timeoutMs);
return { provider: "brave", results, warnings };
} catch (error) {
warnings.push(`Brave search fallback failed: ${(error as Error).message}`);
throw new Error(warnings.join(" "));
}
}
src / search.ts
export interface SearchResult {
title: string;
url: string;
snippet: string;
source: string;
published_date: string | null;
}
function hostnameOf(url: string): string {
try {
return new URL(url).hostname;
} catch {
return "";
}
}
async function fetchWithTimeout(url: string, timeoutMs: number, init?: RequestInit) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function searchSearxng(
baseUrl: string,
query: string,
count: number,
timeoutMs: number,
): Promise<SearchResult[]> {
const url = new URL(baseUrl);
url.pathname = "/search";
url.searchParams.set("q", query);
url.searchParams.set("format", "json");
const response = await fetchWithTimeout(url.toString(), timeoutMs);
if (!response.ok) {
throw new Error(`SearXNG returned HTTP ${response.status}`);
}
const data = await response.json();
const results: any[] = data.results ?? [];
return results.slice(0, count).map((result) => ({
title: result.title ?? "",
url: result.url ?? "",
snippet: result.content ?? "",
source: hostnameOf(result.url ?? ""),
published_date: result.publishedDate ?? null,
}));
}
export async function searchBrave(
baseUrl: string,
apiKey: string,
query: string,
count: number,
timeoutMs: number,
): Promise<SearchResult[]> {
const url = new URL(baseUrl);
url.pathname = "/res/v1/web/search";
url.searchParams.set("q", query);
url.searchParams.set("count", String(count));
const response = await fetchWithTimeout(url.toString(), timeoutMs, {
headers: {
Accept: "application/json",
"X-Subscription-Token": apiKey,
},
});
if (!response.ok) {
throw new Error(`Brave Search returned HTTP ${response.status}`);
}
const data = await response.json();
const results: any[] = data.web?.results ?? [];
return results.slice(0, count).map((result) => ({
title: result.title ?? "",
url: result.url ?? "",
snippet: result.description ?? "",
source: hostnameOf(result.url ?? ""),
published_date: result.page_age ?? result.age ?? null,
}));
}
export interface WebSearchOutcome {
provider: "searxng" | "brave";
results: SearchResult[];
warnings: string[];
}
export async function searchWeb(options: {
searxngBaseUrl: string;
braveBaseUrl: string;
braveApiKey: string;
query: string;
count: number;
timeoutMs: number;
}): Promise<WebSearchOutcome> {
const { searxngBaseUrl, braveBaseUrl, braveApiKey, query, count, timeoutMs } = options;
const warnings: string[] = [];
try {
const results = await searchSearxng(searxngBaseUrl, query, count, timeoutMs);
return { provider: "searxng", results, warnings };
} catch (error) {
warnings.push(`SearXNG search failed: ${(error as Error).message}`);
}
if (!braveApiKey) {
throw new Error(
`${warnings.join(" ")} No Brave API key configured, so no fallback is available.`,
);
}
try {
const results = await searchBrave(braveBaseUrl, braveApiKey, query, count, timeoutMs);
return { provider: "brave", results, warnings };
} catch (error) {
warnings.push(`Brave search fallback failed: ${(error as Error).message}`);
throw new Error(warnings.join(" "));
}
}