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 3002
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,23 @@
{
"name": "discover-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",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,395 @@
/**
* discover-svc — полная логика перенесена из apps/frontend/src/app/api/discover/route.ts
* API: GET /api/v1/discover?topic=&region=&mode=
* Ответ: { blogs: [{ title, content, url, thumbnail }] }
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import Redis from 'ioredis';
import { searchSearxng, type SearxngSearchResult } from './searxng.js';
const PORT = parseInt(process.env.PORT ?? '3002', 10);
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const GHOST_URL = process.env.GHOST_URL?.trim() ?? '';
const GHOST_CONTENT_API_KEY = process.env.GHOST_CONTENT_API_KEY?.trim() ?? '';
const GEO_DEVICE_SERVICE_URL = process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002';
const PLACEHOLDER_IMAGE = 'https://placehold.co/400x225/e5e7eb/6b7280?text=Post';
const NEWS_REGION = (process.env.NEWS_REGION ?? 'auto') as string;
type Region = 'america' | 'eu' | 'russia' | 'china';
type Topic =
| 'tech'
| 'finance'
| 'art'
| 'sports'
| 'entertainment'
| 'gooseek';
interface GhostPost {
title: string;
excerpt?: string | null;
custom_excerpt?: string | null;
meta_description?: string | null;
plaintext?: string | null;
html?: string | null;
feature_image?: string | null;
url: string;
}
// ioredis + NodeNext: default export не распознаётся как конструктор
const redis: import('ioredis') = new (Redis as any)(REDIS_URL);
redis.on('error', () => {});
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
const SOURCES_BY_REGION: Record<
Region,
Record<Topic, { query: string[]; links: string[] }>
> = {
america: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'latest tech', 'AI', 'science and innovation'],
links: ['techcrunch.com', 'wired.com', 'theverge.com', 'arstechnica.com'],
},
finance: {
query: ['finance news', 'economy', 'stock market', 'investing'],
links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com', 'reuters.com'],
},
art: {
query: ['art news', 'culture', 'modern art', 'cultural events'],
links: ['artnews.com', 'hyperallergic.com', 'theartnewspaper.com'],
},
sports: {
query: ['sports news', 'latest sports', 'cricket football tennis'],
links: ['espn.com', 'cbssports.com', 'nfl.com', 'nba.com'],
},
entertainment: {
query: ['entertainment news', 'movies', 'TV shows', 'celebrities'],
links: ['hollywoodreporter.com', 'variety.com', 'deadline.com'],
},
},
eu: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'AI', 'innovation'],
links: ['techcrunch.com', 'bbc.com', 'theguardian.com', 'reuters.com', 'euronews.com'],
},
finance: {
query: ['finance news', 'economy', 'stock market', 'euro'],
links: ['reuters.com', 'ft.com', 'bbc.com', 'euronews.com', 'politico.eu'],
},
art: {
query: ['art news', 'culture', 'art', 'exhibition'],
links: ['theguardian.com', 'bbc.com', 'dw.com', 'france24.com'],
},
sports: {
query: ['sports news', 'football', 'football soccer', 'champions league'],
links: ['bbc.com/sport', 'skysports.com', 'uefa.com', 'eurosport.com'],
},
entertainment: {
query: ['entertainment news', 'films', 'music', 'culture'],
links: ['bbc.com', 'theguardian.com', 'euronews.com', 'dw.com'],
},
},
russia: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'IT', 'innovation'],
links: ['tass.com', 'ria.ru', 'interfax.com', 'kommersant.ru', 'vedomosti.ru'],
},
finance: {
query: ['finance news', 'economy', 'markets', 'ruble'],
links: ['tass.com', 'ria.ru', 'interfax.com', 'kommersant.ru', 'vedomosti.ru'],
},
art: {
query: ['art news', 'culture', 'exhibition', 'museum'],
links: ['tass.com', 'ria.ru', 'kommersant.ru', 'interfax.com'],
},
sports: {
query: ['sports news', 'football', 'hockey', 'olympics'],
links: ['tass.com', 'ria.ru', 'rsport.ria.ru', 'championat.com', 'sport-express.ru'],
},
entertainment: {
query: ['entertainment news', 'films', 'music', 'culture'],
links: ['tass.com', 'ria.ru', 'interfax.com', 'kommersant.ru'],
},
},
china: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'AI', 'innovation'],
links: ['scmp.com', 'xinhuanet.com', 'chinadaily.com.cn', 'reuters.com'],
},
finance: {
query: ['finance news', 'economy', 'China markets', 'investing'],
links: ['scmp.com', 'chinadaily.com.cn', 'reuters.com', 'bloomberg.com'],
},
art: {
query: ['art news', 'culture', 'exhibition', 'Chinese art'],
links: ['scmp.com', 'chinadaily.com.cn', 'xinhuanet.com'],
},
sports: {
query: ['sports news', 'Olympics', 'football', 'basketball'],
links: ['scmp.com', 'chinadaily.com.cn', 'xinhuanet.com'],
},
entertainment: {
query: ['entertainment news', 'films', 'music', 'culture'],
links: ['scmp.com', 'chinadaily.com.cn', 'variety.com'],
},
},
};
const COUNTRY_TO_REGION: Record<string, Region> = {
US: 'america', CA: 'america', MX: 'america',
RU: 'russia', BY: 'russia', KZ: 'russia',
CN: 'china', HK: 'china', TW: 'china',
DE: 'eu', FR: 'eu', IT: 'eu', ES: 'eu', GB: 'eu', UK: 'eu',
NL: 'eu', PL: 'eu', BE: 'eu', AT: 'eu', PT: 'eu', SE: 'eu',
FI: 'eu', IE: 'eu', GR: 'eu', RO: 'eu', CZ: 'eu', HU: 'eu',
BG: 'eu', HR: 'eu', SK: 'eu', SI: 'eu', LT: 'eu', LV: 'eu', EE: 'eu', DK: 'eu',
};
async function fetchGooseekPosts(): Promise<
{ title: string; content: string; url: string; thumbnail: string }[]
> {
if (!GHOST_URL || !GHOST_CONTENT_API_KEY) {
throw new Error(
'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env'
);
}
const base = GHOST_URL.replace(/\/$/, '');
const apiUrl = `${base}/ghost/api/content/posts/?key=${GHOST_CONTENT_API_KEY}&limit=50&fields=title,excerpt,custom_excerpt,meta_description,html,feature_image,url&formats=html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15_000);
try {
const res = await fetch(apiUrl, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
clearTimeout(timeoutId);
if (!res.ok) {
if (res.status === 401) {
throw new Error(
'Неверный Ghost Content API Key. Получите ключ в Ghost Admin → Settings → Integrations.'
);
}
throw new Error(`Ghost API: HTTP ${res.status}`);
}
const data = await res.json();
const posts: GhostPost[] = data.posts ?? [];
return posts.map((p) => {
const excerpt =
p.custom_excerpt?.trim() ||
p.meta_description?.trim() ||
p.excerpt?.trim() ||
p.plaintext?.trim() ||
(p.html ? stripHtml(p.html) : '');
const content = excerpt
? excerpt.slice(0, 300) + (excerpt.length > 300 ? '…' : '')
: (p.title ? `${p.title}. Читать далее →` : 'Читать далее →');
return {
title: p.title || 'Без названия',
content,
url: p.url,
thumbnail: p.feature_image?.trim() || PLACEHOLDER_IMAGE,
};
});
} catch (e) {
clearTimeout(timeoutId);
throw e;
}
}
async function resolveRegion(
regionParam: string | null,
forwardedFor: string | null,
userAgent: string | null
): Promise<Region> {
if (
NEWS_REGION !== 'auto' &&
['america', 'eu', 'russia', 'china'].includes(NEWS_REGION)
) {
return NEWS_REGION as Region;
}
if (regionParam && ['america', 'eu', 'russia', 'china'].includes(regionParam)) {
return regionParam as Region;
}
try {
const geoRes = await fetch(`${GEO_DEVICE_SERVICE_URL}/api/context`, {
headers: {
'x-forwarded-for': forwardedFor ?? '',
'x-real-ip': forwardedFor ?? '',
'user-agent': userAgent ?? '',
},
signal: AbortSignal.timeout(5000),
});
const geoData = await geoRes.json();
const cc = geoData?.geo?.countryCode;
if (cc && COUNTRY_TO_REGION[cc]) {
return COUNTRY_TO_REGION[cc];
}
} catch {
// keep default
}
return 'america';
}
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('/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('/health', async () => ({ status: 'ok' }));
app.get('/ready', async () => {
try {
await redis.ping();
return { status: 'ready', redis: 'ok' };
} catch {
return { status: 'degraded', redis: 'unavailable' };
}
});
app.get<{
Querystring: { topic?: string; region?: string; mode?: string };
}>('/api/v1/discover', async (req, reply) => {
const topic = (req.query.topic ?? 'tech') as Topic;
const mode = (req.query.mode ?? 'normal') as 'normal' | 'preview';
if (topic === 'gooseek') {
try {
const blogs = await fetchGooseekPosts();
return { blogs };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const isConnect =
msg.includes('ECONNREFUSED') ||
msg.includes('fetch failed') ||
msg.includes('Failed to fetch') ||
msg.includes('AbortError');
return reply
.status(isConnect ? 503 : 500)
.send({
message:
!GHOST_URL || !GHOST_CONTENT_API_KEY
? 'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY'
: isConnect
? 'Не удалось подключиться к Ghost.'
: `Ошибка загрузки постов: ${msg}`,
});
}
}
const region = await resolveRegion(
req.query.region ?? null,
req.headers['x-forwarded-for'] as string | null,
req.headers['user-agent'] as string | null
);
const selectedTopic = SOURCES_BY_REGION[region][topic];
const searchLang = region === 'russia' ? 'ru' : region === 'china' ? 'zh' : 'en';
const cacheKey = `discover:${topic}:${region}:${mode}`;
try {
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as { blogs: unknown[] };
}
} catch {
// skip cache
}
let data: SearxngSearchResult[] = [];
try {
if (mode === 'normal') {
const seenUrls = new Set<string>();
const searchPromises = selectedTopic.links.flatMap((link) =>
selectedTopic.query.map((query) =>
searchSearxng(`site:${link} ${query}`, {
engines: ['bing news'],
pageno: 1,
language: searchLang,
}).then((r) => r.results)
)
);
const settled = await Promise.allSettled(searchPromises);
const allResults = settled
.filter(
(r): r is PromiseFulfilledResult<SearxngSearchResult[]> =>
r.status === 'fulfilled'
)
.flatMap((r) => r.value);
data = allResults
.flat()
.filter((item) => {
const url = item.url?.toLowerCase().trim();
if (!url || seenUrls.has(url)) return false;
seenUrls.add(url);
return true;
})
.sort(() => Math.random() - 0.5);
} else {
const link =
selectedTopic.links[
Math.floor(Math.random() * selectedTopic.links.length)
];
const query =
selectedTopic.query[
Math.floor(Math.random() * selectedTopic.query.length)
];
const res = await searchSearxng(`site:${link} ${query}`, {
engines: ['bing news'],
pageno: 1,
language: searchLang,
});
data = res.results;
}
} catch (err) {
req.log.error(err);
return reply.status(503).send({
message:
err instanceof Error && err.message.includes('not configured')
? 'SearxNG is not configured. Set SEARXNG_URL or SEARXNG_FALLBACK_URL.'
: 'Cannot connect to SearxNG. Check configuration.',
});
}
const blogs = data.map((item) => ({
title: item.title ?? 'No title',
content: (item.content ?? item.title ?? '').slice(0, 300),
url: item.url ?? '',
thumbnail: item.thumbnail ?? item.thumbnail_src ?? item.img_src ?? '',
}));
try {
await redis.setex(cacheKey, 30 * 60, JSON.stringify({ blogs }));
} catch {
// skip cache
}
return { blogs };
});
try {
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`discover-svc listening on :${PORT}`);
} catch (err) {
console.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,131 @@
/**
* Перенесено из apps/frontend/src/lib/searxng.ts
*/
const FALLBACK_INSTANCES = (
process.env.SEARXNG_FALLBACK_URL
? process.env.SEARXNG_FALLBACK_URL.split(',').map((u) => u.trim())
: ['https://searx.tiekoetter.com', 'https://search.sapti.me']
).filter(Boolean);
interface SearxngSearchOptions {
categories?: string[];
engines?: string[];
language?: string;
pageno?: number;
}
export interface SearxngSearchResult {
title: string;
url: string;
img_src?: string;
thumbnail_src?: string;
thumbnail?: string;
content?: string;
author?: string;
iframe_src?: string;
}
function buildSearchUrl(
baseUrl: string,
query: string,
opts?: SearxngSearchOptions
): string {
const params = new URLSearchParams();
params.append('format', 'json');
params.append('q', query);
if (opts) {
Object.entries(opts).forEach(([key, value]) => {
if (value == null) return;
params.append(
key,
Array.isArray(value) ? value.join(',') : String(value)
);
});
}
const base = baseUrl.trim().replace(/\/$/, '');
const prefix = /^https?:\/\//i.test(base) ? '' : 'http://';
return `${prefix}${base}/search?${params.toString()}`;
}
export async function searchSearxng(
query: string,
opts?: SearxngSearchOptions
): Promise<{ results: SearxngSearchResult[]; suggestions?: string[] }> {
const searxngURL = process.env.SEARXNG_URL?.trim() ?? '';
const candidates: string[] = [];
if (searxngURL) {
let u = searxngURL.replace(/\/$/, '');
if (!/^https?:\/\//i.test(u)) u = `http://${u}`;
candidates.push(u);
}
FALLBACK_INSTANCES.forEach((u) => {
const trimmed = u.trim().replace(/\/$/, '');
if (trimmed && !candidates.includes(trimmed)) candidates.push(trimmed);
});
if (candidates.length === 0) {
throw new Error('SearxNG is not configured. Set SEARXNG_URL.');
}
let lastError: Error | null = null;
const FETCH_TIMEOUT_MS = 15_000;
for (const baseUrl of candidates) {
try {
const fullUrl = buildSearchUrl(baseUrl, query, opts);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(fullUrl, { signal: controller.signal });
clearTimeout(timeoutId);
const text = await res.text();
const isJson =
text.trim().startsWith('{') || text.trim().startsWith('[');
if (res.status === 429) {
const err = new Error(
`SearXNG ${baseUrl}: лимит запросов (429)`
);
err.name = 'SearxngRateLimit';
throw err;
}
if (!isJson) {
throw new Error(
`SearXNG ${baseUrl}: ответ не JSON (HTTP ${res.status})`
);
}
const data = JSON.parse(text);
const results: SearxngSearchResult[] = data.results ?? [];
const suggestions: string[] = data.suggestions ?? [];
if (!res.ok && results.length === 0) {
throw new Error(`SearXNG ${baseUrl}: ${text.slice(0, 200)}`);
}
return { results, suggestions };
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
const cause = (err as { cause?: { code?: string } })?.cause;
const isAbort = lastError.name === 'AbortError';
const isNetwork =
isAbort ||
lastError.message.includes('fetch failed') ||
lastError.message.includes('Invalid URL') ||
cause?.code === 'ECONNREFUSED' ||
cause?.code === 'ECONNRESET';
const isRateLimit =
lastError.name === 'SearxngRateLimit' ||
lastError.message.includes('429');
if (candidates.indexOf(baseUrl) < candidates.length - 1 && (isNetwork || isRateLimit)) {
continue;
}
throw lastError;
}
}
throw lastError ?? new Error('SearXNG not configured');
}

View File

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