87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
// SearXNG's API client — searxng's own code, living in the module (novox/hq ADR 0039). SearXNG is a
|
|
// privacy-respecting metasearch engine: it forwards a query to many upstream engines and returns the
|
|
// merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool
|
|
// useful; the client speaks only that. No credential — the instance is reached inside the mesh.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
export interface SearxResult {
|
|
title: string;
|
|
url: string;
|
|
content?: string;
|
|
engine?: string;
|
|
category?: string;
|
|
score?: number;
|
|
}
|
|
|
|
export interface SearxSearch {
|
|
query: string;
|
|
numberOfResults: number;
|
|
results: SearxResult[];
|
|
suggestions: string[];
|
|
answers: string[];
|
|
}
|
|
|
|
export interface SearxOptions {
|
|
categories?: string;
|
|
language?: string;
|
|
pageno?: number;
|
|
}
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
export class SearxngClient {
|
|
readonly baseUrl: string;
|
|
|
|
constructor(url: string) {
|
|
this.baseUrl = url.replace(/\/$/, "");
|
|
}
|
|
|
|
/** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL
|
|
* is all it takes — defaulting to the container's own listen port. */
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient {
|
|
const cfg = meshConfig(env.MESH_SEARXNG_CONFIG_FILE);
|
|
const url = cfg.url ?? (env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`);
|
|
return new SearxngClient(url);
|
|
}
|
|
|
|
async search(query: string, opts: SearxOptions = {}): Promise<SearxSearch> {
|
|
const params = new URLSearchParams({ q: query, format: "json" });
|
|
if (opts.categories) params.set("categories", opts.categories);
|
|
if (opts.language) params.set("language", opts.language);
|
|
if (opts.pageno) params.set("pageno", String(opts.pageno));
|
|
|
|
const res = await fetch(`${this.baseUrl}/search?${params.toString()}`, {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!res.ok) throw new Error(`SearXNG search: ${res.status} ${await res.text()}`);
|
|
const data = (await res.json()) as {
|
|
results?: SearxResult[];
|
|
suggestions?: string[];
|
|
answers?: string[];
|
|
number_of_results?: number;
|
|
};
|
|
const results = data.results ?? [];
|
|
return {
|
|
query,
|
|
// SearXNG's own count is often 0 even with results; fall back to what we actually got.
|
|
numberOfResults: data.number_of_results || results.length,
|
|
results: results.map((r) => ({
|
|
title: r.title,
|
|
url: r.url,
|
|
content: r.content,
|
|
engine: r.engine,
|
|
category: r.category,
|
|
score: r.score,
|
|
})),
|
|
suggestions: data.suggestions ?? [],
|
|
answers: data.answers ?? [],
|
|
};
|
|
}
|
|
}
|