feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
159
services/search-svc/src/index.ts
Normal file
159
services/search-svc/src/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* search-svc — SearXNG proxy, кэш по query_hash
|
||||
* docs/architecture: 02-k3s-microservices-spec.md
|
||||
* API: GET /api/v1/search?q=...&categories=...
|
||||
* Redis: search:{query_hash} TTL 1h
|
||||
*/
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import Redis from 'ioredis';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3001', 10);
|
||||
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
|
||||
const SEARXNG_URL = process.env.SEARXNG_URL ?? 'https://searx.tiekoetter.com';
|
||||
|
||||
// @ts-expect-error — ioredis + NodeNext ESM
|
||||
const redis = new Redis(REDIS_URL);
|
||||
redis.on('error', () => {});
|
||||
|
||||
function queryHash(q: string, extra = ''): string {
|
||||
const normalized = q.trim().toLowerCase();
|
||||
return createHash('sha256').update(normalized + extra).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
const FALLBACK_INSTANCES = (
|
||||
process.env.SEARXNG_FALLBACK_URL?.split(',').map((u) => u.trim()) ?? [
|
||||
'https://searx.tiekoetter.com',
|
||||
'https://search.sapti.me',
|
||||
]
|
||||
).filter(Boolean);
|
||||
|
||||
async function searchSearxng(
|
||||
query: string,
|
||||
options: {
|
||||
categories?: string | string[];
|
||||
engines?: string | string[];
|
||||
language?: string;
|
||||
pageno?: number;
|
||||
} = {}
|
||||
): Promise<{ results: unknown[]; suggestions?: string[] }> {
|
||||
const candidates = [SEARXNG_URL?.trim()?.replace(/\/$/, ''), ...FALLBACK_INSTANCES].filter(
|
||||
(u): u is string => !!u && u.length > 0
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('SearXNG not configured. Set SEARXNG_URL.');
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
for (const base of candidates) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('format', 'json');
|
||||
params.set('q', query);
|
||||
if (options.categories) {
|
||||
params.set('categories', Array.isArray(options.categories) ? options.categories.join(',') : options.categories);
|
||||
}
|
||||
if (options.engines) {
|
||||
params.set('engines', Array.isArray(options.engines) ? options.engines.join(',') : options.engines);
|
||||
}
|
||||
if (options.language) params.set('language', options.language);
|
||||
if (options.pageno != null) params.set('pageno', String(options.pageno));
|
||||
const url = `${base.startsWith('http') ? base : 'http://' + base}/search?${params.toString()}`;
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 15000);
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(t);
|
||||
const data = (await res.json()) as { results?: unknown[]; suggestions?: string[] };
|
||||
if (!res.ok && (!data.results || data.results.length === 0)) {
|
||||
throw new Error(`SearXNG HTTP ${res.status}`);
|
||||
}
|
||||
return {
|
||||
results: data.results ?? [],
|
||||
suggestions: data.suggestions ?? [],
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (candidates.indexOf(base) < candidates.length - 1) continue;
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('SearXNG failed');
|
||||
}
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
const corsOrigin = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: true;
|
||||
await app.register(cors, { origin: corsOrigin });
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
app.get('/ready', async () => {
|
||||
try {
|
||||
await redis.ping();
|
||||
return { status: 'ready' };
|
||||
} catch {
|
||||
return { status: 'degraded', redis: 'unavailable' };
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/metrics', async (_req, reply) => {
|
||||
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
return reply.send(
|
||||
'# HELP gooseek_up Service is up (1) or down (0)\n' +
|
||||
'# TYPE gooseek_up gauge\n' +
|
||||
'gooseek_up 1\n'
|
||||
);
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: { q?: string; categories?: string; engines?: string; language?: string; pageno?: string };
|
||||
}>('/api/v1/search', async (req, reply) => {
|
||||
const q = req.query.q?.trim();
|
||||
if (!q) {
|
||||
return reply.status(400).send({ error: 'q (query) is required' });
|
||||
}
|
||||
const categories = req.query.categories ?? 'general';
|
||||
const engines = req.query.engines ?? '';
|
||||
const language = req.query.language ?? '';
|
||||
const pageno = parseInt(req.query.pageno ?? '1', 10);
|
||||
const hash = queryHash(q, categories + engines + language);
|
||||
const cacheKey = `search:${hash}`;
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return reply.header('X-Cache', 'HIT').send(JSON.parse(cached));
|
||||
}
|
||||
} catch {
|
||||
// Redis недоступен
|
||||
}
|
||||
try {
|
||||
const data = await searchSearxng(q, {
|
||||
categories,
|
||||
engines: engines || undefined,
|
||||
language: language || undefined,
|
||||
pageno,
|
||||
});
|
||||
try {
|
||||
await redis.setex(cacheKey, 3600, JSON.stringify(data));
|
||||
} catch {
|
||||
// Redis недоступен — не кэшируем
|
||||
}
|
||||
return reply.header('X-Cache', 'MISS').send(data);
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(502).send({
|
||||
error: err instanceof Error ? err.message : 'Search failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||
console.log(`search-svc listening on :${PORT}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user