searxng, icecast, photos: full nox modules (ADR 0044/0046)
searxng: a search tool — tools-only, a stateless search has nothing to observe. icecast: status tool, emits stream.started/.stopped by diffing live mountpoints. photos: status/albums/recent tools, emits item.added (immich-shaped API, coded defensively since the stub ships a placeholder image — flagged in the code). Typecheck; manifests parse.
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
// Icecast's API client — icecast's own code, living in the module (novox/hq ADR 0044). Icecast is an
|
||||||
|
// audio streaming server: sources push mountpoints in, listeners pull them out. Its `/status-json.xsl`
|
||||||
|
// endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and
|
||||||
|
// the basis for both the status tool and the stream started/stopped events.
|
||||||
|
|
||||||
|
export interface IcecastMount {
|
||||||
|
/** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */
|
||||||
|
mount: string;
|
||||||
|
listeners: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
streamStart?: string;
|
||||||
|
bitrate?: number;
|
||||||
|
serverType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IcecastStatus {
|
||||||
|
mounts: IcecastMount[];
|
||||||
|
totalListeners: number;
|
||||||
|
mountCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The raw shape of one <source> in status-json.xsl. `source` is absent with no mounts, a lone object
|
||||||
|
// with one, and an array with several — normalised below.
|
||||||
|
interface RawSource {
|
||||||
|
listenurl?: string;
|
||||||
|
listeners?: number;
|
||||||
|
server_name?: string;
|
||||||
|
server_description?: string;
|
||||||
|
stream_start_iso8601?: string;
|
||||||
|
stream_start?: string;
|
||||||
|
bitrate?: number;
|
||||||
|
server_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IcecastClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
private readonly authHeader?: string;
|
||||||
|
|
||||||
|
constructor(url: string, adminUser?: string, adminPassword?: string) {
|
||||||
|
this.baseUrl = url.replace(/\/$/, "");
|
||||||
|
// status-json.xsl is public on most instances; basic auth is used only where admin locked it down.
|
||||||
|
if (adminUser && adminPassword) {
|
||||||
|
this.authHeader = "Basic " + Buffer.from(`${adminUser}:${adminPassword}`).toString("base64");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient {
|
||||||
|
const url = env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`;
|
||||||
|
return new IcecastClient(url, env.MESH_ICECAST_ADMIN_USER, env.MESH_ICECAST_ADMIN_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(): Promise<IcecastStatus> {
|
||||||
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
|
if (this.authHeader) headers.Authorization = this.authHeader;
|
||||||
|
const res = await fetch(`${this.baseUrl}/status-json.xsl`, { headers });
|
||||||
|
if (!res.ok) throw new Error(`Icecast status: ${res.status} ${await res.text()}`);
|
||||||
|
const data = (await res.json()) as { icestats?: { source?: RawSource | RawSource[] } };
|
||||||
|
|
||||||
|
const raw = data.icestats?.source;
|
||||||
|
const sources: RawSource[] = raw == null ? [] : Array.isArray(raw) ? raw : [raw];
|
||||||
|
const mounts = sources.map((s) => ({
|
||||||
|
mount: this.mountFromUrl(s.listenurl),
|
||||||
|
listeners: s.listeners ?? 0,
|
||||||
|
name: s.server_name,
|
||||||
|
description: s.server_description,
|
||||||
|
streamStart: s.stream_start_iso8601 ?? s.stream_start,
|
||||||
|
bitrate: s.bitrate,
|
||||||
|
serverType: s.server_type,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
mounts,
|
||||||
|
totalListeners: mounts.reduce((n, m) => n + m.listeners, 0),
|
||||||
|
mountCount: mounts.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icecast names the mount only inside the listen URL's path; pull it back out (fall back to raw). */
|
||||||
|
private mountFromUrl(listenurl?: string): string {
|
||||||
|
if (!listenurl) return "unknown";
|
||||||
|
try {
|
||||||
|
return new URL(listenurl).pathname;
|
||||||
|
} catch {
|
||||||
|
return listenurl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// icecast's events. The tool runtime imports this once the broker is bound. It watches the streaming
|
||||||
|
// server and announces when a mountpoint goes live or drops.
|
||||||
|
//
|
||||||
|
// Emits (novox/hq ADR 0046/0047):
|
||||||
|
// module.icecast.stream.started / .stopped — a mountpoint appeared or disappeared
|
||||||
|
//
|
||||||
|
// A mountpoint exists only while a source is connected, so the set of mounts diffed over time is
|
||||||
|
// exactly the set of live streams. Primed silently on the first look, so streams already running when
|
||||||
|
// this starts are not announced as freshly begun. Polling is unhurried — a stream a few seconds late
|
||||||
|
// is still the event, and hammering the status endpoint buys immediacy nobody asked for.
|
||||||
|
|
||||||
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
|
import { IcecastClient, type IcecastMount } from "./client.js";
|
||||||
|
|
||||||
|
const icecast = IcecastClient.fromEnv();
|
||||||
|
|
||||||
|
const live = new Map<string, IcecastMount>();
|
||||||
|
let primed = false;
|
||||||
|
|
||||||
|
async function pollMounts(): Promise<void> {
|
||||||
|
const { mounts } = await icecast.getStatus();
|
||||||
|
const now = new Map(mounts.map((m) => [m.mount, m]));
|
||||||
|
if (primed) {
|
||||||
|
for (const [mount, m] of now) {
|
||||||
|
if (!live.has(mount)) {
|
||||||
|
await emit("module.icecast.stream.started", {
|
||||||
|
mount,
|
||||||
|
name: m.name,
|
||||||
|
description: m.description,
|
||||||
|
bitrate: m.bitrate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [mount, m] of live) {
|
||||||
|
if (!now.has(mount)) {
|
||||||
|
await emit("module.icecast.stream.stopped", { mount, name: m.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
live.clear();
|
||||||
|
for (const [mount, m] of now) live.set(mount, m);
|
||||||
|
primed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (): void => void pollMounts().catch((err) => console.error(`[icecast] ${err}`));
|
||||||
|
setInterval(run, 15_000);
|
||||||
|
run();
|
||||||
|
|
||||||
|
console.log("[icecast] watching mountpoints for streams starting and stopping");
|
||||||
@@ -4,10 +4,15 @@
|
|||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"module.icecast.stream.started",
|
||||||
|
"module.icecast.stream.stopped"
|
||||||
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"source": "/var/lib/icecast-module/source.secret",
|
"source": "/var/lib/icecast-module/source.secret",
|
||||||
"admin": "/var/lib/icecast-module/admin.secret",
|
"admin": "/var/lib/icecast-module/admin.secret",
|
||||||
"relay": "/var/lib/icecast-module/relay.secret"
|
"relay": "/var/lib/icecast-module/relay.secret",
|
||||||
|
"broker": "/var/lib/icecast-module/broker"
|
||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-icecast",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "icecast — audio streaming server. Its API client, tools and events live here (novox/hq ADR 0044).",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@novox/mesh-sdk": "^0.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// icecast's tools (novox/hq ADR 0044), importing icecast's own client.
|
||||||
|
|
||||||
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { IcecastClient } from "../client.js";
|
||||||
|
|
||||||
|
export function getIcecastTools(icecast: IcecastClient): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "icecast_status",
|
||||||
|
description: "Icecast streaming status: live mountpoints, each with its listener count, plus the total.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const status = await icecast.getStatus();
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
registerModuleTools("icecast", (env) => {
|
||||||
|
try {
|
||||||
|
return getIcecastTools(IcecastClient.fromEnv(env));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// The photo app's API client — photos' own code, living in the module (novox/hq ADR 0044). The
|
||||||
|
// module packages a self-hosted photo library (immich-shaped: a REST API under `/api`, authenticated
|
||||||
|
// by an API key sent as the `x-api-key` header). The client speaks only what the tools and the
|
||||||
|
// item-added event need: server version and statistics, albums, and recent assets.
|
||||||
|
|
||||||
|
export interface PhotosServerInfo {
|
||||||
|
version: string;
|
||||||
|
photos?: number;
|
||||||
|
videos?: number;
|
||||||
|
usageBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotosAlbum {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
assetCount: number;
|
||||||
|
shared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotosAsset {
|
||||||
|
id: string;
|
||||||
|
fileName?: string;
|
||||||
|
type?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PhotosClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
private readonly apiKey: string,
|
||||||
|
) {
|
||||||
|
this.baseUrl = url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build from the module's environment. Unlike an open service, a photo library holds private data:
|
||||||
|
* the API key is required, and without it the module contributes nothing rather than reaching an
|
||||||
|
* unauthenticated endpoint. */
|
||||||
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient {
|
||||||
|
const url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`;
|
||||||
|
const key = env.MESH_PHOTOS_API_KEY;
|
||||||
|
if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY");
|
||||||
|
return new PhotosClient(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"x-api-key": this.apiKey,
|
||||||
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`photos API ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServerInfo(): Promise<PhotosServerInfo> {
|
||||||
|
const version = await this.request<{ major: number; minor: number; patch: number }>("/api/server/version");
|
||||||
|
const info: PhotosServerInfo = { version: `${version.major}.${version.minor}.${version.patch}` };
|
||||||
|
// Statistics needs an admin key; a scoped key still gives version, so treat stats as best-effort.
|
||||||
|
try {
|
||||||
|
const stats = await this.request<{ photos: number; videos: number; usage: number }>("/api/server/statistics");
|
||||||
|
info.photos = stats.photos;
|
||||||
|
info.videos = stats.videos;
|
||||||
|
info.usageBytes = stats.usage;
|
||||||
|
} catch {
|
||||||
|
// leave the counts unset
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbums(): Promise<PhotosAlbum[]> {
|
||||||
|
const albums = await this.request<
|
||||||
|
{ id: string; albumName: string; assetCount: number; shared: boolean }[]
|
||||||
|
>("/api/albums");
|
||||||
|
return albums.map((a) => ({ id: a.id, name: a.albumName, assetCount: a.assetCount, shared: a.shared }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recent assets, newest first — via the metadata search, which is how this API returns a bounded,
|
||||||
|
* ordered slice of the library. The poll that emits item.added builds on this. */
|
||||||
|
async getRecentAssets(limit = 20): Promise<PhotosAsset[]> {
|
||||||
|
const data = await this.request<{
|
||||||
|
assets?: { items?: { id: string; originalFileName?: string; type?: string; fileCreatedAt?: string }[] };
|
||||||
|
}>("/api/search/metadata", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ size: limit, order: "desc" }),
|
||||||
|
});
|
||||||
|
const items = data.assets?.items ?? [];
|
||||||
|
return items.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
fileName: a.originalFileName,
|
||||||
|
type: a.type,
|
||||||
|
createdAt: a.fileCreatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// photos' events. The tool runtime imports this once the broker is bound. It watches the library and
|
||||||
|
// announces newly added assets.
|
||||||
|
//
|
||||||
|
// Emits (novox/hq ADR 0046/0047):
|
||||||
|
// module.photos.item.added — a new asset appeared in the library
|
||||||
|
//
|
||||||
|
// New assets are found by diffing the recent-assets slice by asset id. Primed silently on the first
|
||||||
|
// look, so a restart does not re-announce the whole recent list as freshly added.
|
||||||
|
|
||||||
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
|
import { PhotosClient } from "./client.js";
|
||||||
|
|
||||||
|
const photos = PhotosClient.fromEnv();
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let primed = false;
|
||||||
|
|
||||||
|
async function pollRecent(): Promise<void> {
|
||||||
|
const items = await photos.getRecentAssets(50);
|
||||||
|
for (const asset of items) {
|
||||||
|
if (!seen.has(asset.id)) {
|
||||||
|
if (primed) {
|
||||||
|
await emit("module.photos.item.added", {
|
||||||
|
id: asset.id,
|
||||||
|
fileName: asset.fileName,
|
||||||
|
kind: asset.type,
|
||||||
|
createdAt: asset.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seen.add(asset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (): void => void pollRecent().catch((err) => console.error(`[photos] ${err}`));
|
||||||
|
setInterval(run, 60_000);
|
||||||
|
run();
|
||||||
|
|
||||||
|
console.log("[photos] watching for newly added assets");
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
"secrets": {
|
"secrets": {
|
||||||
"s3-bucket": "/etc/photos/store.secret"
|
"s3-bucket": "/etc/photos/store.secret"
|
||||||
},
|
},
|
||||||
|
"emits": [
|
||||||
|
"module.photos.item.added"
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "/etc/photos/broker"
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "config",
|
"id": "config",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-photos",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "photos — self-hosted photo library. Its API client, tools and events live here (novox/hq ADR 0044).",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@novox/mesh-sdk": "^0.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// photos' tools (novox/hq ADR 0044), importing photos' own client.
|
||||||
|
|
||||||
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { PhotosClient } from "../client.js";
|
||||||
|
|
||||||
|
export function getPhotosTools(photos: PhotosClient): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "photos_status",
|
||||||
|
description: "Photo library status: server version and, where the key allows, photo/video counts and storage used.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const [server, albums] = await Promise.all([photos.getServerInfo(), photos.getAlbums()]);
|
||||||
|
return { server, albumCount: albums.length };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "photos_albums",
|
||||||
|
description: "List the albums in the photo library, each with its asset count and whether it is shared.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const albums = await photos.getAlbums();
|
||||||
|
return { count: albums.length, albums };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "photos_recent",
|
||||||
|
description: "Most recently added assets in the photo library, newest first.",
|
||||||
|
input: { limit: { type: "number", description: "how many assets (default 20)" } },
|
||||||
|
run: async (args) => ({ items: await photos.getRecentAssets(args.limit ? Number(args.limit) : 20) }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tools exist only when an API key is configured; without one, photos contributes none rather
|
||||||
|
// than reaching an unauthenticated endpoint or failing the whole runtime.
|
||||||
|
registerModuleTools("photos", (env) => {
|
||||||
|
try {
|
||||||
|
return getPhotosTools(PhotosClient.fromEnv(env));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// SearXNG's API client — searxng's own code, living in the module (novox/hq ADR 0044). 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.
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-searxng",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "searxng — privacy-respecting metasearch. Its API client and tools live here (novox/hq ADR 0044).",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@novox/mesh-sdk": "^0.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// searxng's tools (novox/hq ADR 0044), importing searxng's own client. A stateless metasearch: one
|
||||||
|
// query in, merged results out — genuinely useful, and with nothing to observe over time it stays
|
||||||
|
// tools-only (no events, no broker).
|
||||||
|
|
||||||
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { SearxngClient } from "../client.js";
|
||||||
|
|
||||||
|
export function getSearxngTools(searxng: SearxngClient): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "searxng_search",
|
||||||
|
description: "Search the web through SearXNG's privacy-respecting metasearch — merged results from many engines.",
|
||||||
|
input: {
|
||||||
|
query: { type: "string", description: "the search query" },
|
||||||
|
categories: { type: "string", description: "comma-separated categories, e.g. 'general', 'news', 'images'" },
|
||||||
|
language: { type: "string", description: "language code, e.g. 'en' or 'en-US'" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const found = await searxng.search(String(args.query), {
|
||||||
|
categories: args.categories ? String(args.categories) : undefined,
|
||||||
|
language: args.language ? String(args.language) : undefined,
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A URL always resolves (it defaults), so this normally contributes its tool; the try/catch keeps a
|
||||||
|
// misconfiguration from taking the whole runtime down.
|
||||||
|
registerModuleTools("searxng", (env) => {
|
||||||
|
try {
|
||||||
|
return getSearxngTools(SearxngClient.fromEnv(env));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["client.ts", "tools/index.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user