- new tinyFishSearch module: GET api.search.tinyfish.ai with X-API-Key, maps top-3 to SearchResult shape, never throws (all failure modes -> []) - wikipediaSearch: on wiki miss, one tinyfish attempt; hits cached 6h under the same key so fallback latency is paid once - termGlossary: on summary miss, top tinyfish hit becomes the definition (persisted permanently like wiki defs); miss keeps 1h sentinel - config: TINYFISH_API_KEY (empty = fallback disabled), ENABLED, BASE_URL, TIMEOUT_MS, LOCATION, LANGUAGE knobs - tests: 6 coverage for disabled/mapping/non-OK/network/bad-json API key NOT committed — set TINYFISH_API_KEY in BWS gmw secrets. Verified: typecheck + lint clean, 216/216 tests pass, live probe 'gubernur jawa barat' returned 3 mapped results
112 lines
3.1 KiB
TypeScript
112 lines
3.1 KiB
TypeScript
import { createChildLogger } from "@/shared/logger/index";
|
|
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
|
import { config } from "../../shared/config/config.js";
|
|
import type { SearchResult } from "./wikipediaClient.js";
|
|
|
|
const log = createChildLogger("tinyfish-search");
|
|
|
|
/**
|
|
* Raw TinyFish search result (subset of fields we use).
|
|
* Endpoint: GET {base}?query=..&location=..&language=.. with X-API-Key.
|
|
*/
|
|
interface TinyFishResult {
|
|
title?: string;
|
|
url?: string;
|
|
snippet?: string;
|
|
date?: string;
|
|
}
|
|
|
|
interface TinyFishResponse {
|
|
query?: string;
|
|
results?: TinyFishResult[];
|
|
}
|
|
|
|
/**
|
|
* Fallback detection: true only when the provider is reachable AND returns a
|
|
* usable answer. Everything else (missing key, disabled, timeout, non-200,
|
|
* malformed body, zero results) returns false/null and the caller falls
|
|
* through to empty enrichment — never throws.
|
|
*/
|
|
export function isTinyFishEnabled(): boolean {
|
|
return (
|
|
config.TINYFISH_SEARCH_ENABLED === true &&
|
|
config.TINYFISH_API_KEY.length > 0
|
|
);
|
|
}
|
|
|
|
function mapResult(r: TinyFishResult): SearchResult | null {
|
|
const title = (r.title ?? "").trim();
|
|
const url = (r.url ?? "").trim();
|
|
if (!title || !url) return null;
|
|
return {
|
|
title,
|
|
url,
|
|
snippet: (r.snippet ?? "").replace(/\s+/g, " ").trim().slice(0, 500),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Live TinyFish web search for one query. Returns [] on any failure
|
|
* (disabled, no key, network error, non-OK, bad JSON, no results).
|
|
* Callers treat [] the same as a Wikipedia miss — enrichment is skipped.
|
|
*/
|
|
export async function tinyFishSearchLive(
|
|
query: string,
|
|
timeoutMs: number = config.TINYFISH_SEARCH_TIMEOUT_MS,
|
|
): Promise<SearchResult[]> {
|
|
const q = query.trim();
|
|
if (!q) return [];
|
|
if (!isTinyFishEnabled()) return [];
|
|
|
|
const params = new URLSearchParams({
|
|
query: q,
|
|
location: config.TINYFISH_SEARCH_LOCATION,
|
|
language: config.TINYFISH_SEARCH_LANGUAGE,
|
|
});
|
|
const { controller, clear } = createAbortControllerWithTimeout(timeoutMs);
|
|
try {
|
|
const res = await fetch(
|
|
`${config.TINYFISH_SEARCH_BASE_URL}?${params.toString()}`,
|
|
{
|
|
signal: controller.signal,
|
|
headers: {
|
|
Accept: "application/json",
|
|
"X-API-Key": config.TINYFISH_API_KEY,
|
|
},
|
|
},
|
|
);
|
|
if (!res.ok) {
|
|
log.warn(
|
|
{ status: res.status, query: q },
|
|
"TinyFish search failed (non-OK) — skipping fallback",
|
|
);
|
|
return [];
|
|
}
|
|
let data: TinyFishResponse;
|
|
try {
|
|
data = (await res.json()) as TinyFishResponse;
|
|
} catch {
|
|
log.warn({ query: q }, "TinyFish search returned invalid JSON");
|
|
return [];
|
|
}
|
|
const results = Array.isArray(data.results) ? data.results : [];
|
|
const mapped = results
|
|
.slice(0, 3)
|
|
.map(mapResult)
|
|
.filter((r): r is SearchResult => r !== null);
|
|
log.debug(
|
|
{ query: q, resultCount: mapped.length },
|
|
"TinyFish search fallback OK",
|
|
);
|
|
return mapped;
|
|
} catch (err) {
|
|
log.warn(
|
|
{ error: err instanceof Error ? err.message : String(err), query: q },
|
|
"TinyFish search error — skipping fallback",
|
|
);
|
|
return [];
|
|
} finally {
|
|
clear();
|
|
}
|
|
}
|