src / searxngClient.ts
import type { SearchHit } from "./types";
export interface SearchOptions {
query: string;
category?: string;
language?: string;
safesearch?: "0" | "1" | "2";
timeRange?: "day" | "week" | "month" | "year";
pageno?: number;
}
export interface SearchResponse {
query: string;
results: SearchHit[];
answers: string[];
infoboxes: Array<{ infobox: string; content?: string; urls?: Array<{ title: string; url: string }> }>;
suggestions: string[];
unresponsive_engines: string[];
}
export class SearxngError extends Error {
constructor(message: string) {
super(message);
this.name = "SearxngError";
}
}
export async function searxngSearch(
instanceUrl: string,
opts: SearchOptions,
timeoutMs: number,
): Promise<SearchResponse> {
if (!instanceUrl) {
throw new SearxngError(
"No SearXNG instance configured. Set the instance URL in the plugin's global configuration.",
);
}
let base: URL;
try {
base = new URL(instanceUrl);
} catch {
throw new SearxngError(`Invalid SearXNG instance URL: "${instanceUrl}".`);
}
const search = new URL("search", base.toString().endsWith("/") ? base : new URL(base.toString() + "/"));
search.searchParams.set("q", opts.query);
search.searchParams.set("format", "json");
if (opts.category) search.searchParams.set("categories", opts.category);
if (opts.language && opts.language !== "auto") search.searchParams.set("language", opts.language);
if (opts.safesearch !== undefined) search.searchParams.set("safesearch", opts.safesearch);
if (opts.timeRange) search.searchParams.set("time_range", opts.timeRange);
if (opts.pageno) search.searchParams.set("pageno", String(opts.pageno));
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(search, {
headers: {
Accept: "application/json",
"User-Agent": "lms-plugin-web-search/1.0 (+https://lmstudio.ai/zexigh/web-search)",
},
signal: ctrl.signal,
});
} catch (e: unknown) {
if (e instanceof Error && e.name === "AbortError") {
throw new SearxngError(`SearXNG instance did not respond within ${timeoutMs} ms.`);
}
throw new SearxngError(`Could not reach SearXNG instance: ${e instanceof Error ? e.message : String(e)}`);
} finally {
clearTimeout(timer);
}
if (!res.ok) {
throw new SearxngError(`SearXNG returned HTTP ${res.status} ${res.statusText}.`);
}
const ctype = res.headers.get("content-type") || "";
if (!ctype.includes("application/json")) {
throw new SearxngError(
"SearXNG did not return JSON. The instance probably has the JSON format disabled in `settings.yml` (search.formats must include `json`).",
);
}
const data = (await res.json()) as Record<string, unknown>;
const rawResults = Array.isArray(data.results) ? (data.results as Record<string, unknown>[]) : [];
const results: SearchHit[] = rawResults.map((r) => ({
title: String(r.title ?? ""),
url: String(r.url ?? ""),
content: String(r.content ?? ""),
engine: typeof r.engine === "string" ? r.engine : undefined,
category: typeof r.category === "string" ? r.category : undefined,
published_date: typeof r.publishedDate === "string" ? r.publishedDate : undefined,
}));
return {
query: String(data.query ?? opts.query),
results,
answers: Array.isArray(data.answers) ? (data.answers as string[]) : [],
infoboxes: Array.isArray(data.infoboxes) ? (data.infoboxes as SearchResponse["infoboxes"]) : [],
suggestions: Array.isArray(data.suggestions) ? (data.suggestions as string[]) : [],
unresponsive_engines: Array.isArray(data.unresponsive_engines)
? (data.unresponsive_engines as unknown[]).map((e) => (Array.isArray(e) ? String(e[0]) : String(e)))
: [],
};
}