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 9090
ENTRYPOINT ["node", "dist/run.js"]

View File

@@ -0,0 +1,21 @@
{
"name": "cache-worker",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"discover": "tsx src/run.ts --task=discover",
"finance": "tsx src/run.ts --task=finance",
"travel": "tsx src/run.ts --task=travel",
"all": "tsx src/run.ts --task=all",
"build": "tsc"
},
"dependencies": {
"ioredis": "^5.4.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,49 @@
/**
* cache-worker — pre-compute discover, finance, travel
* docs/architecture: 03-cache-and-precompute-strategy.md
* Cron: discover every 15m, finance every 2m, travel every 4h
* Usage: node run.js --task=discover|finance|travel|all
*/
import Redis from 'ioredis';
import { runDiscoverPrecompute } from './tasks/discover.js';
import { runFinancePrecompute } from './tasks/finance.js';
import { runTravelPrecompute } from './tasks/travel.js';
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const DISCOVER_SVC_URL = process.env.DISCOVER_SVC_URL ?? 'http://localhost:3002';
const FINANCE_SVC_URL = process.env.FINANCE_SVC_URL ?? 'http://localhost:3003';
const TRAVEL_SVC_URL = process.env.TRAVEL_SVC_URL ?? 'http://localhost:3004';
function getTask(): string {
const idx = process.argv.indexOf('--task');
return idx >= 0 && process.argv[idx + 1] ? process.argv[idx + 1] : 'all';
}
async function main() {
const task = getTask();
// @ts-expect-error — ioredis + NodeNext ESM constructability
const redis = new Redis(REDIS_URL);
try {
if (task === 'discover' || task === 'all') {
console.log('[cache-worker] Running discover precompute...');
await runDiscoverPrecompute(redis, DISCOVER_SVC_URL);
}
if (task === 'finance' || task === 'all') {
console.log('[cache-worker] Running finance precompute...');
await runFinancePrecompute(redis, FINANCE_SVC_URL);
}
if (task === 'travel' || task === 'all') {
console.log('[cache-worker] Running travel precompute...');
await runTravelPrecompute(redis, TRAVEL_SVC_URL);
}
console.log('[cache-worker] Done');
} finally {
await redis.quit();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,30 @@
/**
* Discover pre-compute: вызывает discover-svc для всех тем, заполняет Redis
* Redis: discover:{topic} TTL 30 min
*/
import type { Redis as RedisType } from 'ioredis';
const TOPICS = ['tech', 'finance', 'travel', 'world', 'science'];
export async function runDiscoverPrecompute(
redis: RedisType,
discoverSvcUrl: string
): Promise<void> {
for (const topic of TOPICS) {
try {
const url = `${discoverSvcUrl.replace(/\/$/, '')}/api/v1/discover?topic=${topic}`;
const res = await fetch(url, { signal: AbortSignal.timeout(60000) });
if (!res.ok) {
console.warn(`[discover] ${topic}: HTTP ${res.status}`);
continue;
}
const data = await res.json();
const key = `discover:${topic}`;
await redis.setex(key, 30 * 60, JSON.stringify(data));
console.log(`[discover] ${topic}: cached ${data?.items?.length ?? 0} items`);
} catch (err) {
console.error(`[discover] ${topic}:`, err);
}
}
}

View File

@@ -0,0 +1,55 @@
/**
* Finance pre-compute: summary, heatmap, news по топ-тикерам
* Redis: finance:summary, finance:heatmap, finance:news:{ticker}
*/
import type { Redis as RedisType } from 'ioredis';
const TOP_TICKERS = ['AAPL', 'TSLA', 'GOOGL', 'MSFT', 'AMZN', 'NVDA', 'META'];
export async function runFinancePrecompute(
redis: RedisType,
financeSvcUrl: string
): Promise<void> {
const base = financeSvcUrl.replace(/\/$/, '');
try {
const summaryRes = await fetch(`${base}/api/v1/finance/summary`, {
signal: AbortSignal.timeout(30000),
});
if (summaryRes.ok) {
const data = await summaryRes.json();
await redis.setex('finance:summary', 5 * 60, JSON.stringify(data));
console.log('[finance] summary: cached');
}
} catch (err) {
console.error('[finance] summary:', err);
}
try {
const heatmapRes = await fetch(`${base}/api/v1/finance/heatmap`, {
signal: AbortSignal.timeout(30000),
});
if (heatmapRes.ok) {
const data = await heatmapRes.json();
await redis.setex('finance:heatmap', 5 * 60, JSON.stringify(data));
console.log('[finance] heatmap: cached');
}
} catch (err) {
console.error('[finance] heatmap:', err);
}
for (const ticker of TOP_TICKERS) {
try {
const res = await fetch(`${base}/api/v1/finance/news/${ticker}`, {
signal: AbortSignal.timeout(20000),
});
if (res.ok) {
const data = await res.json();
await redis.setex(`finance:news:${ticker}`, 15 * 60, JSON.stringify(data));
}
} catch {
// skip
}
}
}

View File

@@ -0,0 +1,103 @@
/**
* Travel pre-compute: trending, Inspiration Cards
* Redis: travel:trending, travel:inspiration TTL 6h
* docs/architecture: 03-cache-and-precompute-strategy.md §3
* Inspiration: курируемые темы → LLM суммаризация (gpt-4o-mini)
*/
import type { Redis as RedisType } from 'ioredis';
const INSPIRATION_TOPICS = [
'best hiking trails Slovenia',
'forest hotels Sweden solitude',
'hidden beaches Portugal',
'weekend getaways Europe',
'road trips USA west',
'food markets Istanbul',
];
const STUB_INSPIRATION = [
{ title: 'Best hiking trails in Slovenia', summary: 'Explore Triglav and the Julian Alps.', image: '', url: '#' },
{ title: 'Forest hotels in Sweden', summary: 'Escape to Nordic solitude.', image: '', url: '#' },
{ title: 'Hidden beaches in Portugal', summary: 'Algarve beyond the crowds.', image: '', url: '#' },
];
async function generateInspirationCards(apiKey: string): Promise<{ title: string; summary: string; image?: string; url?: string }[]> {
try {
const topics = INSPIRATION_TOPICS.slice(0, 4).join(', ');
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'Return ONLY valid JSON array. Each item: { "title": "short catchy title", "summary": "1-2 sentences" }. No markdown.',
},
{
role: 'user',
content: `Create 4 travel inspiration cards for these topics: ${topics}. Return JSON array of 4 objects with title and summary.`,
},
],
max_tokens: 600,
temperature: 0.7,
}),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) return STUB_INSPIRATION;
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const raw = data.choices?.[0]?.message?.content?.trim() ?? '';
const cleaned = raw.replace(/^```json?\s*/i, '').replace(/\s*```$/i, '');
const parsed = JSON.parse(cleaned) as { title?: string; summary?: string }[];
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((p) => ({
title: String(p.title ?? ''),
summary: String(p.summary ?? ''),
image: '',
url: '#',
}));
}
} catch (err) {
console.error('[travel] inspiration LLM:', err);
}
return STUB_INSPIRATION;
}
export async function runTravelPrecompute(
redis: RedisType,
travelSvcUrl: string
): Promise<void> {
const base = travelSvcUrl.replace(/\/$/, '');
try {
const res = await fetch(`${base}/api/v1/travel/trending`, {
signal: AbortSignal.timeout(60000),
});
if (res.ok) {
const data = await res.json();
await redis.setex('travel:trending', 6 * 60 * 60, JSON.stringify(data));
console.log('[travel] trending: cached');
}
} catch (err) {
console.error('[travel] trending:', err);
}
const apiKey = process.env.OPENAI_API_KEY ?? '';
const inspiration = apiKey
? await generateInspirationCards(apiKey)
: STUB_INSPIRATION;
try {
await redis.setex(
'travel:inspiration',
6 * 60 * 60,
JSON.stringify({ items: inspiration })
);
console.log('[travel] inspiration: cached', apiKey ? '(LLM)' : '(stub)');
} catch (err) {
console.error('[travel] inspiration:', err);
}
}

View File

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