2026-06-22 12:41:48 +07:00
|
|
|
import Redis from "ioredis";
|
2026-06-22 10:58:52 +07:00
|
|
|
import { createChildLogger } from "@bete/shared/logger";
|
2026-07-02 00:02:41 +07:00
|
|
|
import { createAbortTimeout } from "./abortHelper.js";
|
2026-06-22 10:58:52 +07:00
|
|
|
|
|
|
|
|
const log = createChildLogger("searxng-search");
|
|
|
|
|
|
|
|
|
|
const SEARXNG_BASE_URL = "https://searxng.imrnes.team";
|
|
|
|
|
const MAX_RESULTS = 3;
|
|
|
|
|
const TIMEOUT_MS = 8000;
|
2026-06-22 12:41:48 +07:00
|
|
|
const CACHE_TTL = 86400; // 24 hours
|
|
|
|
|
const CACHE_PREFIX = "searxng:";
|
2026-07-02 00:02:41 +07:00
|
|
|
/** How often to attempt reconnection when Redis is down (ms). */
|
|
|
|
|
const RECONNECT_INTERVAL_MS = 60_000;
|
2026-06-22 12:41:48 +07:00
|
|
|
|
|
|
|
|
let redis: Redis | null = null;
|
2026-07-02 00:02:41 +07:00
|
|
|
let _initialized = false;
|
|
|
|
|
let _redisUrl = "";
|
|
|
|
|
/** Tracks whether Redis is currently healthy (connected + responding). */
|
|
|
|
|
let _redisHealthy = false;
|
|
|
|
|
let _lastLogAt = 0; // throttle repeated warn logs to once per 60s
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Health tracking
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns true if the SearXNG Redis cache is connected and healthy.
|
|
|
|
|
* Use this for health-check endpoints or status dashboards.
|
|
|
|
|
*/
|
|
|
|
|
export function isSearxngCacheAvailable(): boolean {
|
|
|
|
|
return _redisHealthy;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns a human-readable status string for logging / health endpoints.
|
|
|
|
|
*/
|
|
|
|
|
export function getSearxngCacheStatus(): string {
|
|
|
|
|
if (!_initialized) return "not-initialized";
|
|
|
|
|
if (!redis) return "no-url";
|
|
|
|
|
return _redisHealthy ? "healthy" : "disconnected";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Reconnection helper
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Schedule a one-shot reconnection attempt after RECONNECT_INTERVAL_MS.
|
|
|
|
|
* Only one reconnection timer runs at a time.
|
|
|
|
|
*/
|
|
|
|
|
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
|
|
|
|
function scheduleReconnect(): void {
|
|
|
|
|
if (_reconnectTimer) return; // already scheduled
|
|
|
|
|
_reconnectTimer = setTimeout(async () => {
|
|
|
|
|
_reconnectTimer = null;
|
|
|
|
|
if (!redis || _redisHealthy) return; // nothing to do
|
|
|
|
|
|
|
|
|
|
log.info("SearXNG Redis: attempting reconnection...");
|
|
|
|
|
try {
|
|
|
|
|
// ioredis reconnects automatically if `lazyConnect` is false,
|
|
|
|
|
// but we set it to true, so we need to manually call connect().
|
|
|
|
|
await redis.connect();
|
|
|
|
|
// If we get here, connection succeeded
|
|
|
|
|
_redisHealthy = true;
|
|
|
|
|
log.info("SearXNG Redis: reconnected successfully ✅");
|
|
|
|
|
} catch {
|
|
|
|
|
_redisHealthy = false;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - _lastLogAt > 60_000) {
|
|
|
|
|
log.warn(
|
|
|
|
|
{ nextRetryMs: RECONNECT_INTERVAL_MS },
|
|
|
|
|
"SearXNG Redis: reconnection failed — will retry",
|
|
|
|
|
);
|
|
|
|
|
_lastLogAt = now;
|
|
|
|
|
}
|
|
|
|
|
// Schedule another attempt
|
|
|
|
|
scheduleReconnect();
|
|
|
|
|
}
|
|
|
|
|
}, RECONNECT_INTERVAL_MS);
|
|
|
|
|
_reconnectTimer.unref?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Initialization
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-06-22 12:41:48 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize Redis connection for SearXNG cache.
|
|
|
|
|
* Safe to call multiple times — only creates one connection.
|
2026-07-02 00:02:41 +07:00
|
|
|
* Returns true if Redis cache is available, false if falling back to no-cache.
|
2026-06-22 12:41:48 +07:00
|
|
|
*/
|
2026-07-02 00:02:41 +07:00
|
|
|
export function initSearxngCache(redisUrl: string): boolean {
|
|
|
|
|
if (_initialized) return _redisHealthy;
|
|
|
|
|
_initialized = true;
|
|
|
|
|
_redisUrl = redisUrl;
|
|
|
|
|
|
|
|
|
|
if (!redisUrl) {
|
|
|
|
|
log.warn("No REDIS_URL provided — SearXNG cache disabled");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 12:41:48 +07:00
|
|
|
redis = new Redis(redisUrl, {
|
|
|
|
|
maxRetriesPerRequest: 3,
|
|
|
|
|
retryStrategy(times) {
|
|
|
|
|
const delay = Math.min(times * 200, 2000);
|
|
|
|
|
return delay;
|
|
|
|
|
},
|
|
|
|
|
lazyConnect: true,
|
|
|
|
|
enableReadyCheck: false,
|
|
|
|
|
});
|
2026-07-02 00:02:41 +07:00
|
|
|
|
2026-06-22 12:41:48 +07:00
|
|
|
redis.on("error", (err) => {
|
2026-07-02 00:02:41 +07:00
|
|
|
const wasHealthy = _redisHealthy;
|
|
|
|
|
_redisHealthy = false;
|
|
|
|
|
if (wasHealthy) {
|
|
|
|
|
// State transition: healthy → unhealthy — always log
|
|
|
|
|
log.warn(
|
|
|
|
|
{ error: err.message },
|
|
|
|
|
"SearXNG Redis: connection lost — falling back to no-cache",
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// Already unhealthy — throttle repeated error logs
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - _lastLogAt > 60_000) {
|
|
|
|
|
log.warn(
|
|
|
|
|
{ error: err.message },
|
|
|
|
|
"SearXNG Redis: still disconnected",
|
|
|
|
|
);
|
|
|
|
|
_lastLogAt = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
scheduleReconnect();
|
2026-06-22 12:41:48 +07:00
|
|
|
});
|
2026-07-02 00:02:41 +07:00
|
|
|
|
|
|
|
|
redis.on("ready", () => {
|
|
|
|
|
if (!_redisHealthy) {
|
|
|
|
|
_redisHealthy = true;
|
|
|
|
|
log.info("SearXNG Redis: connected and healthy ✅");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-22 12:41:48 +07:00
|
|
|
redis.connect().catch(() => {
|
2026-07-02 00:02:41 +07:00
|
|
|
_redisHealthy = false;
|
|
|
|
|
log.warn("SearXNG Redis: initial connection failed — running without cache");
|
|
|
|
|
scheduleReconnect();
|
2026-06-22 12:41:48 +07:00
|
|
|
});
|
2026-07-02 00:02:41 +07:00
|
|
|
|
2026-06-22 12:41:48 +07:00
|
|
|
log.info("SearXNG Redis cache initialized");
|
2026-07-02 00:02:41 +07:00
|
|
|
return true;
|
2026-06-22 12:41:48 +07:00
|
|
|
}
|
2026-06-22 10:58:52 +07:00
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Cache operations with visible failure logging
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function cacheGet(key: string): Promise<string | null> {
|
|
|
|
|
if (!redis || !_redisHealthy) return null;
|
|
|
|
|
try {
|
|
|
|
|
const result = await redis.get(key);
|
|
|
|
|
return result;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// Log once per minute to avoid log spam
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - _lastLogAt > 60_000) {
|
|
|
|
|
log.warn(
|
|
|
|
|
{ error: err instanceof Error ? err.message : String(err) },
|
|
|
|
|
"SearXNG Redis: cache read failed",
|
|
|
|
|
);
|
|
|
|
|
_lastLogAt = now;
|
|
|
|
|
}
|
|
|
|
|
_redisHealthy = false;
|
|
|
|
|
scheduleReconnect();
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cacheSet(key: string, value: string, ttlSeconds: number): void {
|
|
|
|
|
if (!redis || !_redisHealthy) return;
|
|
|
|
|
redis.setex(key, ttlSeconds, value).catch((err) => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - _lastLogAt > 60_000) {
|
|
|
|
|
log.warn(
|
|
|
|
|
{ error: err instanceof Error ? err.message : String(err) },
|
|
|
|
|
"SearXNG Redis: cache write failed",
|
|
|
|
|
);
|
|
|
|
|
_lastLogAt = now;
|
|
|
|
|
}
|
|
|
|
|
_redisHealthy = false;
|
|
|
|
|
scheduleReconnect();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Search
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-06-22 10:58:52 +07:00
|
|
|
export interface SearxngResult {
|
|
|
|
|
title: string;
|
|
|
|
|
url: string;
|
|
|
|
|
snippet: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Search SearXNG for a query and return structured results.
|
2026-06-22 12:41:48 +07:00
|
|
|
* Uses Redis cache when available — same query within 24h returns cached results.
|
2026-06-22 10:58:52 +07:00
|
|
|
*/
|
|
|
|
|
export async function searchSearxng(
|
|
|
|
|
query: string,
|
|
|
|
|
category: "general" | "news" | "science" = "general",
|
|
|
|
|
): Promise<SearxngResult[]> {
|
2026-06-22 12:41:48 +07:00
|
|
|
const cacheKey = `${CACHE_PREFIX}${category}:${query.toLowerCase().trim()}`;
|
|
|
|
|
|
|
|
|
|
// Try cache first
|
2026-07-02 00:02:41 +07:00
|
|
|
const cached = await cacheGet(cacheKey);
|
|
|
|
|
if (cached) {
|
|
|
|
|
log.debug({ query, category }, "SearXNG cache HIT");
|
2026-06-22 12:41:48 +07:00
|
|
|
try {
|
2026-07-02 00:02:41 +07:00
|
|
|
return JSON.parse(cached) as SearxngResult[];
|
2026-06-22 12:41:48 +07:00
|
|
|
} catch {
|
2026-07-02 00:02:41 +07:00
|
|
|
// Corrupted cache entry — continue to API
|
2026-06-22 12:41:48 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache miss — hit SearXNG API
|
2026-06-22 10:58:52 +07:00
|
|
|
try {
|
|
|
|
|
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}`;
|
2026-07-02 00:02:41 +07:00
|
|
|
const { signal, cleanup } = createAbortTimeout(TIMEOUT_MS);
|
2026-06-22 10:58:52 +07:00
|
|
|
|
|
|
|
|
const response = await fetch(url, {
|
2026-07-02 00:02:41 +07:00
|
|
|
signal,
|
2026-06-22 10:58:52 +07:00
|
|
|
headers: {
|
|
|
|
|
Accept: "application/json",
|
|
|
|
|
"User-Agent":
|
|
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-07-02 00:02:41 +07:00
|
|
|
cleanup();
|
2026-06-22 10:58:52 +07:00
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
log.warn({ status: response.status, query }, "SearXNG search failed");
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = (await response.json()) as {
|
|
|
|
|
results?: Array<{ title?: string; url?: string; content?: string }>;
|
|
|
|
|
};
|
|
|
|
|
const results = data.results ?? [];
|
2026-06-22 12:41:48 +07:00
|
|
|
const mapped = results.slice(0, MAX_RESULTS).map((r) => ({
|
2026-06-22 10:58:52 +07:00
|
|
|
title: r.title ?? "",
|
|
|
|
|
url: r.url ?? "",
|
|
|
|
|
snippet: (r.content ?? "").slice(0, 500),
|
|
|
|
|
}));
|
2026-06-22 12:41:48 +07:00
|
|
|
|
|
|
|
|
// Store in cache (fire and forget — don't block on write)
|
2026-07-02 00:02:41 +07:00
|
|
|
cacheSet(cacheKey, JSON.stringify(mapped), CACHE_TTL);
|
2026-06-22 12:41:48 +07:00
|
|
|
|
|
|
|
|
log.debug({ query, category, resultCount: mapped.length }, "SearXNG search OK");
|
|
|
|
|
return mapped;
|
2026-06-22 10:58:52 +07:00
|
|
|
} catch (err) {
|
|
|
|
|
log.warn(
|
|
|
|
|
{ error: err instanceof Error ? err.message : String(err), query },
|
|
|
|
|
"SearXNG search error",
|
|
|
|
|
);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Query extraction
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-06-22 10:58:52 +07:00
|
|
|
/**
|
2026-06-22 12:41:48 +07:00
|
|
|
* Extract meaningful search queries from message content.
|
|
|
|
|
* Uses multiple strategies to find terms worth searching.
|
|
|
|
|
* Returns up to 3 clean queries.
|
2026-06-22 10:58:52 +07:00
|
|
|
*/
|
|
|
|
|
export function extractSearchQueries(content: string): string[] {
|
|
|
|
|
const queries = new Set<string>();
|
|
|
|
|
|
2026-06-22 12:41:48 +07:00
|
|
|
// 1. Quoted phrases (explicit user intent)
|
2026-06-22 10:58:52 +07:00
|
|
|
const quotedPhrases = content.match(/"([^"]+)"|'([^']+)'/g);
|
|
|
|
|
if (quotedPhrases) {
|
|
|
|
|
for (const phrase of quotedPhrases) {
|
2026-06-22 12:41:48 +07:00
|
|
|
const clean = phrase.replace(/["']/g, "").trim();
|
2026-06-22 10:58:52 +07:00
|
|
|
if (clean.length >= 3) queries.add(clean);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 12:41:48 +07:00
|
|
|
// 2. "nonton X" pattern — extract the title
|
|
|
|
|
const nontonMatch = content.match(
|
|
|
|
|
/\b(nonton|tonton|rekomen|cari|search|google)\s+(.+?)(?:\s+(?:anime|kartun|film|movie|series|serial))?\s*[!?.]*$/i,
|
|
|
|
|
);
|
|
|
|
|
if (nontonMatch) {
|
|
|
|
|
const title = nontonMatch[2].trim();
|
|
|
|
|
if (title.length >= 2 && title.length <= 80) {
|
|
|
|
|
queries.add(title);
|
2026-06-22 10:58:52 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 12:41:48 +07:00
|
|
|
// 3. "X anime/film" pattern — title before category
|
|
|
|
|
const titleBeforeCategory = content.match(
|
|
|
|
|
/\b(\w[\w\s]{2,40})\s+(?:anime|kartun|film|movie|series|serial)\b/i,
|
|
|
|
|
);
|
|
|
|
|
if (titleBeforeCategory) {
|
|
|
|
|
const title = titleBeforeCategory[1].trim();
|
|
|
|
|
if (title.length >= 3 && !/^(yang|yang|sama|dari|untuk|ini|itu|ada)$/i.test(title)) {
|
|
|
|
|
queries.add(title);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Standalone proper nouns (2+ words, capitalized) that look like titles
|
|
|
|
|
const properNouns = content.match(
|
|
|
|
|
/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4})\b/g,
|
|
|
|
|
);
|
|
|
|
|
if (properNouns) {
|
|
|
|
|
for (const noun of properNouns) {
|
|
|
|
|
// Skip common non-title proper nouns
|
|
|
|
|
const skip = /^(Discord|YouTube|Google|Facebook|Instagram|Twitter|Github|ChatGPT|OpenAI|Claude|Telegram|WhatsApp|TikTok|Netflix|Spotify|Steam|Instagram)$/i;
|
|
|
|
|
if (!skip.test(noun) && noun.length >= 5) {
|
|
|
|
|
queries.add(noun);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. Terms that suggest research intent
|
|
|
|
|
const researchTerms = content.match(
|
|
|
|
|
/\b(apa\s+(?:itu|sih)|what\s+is|siapa\s+itu|who\s+is|arti|meaning|definisi|definition)\s+(.{3,60})/i,
|
|
|
|
|
);
|
|
|
|
|
if (researchTerms) {
|
|
|
|
|
const term = researchTerms[2].trim().replace(/[?!.]+$/, "");
|
|
|
|
|
if (term.length >= 3) queries.add(term);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 10:58:52 +07:00
|
|
|
return Array.from(queries).slice(0, 3);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 00:02:41 +07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Formatting
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-06-22 10:58:52 +07:00
|
|
|
/**
|
|
|
|
|
* Format SearXNG results as XML for LLM context.
|
|
|
|
|
*/
|
|
|
|
|
export function formatSearchResults(results: SearxngResult[]): string {
|
|
|
|
|
if (results.length === 0) return "";
|
|
|
|
|
const lines = results.map(
|
|
|
|
|
(r) =>
|
|
|
|
|
` <result title="${escapeXml(r.title)}">${escapeXml(r.snippet)}</result>`,
|
|
|
|
|
);
|
|
|
|
|
return `<web_search>\n${lines.join("\n")}\n</web_search>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeXml(str: string): string {
|
|
|
|
|
return str
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
.replace(/"/g, """);
|
|
|
|
|
}
|