Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 58s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 2m46s
Deploy documentation / deploy (push) Has been skipped
E2E (real DeepSeek API) / e2e (push) Failing after 1m18s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m18s
Landlock Run / Matrix (push) Successful in 13s
Release (vendor) / Pack npm tarballs (push) Failing after 3m43s
Release (dsh) / Pack npm tarballs (push) Failing after 1m53s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m51s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
167 lines
7.0 KiB
TypeScript
167 lines
7.0 KiB
TypeScript
/**
|
|
* `SearXngSearchProvider`: a `WebSearchProvider` backed by a SearXNG instance's
|
|
* JSON API (`GET /search?format=json`). It maps against the flat `results[]`,
|
|
* keeps entries even without a snippet (URL and title remain useful), and
|
|
* omits `content` because SearXNG returns no generated answer. The provider
|
|
* carries no credentials, so a request carries no `Authorization` header.
|
|
* @module @deepseek-ai/dsh-web-search-searxng/provider
|
|
*/
|
|
|
|
import { WebError } from '@deepseek-ai/dsh-web'
|
|
import type {
|
|
WebSearchProvider,
|
|
WebSearchRequest,
|
|
WebSearchResult,
|
|
WebSearchSource,
|
|
} from '@deepseek-ai/dsh-web'
|
|
import type { SearXngError, SearXngResult, SearXngSearchResponse } from './types.ts'
|
|
|
|
/** Stable id this provider registers under. */
|
|
export const SEARXNG_PROVIDER_ID = 'searxng'
|
|
|
|
/** Default language: let SearXNG decide per user preferences. */
|
|
export const SEARXNG_DEFAULT_LANGUAGE = 'auto'
|
|
|
|
/** `time_range` sentinel meaning "no recency filter". */
|
|
export const SEARXNG_NO_TIME_RANGE = 'none'
|
|
|
|
/** Valid `time_range` values SearXNG accepts. */
|
|
export const SEARXNG_TIME_RANGES = ['day', 'week', 'month', 'year'] as const
|
|
|
|
/** One of SearXNG's `time_range` values, passed through to the endpoint. */
|
|
export type SearXngTimeRange = (typeof SEARXNG_TIME_RANGES)[number]
|
|
|
|
/** Attribution header sent on every request. Bump with the package version. */
|
|
const USER_AGENT = 'deepseek-harness/0.0.1'
|
|
|
|
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
|
export interface SearXngSearchProviderOptions {
|
|
/** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */
|
|
baseURL: string
|
|
/** SearXNG `language` to request. `auto` lets the instance decide. */
|
|
language?: string
|
|
/** SearXNG `time_range` recency filter. */
|
|
timeRange?: SearXngTimeRange
|
|
}
|
|
|
|
/** A snapshot or a per-search resolver (settings live-reload hands the latter). */
|
|
export type SearXngSearchProviderSource = SearXngSearchProviderOptions | (() => SearXngSearchProviderOptions)
|
|
|
|
/**
|
|
* Map one SearXNG result to a normalized source. The URL is always carried; a
|
|
* blank title or snippet is omitted rather than emitted as empty.
|
|
*
|
|
* @param result - one entry of SearXNG's `results[]`.
|
|
* @returns the normalized source.
|
|
*/
|
|
export function mapSearXngResult(result: SearXngResult): WebSearchSource {
|
|
return {
|
|
url: result.url,
|
|
...result.title != null && result.title.trim().length > 0 ? { title: result.title } : {},
|
|
...result.content != null && result.content.trim().length > 0 ? { snippet: result.content } : {},
|
|
...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map a SearXNG response envelope to a normalized search result.
|
|
*
|
|
* @param response - the parsed `format=json` response body.
|
|
* @returns the normalized result; `content` is omitted (no generated answer).
|
|
*/
|
|
export function mapSearXngResponse(response: SearXngSearchResponse): WebSearchResult {
|
|
// SearXNG cannot bound per-request result count (page size is instance
|
|
// configuration), so the request carries no count and the web service owns
|
|
// the final `maxResults` truncation; this provider reports `truncated: false`.
|
|
return { sources: (response.results ?? []).map(mapSearXngResult), truncated: false }
|
|
}
|
|
|
|
/** The SearXNG-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
|
export class SearXngSearchProvider implements WebSearchProvider {
|
|
readonly id = SEARXNG_PROVIDER_ID
|
|
|
|
constructor(source: SearXngSearchProviderSource) {
|
|
this.source = source
|
|
}
|
|
|
|
private readonly source: SearXngSearchProviderSource
|
|
|
|
/** Read the current options: a snapshot stays fixed, a resolver reads live settings. */
|
|
private resolveOptions(): SearXngSearchProviderOptions {
|
|
return typeof this.source === 'function' ? this.source() : this.source
|
|
}
|
|
|
|
available(): boolean {
|
|
const options = this.resolveOptions()
|
|
return isValidBaseUrl(options.baseURL)
|
|
&& (options.language === undefined || options.language.length > 0)
|
|
&& (options.timeRange === undefined || SEARXNG_TIME_RANGES.includes(options.timeRange))
|
|
}
|
|
|
|
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
|
const options = this.resolveOptions()
|
|
const base = trimTrailingSlashes(options.baseURL)
|
|
const params = new URLSearchParams({ q: request.query, format: 'json' })
|
|
if (options.language !== undefined) params.set('language', options.language)
|
|
if (options.timeRange !== undefined) params.set('time_range', options.timeRange)
|
|
|
|
let response: Response
|
|
try {
|
|
response = await fetch(`${base}/search?${params}`, {
|
|
method: 'GET',
|
|
redirect: 'error',
|
|
headers: {
|
|
'accept': 'application/json',
|
|
'user-agent': USER_AGENT,
|
|
},
|
|
...signal !== undefined ? { signal } : {},
|
|
})
|
|
} catch (error: unknown) {
|
|
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
|
|
throw new WebError(`SearXNG search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const status = response.status
|
|
let message = `SearXNG API error (HTTP ${status})`
|
|
try {
|
|
const parsed = await response.json() as SearXngError
|
|
const detail = parsed.error ?? parsed.message ?? parsed.content
|
|
if (detail !== undefined && detail.length > 0) message = detail
|
|
} catch (error: unknown) {
|
|
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
|
|
// into a generic HTTP-error message — cancellation is not a provider
|
|
// error (the seam's cancellation contract).
|
|
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
|
|
// Otherwise: the HTTP status is already captured in `message` above; a
|
|
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
|
|
// cost a richer provider message, never the real error.
|
|
}
|
|
throw new WebError(message, 'WEB_PROVIDER_ERROR')
|
|
}
|
|
|
|
try {
|
|
const payload = await response.json() as SearXngSearchResponse
|
|
return mapSearXngResponse(payload)
|
|
} catch (error: unknown) {
|
|
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
|
|
throw new WebError(`SearXNG returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
|
}
|
|
}
|
|
}
|
|
|
|
/** True when `baseURL` parses as an absolute URL (a cheap local config check). */
|
|
function isValidBaseUrl(baseURL: string): boolean {
|
|
return URL.canParse(baseURL)
|
|
}
|
|
|
|
/** Strip a trailing slash so `${base}/search` never doubles a separator. */
|
|
function trimTrailingSlashes(baseURL: string): string {
|
|
return baseURL.replace(/\/+$/, '')
|
|
}
|
|
|
|
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof DOMException && error.name === 'AbortError'
|
|
}
|