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:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,22 @@
{
"name": "search-svc",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/cors": "^9.0.1",
"ioredis": "^5.4.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View 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);
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}