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 06:02:07 +07:00
|
|
|
import { createAbortControllerWithTimeout } from "@bete/shared/utils";
|
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:";
|
|
|
|
|
|
|
|
|
|
let redis: Redis | null = null;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize Redis connection for SearXNG cache.
|
|
|
|
|
* Safe to call multiple times — only creates one connection.
|
|
|
|
|
*/
|
2026-07-02 03:54:44 +07:00
|
|
|
export function initSearxngCache(redisUrl: string): void {
|
|
|
|
|
if (redis) return;
|
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,
|
|
|
|
|
});
|
|
|
|
|
redis.on("error", (err) => {
|
2026-07-02 03:54:44 +07:00
|
|
|
log.warn({ err: err.message }, "SearXNG Redis cache error");
|
2026-06-22 12:41:48 +07:00
|
|
|
});
|
|
|
|
|
redis.connect().catch(() => {
|
2026-07-02 03:54:44 +07:00
|
|
|
log.warn("SearXNG Redis cache unavailable — falling back to no-cache");
|
|
|
|
|
redis = null;
|
2026-06-22 12:41:48 +07:00
|
|
|
});
|
|
|
|
|
log.info("SearXNG Redis cache initialized");
|
|
|
|
|
}
|
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 03:54:44 +07:00
|
|
|
if (redis) {
|
2026-06-22 12:41:48 +07:00
|
|
|
try {
|
2026-07-02 03:54:44 +07:00
|
|
|
const cached = await redis.get(cacheKey);
|
|
|
|
|
if (cached) {
|
|
|
|
|
log.debug({ query, category }, "SearXNG cache HIT");
|
|
|
|
|
return JSON.parse(cached) as SearxngResult[];
|
|
|
|
|
}
|
2026-06-22 12:41:48 +07:00
|
|
|
} catch {
|
2026-07-02 03:54:44 +07:00
|
|
|
// Cache read failed, 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 06:02:07 +07:00
|
|
|
const { controller, clear } = createAbortControllerWithTimeout(TIMEOUT_MS);
|
2026-06-22 10:58:52 +07:00
|
|
|
|
2026-07-02 06:02:07 +07:00
|
|
|
try {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
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 03:54:44 +07:00
|
|
|
});
|
2026-06-22 12:41:48 +07:00
|
|
|
|
2026-07-02 06:02:07 +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 ?? [];
|
|
|
|
|
const mapped = results.slice(0, MAX_RESULTS).map((r) => ({
|
|
|
|
|
title: r.title ?? "",
|
|
|
|
|
url: r.url ?? "",
|
|
|
|
|
snippet: (r.content ?? "").slice(0, 500),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Store in cache (fire and forget — don't block on write)
|
|
|
|
|
if (redis) {
|
|
|
|
|
redis.setex(cacheKey, CACHE_TTL, JSON.stringify(mapped)).catch(() => {
|
|
|
|
|
// Cache write failed silently
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.debug({ query, category, resultCount: mapped.length }, "SearXNG search OK");
|
|
|
|
|
return mapped;
|
|
|
|
|
} finally {
|
|
|
|
|
clear();
|
|
|
|
|
}
|
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-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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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, """);
|
|
|
|
|
}
|