chore: initial commit
pi extension exposing a single search tool. Providers: Kagi (headless Firefox against kagi.com session-token endpoint) and SearXNG (JSON API). Config lives at ~/.pi/pi-search/config.json with env overrides.
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.direnv
|
||||||
|
_scratch
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# evan/pi-search
|
||||||
|
|
||||||
|
Web search extension for [pi coding agent](https://github.com/mariozechner/pi-coding-agent). Registers a single `search` tool. Choose your provider via config.
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
| Provider | How it works | Requires |
|
||||||
|
| --------- | --------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||||
|
| `kagi` | Drives a headless Firefox session against `kagi.com/search?token=…&q=…` and scrapes results. | Kagi session token, `firefox`, `geckodriver` |
|
||||||
|
| `searxng` | Calls a SearXNG instance's `/search?format=json` endpoint. | A SearXNG base URL with JSON format enabled |
|
||||||
|
|
||||||
|
The Kagi driver is shared across calls for the lifetime of the pi process, so you only pay browser startup once per session.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Drop a JSON file at `~/.pi/pi-search/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "searxng",
|
||||||
|
"kagi": {
|
||||||
|
"token": "<your kagi session token>"
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"baseUrl": "https://search.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Env Var Overrides
|
||||||
|
|
||||||
|
| Variable | Overrides |
|
||||||
|
| ------------------------- | -------------------- |
|
||||||
|
| `PI_SEARCH_PROVIDER` | `provider` |
|
||||||
|
| `KAGI_TOKEN` | `kagi.token` |
|
||||||
|
| `PI_SEARCH_SEARXNG_URL` | `searxng.baseUrl` |
|
||||||
|
|
||||||
|
### Getting A Kagi Session Token
|
||||||
|
|
||||||
|
Open `kagi.com`, sign in, then go to **Settings → Session Link**. Copy the `token=` value from the link. Treat it like a password — it grants full account access.
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
| Name | Args | Returns |
|
||||||
|
| -------- | ------------------- | ------------------------------------------------------ |
|
||||||
|
| `search` | `query: string` | Markdown list of `## [title](url)\n> description` items |
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.pi/agent/extensions/pi-search
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
`pi` picks up the extension via the `pi.extensions` entry in `package.json`.
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776734388,
|
||||||
|
"narHash": "sha256-vl3dkhlE5gzsItuHoEMVe+DlonsK+0836LIRDnm6MXQ=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
31
flake.nix
Normal file
31
flake.nix
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
description = "pi-search extension development environment";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self
|
||||||
|
, nixpkgs
|
||||||
|
, flake-utils
|
||||||
|
,
|
||||||
|
}:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
oxlint
|
||||||
|
nodejs_22
|
||||||
|
firefox
|
||||||
|
geckodriver
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
57
index.ts
Normal file
57
index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Pi-Search Extension - Registers a single `search` tool. Provider (kagi or
|
||||||
|
// searxng) is chosen via ~/.pi/pi-search/config.json or PI_SEARCH_PROVIDER.
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { ConfigError, loadConfig, resolveSettings } from "./src/config.ts";
|
||||||
|
import { searchKagi } from "./src/providers/kagi.ts";
|
||||||
|
import { searchSearxng } from "./src/providers/searxng.ts";
|
||||||
|
import { SearchError, type SearchResult } from "./src/types.ts";
|
||||||
|
|
||||||
|
function formatResults(results: SearchResult[]): string {
|
||||||
|
if (results.length === 0) return "(no results)";
|
||||||
|
return results
|
||||||
|
.map((r) => `## [${r.title}](${r.url})\n> ${r.description}`)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch(query: string): Promise<SearchResult[]> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const settings = resolveSettings(config);
|
||||||
|
switch (settings.provider) {
|
||||||
|
case "kagi":
|
||||||
|
return searchKagi({ query, token: settings.kagiToken ?? "" });
|
||||||
|
case "searxng":
|
||||||
|
return searchSearxng({ query, baseUrl: settings.searxngBaseUrl ?? "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "search",
|
||||||
|
label: "Web Search",
|
||||||
|
description:
|
||||||
|
"Search the web. Returns a markdown list of titles, URLs, and snippets.",
|
||||||
|
promptSnippet: "Search the web for the given query",
|
||||||
|
parameters: Type.Object({
|
||||||
|
query: Type.String({ description: "Search query text" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
try {
|
||||||
|
const results = await runSearch(params.query);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatResults(results) }],
|
||||||
|
details: { raw: results },
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ConfigError || err instanceof SearchError) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Search error: ${err.message}` }],
|
||||||
|
isError: true,
|
||||||
|
details: { raw: [] as SearchResult[] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
3978
package-lock.json
generated
Normal file
3978
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@evan/pi-search",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Web search tool for pi: Kagi (session token via headless Firefox) or SearXNG (JSON API).",
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "oxlint . --ignore-pattern=.direnv/** --ignore-pattern=node_modules/**"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"selenium-webdriver": "^4.43.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mariozechner/pi-coding-agent": "^0.72.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/selenium-webdriver": "^4.35.5",
|
||||||
|
"oxlint": "^1.62.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typebox": "^1.1.37",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/config.ts
Normal file
99
src/config.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export type Provider = "kagi" | "searxng";
|
||||||
|
|
||||||
|
export interface PiSearchConfig {
|
||||||
|
provider?: Provider;
|
||||||
|
kagi?: {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
searxng?: {
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConfigError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ConfigError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Config Path - Lives under ~/.pi/ alongside other pi state so the
|
||||||
|
// extension is self-contained with the rest of the user's pi setup.
|
||||||
|
export function defaultConfigPath(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string {
|
||||||
|
return join(env.HOME ?? homedir(), ".pi", "pi-search", "config.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateString(value: unknown, name: string): void {
|
||||||
|
if (value !== undefined && typeof value !== "string") {
|
||||||
|
throw new ConfigError(`${name} must be a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfig(raw: unknown): asserts raw is PiSearchConfig {
|
||||||
|
if (!isObject(raw)) {
|
||||||
|
throw new ConfigError("config must be a JSON object");
|
||||||
|
}
|
||||||
|
validateString(raw.provider, "provider");
|
||||||
|
if (raw.provider && !["kagi", "searxng"].includes(raw.provider as string)) {
|
||||||
|
throw new ConfigError(
|
||||||
|
`provider must be "kagi" or "searxng", got: ${raw.provider}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (raw.kagi !== undefined) {
|
||||||
|
if (!isObject(raw.kagi)) throw new ConfigError("kagi must be an object");
|
||||||
|
validateString(raw.kagi.token, "kagi.token");
|
||||||
|
}
|
||||||
|
if (raw.searxng !== undefined) {
|
||||||
|
if (!isObject(raw.searxng))
|
||||||
|
throw new ConfigError("searxng must be an object");
|
||||||
|
validateString(raw.searxng.baseUrl, "searxng.baseUrl");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(path: string = defaultConfigPath()): PiSearchConfig {
|
||||||
|
if (!existsSync(path)) return {};
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new ConfigError(`failed to read ${path}: ${msg}`);
|
||||||
|
}
|
||||||
|
validateConfig(parsed);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved Settings - Merges env vars over file config. Env wins so users
|
||||||
|
// can override per-shell without editing the config file.
|
||||||
|
export interface ResolvedSettings {
|
||||||
|
provider: Provider;
|
||||||
|
kagiToken?: string;
|
||||||
|
searxngBaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSettings(
|
||||||
|
config: PiSearchConfig,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): ResolvedSettings {
|
||||||
|
const provider = (env.PI_SEARCH_PROVIDER as Provider) ?? config.provider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new ConfigError(
|
||||||
|
"no provider configured. Set provider in ~/.pi/pi-search/config.json or PI_SEARCH_PROVIDER env.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
kagiToken: env.KAGI_TOKEN ?? config.kagi?.token,
|
||||||
|
searxngBaseUrl: env.PI_SEARCH_SEARXNG_URL ?? config.searxng?.baseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
66
src/driver.ts
Normal file
66
src/driver.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { Builder, WebDriver } from "selenium-webdriver";
|
||||||
|
import firefox from "selenium-webdriver/firefox.js";
|
||||||
|
|
||||||
|
export { type WebDriver };
|
||||||
|
|
||||||
|
// Find Geckodriver - Resolved off PATH so Nix and system installs both work.
|
||||||
|
// Bypasses selenium-manager which is x86-64 only and breaks on aarch64.
|
||||||
|
function findGeckodriver(): string {
|
||||||
|
try {
|
||||||
|
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"geckodriver not found on $PATH. Install it (e.g. via Nix or your package manager).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDriver(): Promise<WebDriver> {
|
||||||
|
const options = new firefox.Options();
|
||||||
|
options.addArguments("--headless");
|
||||||
|
const service = new firefox.ServiceBuilder(findGeckodriver());
|
||||||
|
return new Builder()
|
||||||
|
.forBrowser("firefox")
|
||||||
|
.setFirefoxOptions(options)
|
||||||
|
.setFirefoxService(service)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared Driver - Reused across search calls so we pay geckodriver/Firefox
|
||||||
|
// startup once per pi process instead of per query. Quit on exit.
|
||||||
|
let sharedDriver: Promise<WebDriver> | undefined;
|
||||||
|
let exitHookInstalled = false;
|
||||||
|
|
||||||
|
export function getSharedDriver(): Promise<WebDriver> {
|
||||||
|
if (!sharedDriver) {
|
||||||
|
sharedDriver = createDriver();
|
||||||
|
if (!exitHookInstalled) {
|
||||||
|
exitHookInstalled = true;
|
||||||
|
const cleanup = () => {
|
||||||
|
const d = sharedDriver;
|
||||||
|
sharedDriver = undefined;
|
||||||
|
if (d) void d.then((drv) => drv.quit().catch(() => {}));
|
||||||
|
};
|
||||||
|
process.once("exit", cleanup);
|
||||||
|
process.once("SIGINT", cleanup);
|
||||||
|
process.once("SIGTERM", cleanup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sharedDriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset Shared Driver - Drops the cache so the next call re-spawns. Used
|
||||||
|
// when a driver becomes unhealthy (session crashed, etc.).
|
||||||
|
export async function resetSharedDriver(): Promise<void> {
|
||||||
|
const d = sharedDriver;
|
||||||
|
sharedDriver = undefined;
|
||||||
|
if (d) {
|
||||||
|
try {
|
||||||
|
const drv = await d;
|
||||||
|
await drv.quit();
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/providers/kagi.ts
Normal file
65
src/providers/kagi.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getSharedDriver, resetSharedDriver } from "../driver.ts";
|
||||||
|
import { SearchError, type SearchResult } from "../types.ts";
|
||||||
|
|
||||||
|
// Kagi Results Extractor - Runs in the page; selects each result's title
|
||||||
|
// container and pulls the link + snippet. Mirrors glimpse's selector set.
|
||||||
|
const KAGI_EXTRACT_SCRIPT = `return Array.from(document.querySelectorAll("div > .__sri-title"))
|
||||||
|
.map((i) => i.parentElement)
|
||||||
|
.map((i) => ({
|
||||||
|
title: i.children[0].querySelector("a").innerText,
|
||||||
|
url: i.children[0].querySelector("a").getAttribute("href"),
|
||||||
|
description: i.querySelector(".__sri-body")?.innerText ?? "",
|
||||||
|
}));`;
|
||||||
|
|
||||||
|
function buildKagiUrl(query: string, token: string): string {
|
||||||
|
return `https://kagi.com/search?token=${encodeURIComponent(token)}&q=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KagiSearchOptions {
|
||||||
|
query: string;
|
||||||
|
token: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
intervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchKagi({
|
||||||
|
query,
|
||||||
|
token,
|
||||||
|
timeoutMs = 8000,
|
||||||
|
intervalMs = 200,
|
||||||
|
}: KagiSearchOptions): Promise<SearchResult[]> {
|
||||||
|
if (!token) {
|
||||||
|
throw new SearchError(
|
||||||
|
"KAGI_TOKEN_REQUIRED",
|
||||||
|
"Kagi search requires a session token. Set kagi.token in config or KAGI_TOKEN env.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry Once On Session Failure - Selenium sessions can die between
|
||||||
|
// requests (e.g. browser crashed). One retry with a fresh driver hides
|
||||||
|
// transient flakiness without masking real problems.
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const driver = await getSharedDriver();
|
||||||
|
await driver.get(buildKagiUrl(query, token));
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let results: SearchResult[] = [];
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const raw = await driver.executeScript(KAGI_EXTRACT_SCRIPT);
|
||||||
|
results = Array.isArray(raw) ? (raw as SearchResult[]) : [];
|
||||||
|
if (results.length > 0) break;
|
||||||
|
await new Promise((r) => setTimeout(r, intervalMs));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === 0) {
|
||||||
|
await resetSharedDriver();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new SearchError("KAGI_SEARCH_FAILED", `Kagi search failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
75
src/providers/searxng.ts
Normal file
75
src/providers/searxng.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { SearchError, type SearchResult } from "../types.ts";
|
||||||
|
|
||||||
|
export interface SearxngSearchOptions {
|
||||||
|
query: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearxngResponse {
|
||||||
|
results?: Array<{
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
content?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSearxng({
|
||||||
|
query,
|
||||||
|
baseUrl,
|
||||||
|
timeoutMs = 8000,
|
||||||
|
}: SearxngSearchOptions): Promise<SearchResult[]> {
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new SearchError(
|
||||||
|
"SEARXNG_URL_REQUIRED",
|
||||||
|
"SearXNG search requires a base URL. Set searxng.baseUrl in config or PI_SEARCH_SEARXNG_URL env.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL("/search", baseUrl);
|
||||||
|
url.searchParams.set("q", query);
|
||||||
|
url.searchParams.set("format", "json");
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new SearchError("SEARXNG_REQUEST_FAILED", `SearXNG request failed: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new SearchError(
|
||||||
|
"SEARXNG_BAD_STATUS",
|
||||||
|
`SearXNG returned ${response.status} ${response.statusText}. Ensure the instance has JSON format enabled.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: SearxngResponse;
|
||||||
|
try {
|
||||||
|
body = (await response.json()) as SearxngResponse;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new SearchError(
|
||||||
|
"SEARXNG_INVALID_JSON",
|
||||||
|
`SearXNG returned non-JSON response: ${msg}. Ensure the instance has JSON format enabled.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = body.results ?? [];
|
||||||
|
return items
|
||||||
|
.filter((r) => r.url && r.title)
|
||||||
|
.map((r) => ({
|
||||||
|
title: r.title ?? "",
|
||||||
|
url: r.url ?? "",
|
||||||
|
description: r.content ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
14
src/types.ts
Normal file
14
src/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchError extends Error {
|
||||||
|
code: string;
|
||||||
|
constructor(code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SearchError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user