export const ANYSEARCH_SEARCH_URL = "https://api.anysearch.com/v1/search"; export const ANYSEARCH_MCP_URL = "https://api.anysearch.com/mcp"; export type Zone = "cn" | "intl"; export type JsonObject = Record; export interface SearchRequest { query: string; maxResults: number; tag?: string; zone?: Zone; language?: string; params?: JsonObject; includeContent: boolean; fresh: boolean; } export interface SearchItem { title: string; url: string; snippet?: string; content?: string; } export interface SearchResponse { items: SearchItem[]; requestId?: string; cached: boolean; } export interface SubDomainsResponse { markdown: string; requestId?: string; cached: boolean; } export class AnySearchError extends Error { constructor(message: string, readonly requestId?: string) { super(message); this.name = "AnySearchError"; } } type FetchLike = typeof fetch; interface CacheEntry { value: T; expiresAt: number; } export interface AnySearchClientOptions { apiKey?: string; fetch?: FetchLike; version?: string; now?: () => number; random?: () => number; sleep?: (ms: number, signal?: AbortSignal) => Promise; } /** A small, process-local client. It intentionally has no configurable base URL. */ export class AnySearchClient { private readonly apiKey?: string; private readonly fetchImpl: FetchLike; private readonly version: string; private readonly now: () => number; private readonly random: () => number; private readonly sleep: (ms: number, signal?: AbortSignal) => Promise; private readonly searchCache = new Map>(); private readonly subDomainsCache = new Map>(); constructor(options: AnySearchClientOptions = {}) { this.apiKey = options.apiKey ?? process.env.ANYSEARCH_API_KEY; this.fetchImpl = options.fetch ?? fetch; this.version = options.version ?? "0.1.0"; this.now = options.now ?? Date.now; this.random = options.random ?? Math.random; this.sleep = options.sleep ?? sleepWithAbort; } async search(request: SearchRequest, signal?: AbortSignal): Promise { const key = stableStringify({ ...request, fresh: undefined }); if (!request.fresh) { const cached = this.getCached(this.searchCache, key); if (cached) return { ...cached, cached: true }; } const payload = await this.postJson( ANYSEARCH_SEARCH_URL, compactObject({ query: request.query, format: "json", max_results: request.maxResults, tag: request.tag, zone: request.zone, language: request.language, sub_domain_params: request.params, include_content: request.includeContent, }), signal, ); const response = parseSearchResponse(payload.body, payload.requestId); this.setCached(this.searchCache, key, response, 60_000, 100); return response; } async getSubDomains(domains: string[], signal?: AbortSignal): Promise { const key = stableStringify([...domains].sort()); const cached = this.getCached(this.subDomainsCache, key); if (cached) return { ...cached, cached: true }; const payload = await this.postJson( ANYSEARCH_MCP_URL, { jsonrpc: "2.0", id: `pi-anysearch-${this.now()}`, method: "tools/call", params: { name: "get_sub_domains", arguments: { domains } }, }, signal, ); const response = parseSubDomainsResponse(payload.body, payload.requestId); this.setCached(this.subDomainsCache, key, response, 10 * 60_000, 100); return response; } private getCached(cache: Map>, key: string): T | undefined { const entry = cache.get(key); if (!entry) return undefined; if (entry.expiresAt <= this.now()) { cache.delete(key); return undefined; } cache.delete(key); cache.set(key, entry); return entry.value; } private setCached(cache: Map>, key: string, value: T, ttl: number, max: number): void { cache.delete(key); cache.set(key, { value, expiresAt: this.now() + ttl }); while (cache.size > max) cache.delete(cache.keys().next().value as string); } private async postJson(url: string, body: JsonObject, signal?: AbortSignal): Promise<{ body: unknown; requestId?: string }> { let lastError: unknown; for (let attempt = 0; attempt < 2; attempt += 1) { if (signal?.aborted) throw abortError(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 15_000); const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; try { const response = await this.fetchImpl(url, { method: "POST", signal: combined, headers: { "content-type": "application/json", "X-Anysearch-Client": `pi-anysearch/${this.version}`, ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}), }, body: JSON.stringify(body), }); const parsed = await readJson(response); const requestId = requestIdOf(response.headers, parsed); if (response.ok) return { body: parsed, requestId }; const error = httpError(response.status, parsed, requestId); if (!retryableStatus(response.status) || attempt === 1) throw error; lastError = error; } catch (error) { if (signal?.aborted) throw abortError(); if (error instanceof AnySearchError && !isRetryableError(error)) throw error; if (attempt === 1) throw normalizeNetworkError(error); lastError = error; } finally { clearTimeout(timer); } await this.sleep(100 + Math.floor(this.random() * 150), signal); } throw normalizeNetworkError(lastError); } } function compactObject(value: Record): JsonObject { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); } async function readJson(response: Response): Promise { const text = await response.text(); if (!text) return {}; try { return JSON.parse(text) as unknown; } catch { return { message: "The AnySearch response was not valid JSON." }; } } function parseSearchResponse(payload: unknown, requestId?: string): SearchResponse { const root = asObject(payload); if (root.code !== 0) throw businessError(root, requestId); const data = asObject(root.data); const candidates = data.results ?? data.items ?? data.data; if (!Array.isArray(candidates)) { throw new AnySearchError(withRequestId("AnySearch returned an invalid success response (expected data.results array).", requestId), requestId); } return { items: candidates.map((item, index) => parseItem(item, index)).filter((item): item is SearchItem => item !== undefined), requestId, cached: false, }; } function parseItem(value: unknown, index: number): SearchItem | undefined { const item = asObject(value); const url = stringOf(item.url ?? item.link ?? item.source_url); if (!url) return undefined; return { title: stringOf(item.title ?? item.name) ?? `Result ${index + 1}`, url, snippet: stringOf(item.snippet ?? item.description ?? item.summary), content: stringOf(item.content ?? item.text), }; } function parseSubDomainsResponse(payload: unknown, requestId?: string): SubDomainsResponse { const root = asObject(payload); if (root.error) { const error = asObject(root.error); throw new AnySearchError(withRequestId(`AnySearch get_sub_domains failed: ${stringOf(error.message) ?? "unknown RPC error"}`, requestId), requestId); } const result = asObject(root.result); const content = result.content; if (!Array.isArray(content)) { throw new AnySearchError(withRequestId("AnySearch returned an invalid get_sub_domains response.", requestId), requestId); } const markdown = content .map((part) => asObject(part)) .filter((part) => part.type === "text") .map((part) => stringOf(part.text) ?? "") .join("\n") .trim(); if (!markdown) throw new AnySearchError(withRequestId("AnySearch returned no sub-domain capability table.", requestId), requestId); return { markdown, requestId, cached: false }; } function businessError(payload: JsonObject, requestId?: string): AnySearchError { const message = redactSensitive(stringOf(payload.message ?? payload.msg ?? payload.error) ?? "unknown service error"); return new AnySearchError(withRequestId(`AnySearch rejected the request: ${message}`, requestId), requestId); } function httpError(status: number, payload: unknown, requestId?: string): AnySearchError { const hint: Record = { 400: "Check the search arguments and any sub_domain_params.", 401: "Set a valid ANYSEARCH_API_KEY, or retry without one for anonymous access.", 402: "Check the AnySearch account balance or plan.", 403: "Check that the API key is allowed to use this resource.", 429: "Slow down and retry later.", }; const message = redactSensitive(stringOf(asObject(payload).message ?? asObject(payload).error) ?? "request failed"); return new AnySearchError(withRequestId(`AnySearch HTTP ${status}: ${message}${hint[status] ? ` ${hint[status]}` : ""}`, requestId), requestId); } function retryableStatus(status: number): boolean { return status === 500 || status === 503 || status === 504; } function isRetryableError(error: AnySearchError): boolean { return /HTTP (500|503|504):/.test(error.message); } function normalizeNetworkError(error: unknown): AnySearchError { if (error instanceof AnySearchError) return error; if (error instanceof Error && error.name === "AbortError") return new AnySearchError("AnySearch request timed out or was aborted."); return new AnySearchError(`AnySearch network error: ${safeErrorMessage(error)}`); } function requestIdOf(headers: Headers, payload: unknown): string | undefined { return headers.get("x-request-id") ?? headers.get("request-id") ?? stringOf(asObject(payload).request_id); } function asObject(value: unknown): JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; } function stringOf(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function withRequestId(message: string, requestId?: string): string { return requestId ? `${message} (request_id: ${requestId})` : message; } function safeErrorMessage(error: unknown): string { return error instanceof Error ? redactSensitive(error.message) : "request failed"; } function redactSensitive(value: string): string { return value .replace(/Bearer\s+\S+/gi, "Bearer [redacted]") .replace(/\bas_(?:sk_)?[A-Za-z0-9_-]{6,}\b/g, "[redacted]"); } function abortError(): DOMException { return new DOMException("The AnySearch request was aborted.", "AbortError"); } async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise { await new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(abortError()); }, { once: true }); }); } function stableStringify(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value as JsonObject).sort().map((key) => `${JSON.stringify(key)}:${stableStringify((value as JsonObject)[key])}`).join(",")}}`; } return JSON.stringify(value); }