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:
15
services/discover-svc/Dockerfile
Normal file
15
services/discover-svc/Dockerfile
Normal 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"]
|
||||
23
services/discover-svc/package.json
Normal file
23
services/discover-svc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
395
services/discover-svc/src/index.ts
Normal file
395
services/discover-svc/src/index.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* discover-svc — полная логика перенесена из apps/frontend/src/app/api/discover/route.ts
|
||||
* API: GET /api/v1/discover?topic=®ion=&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);
|
||||
}
|
||||
131
services/discover-svc/src/searxng.ts
Normal file
131
services/discover-svc/src/searxng.ts
Normal 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');
|
||||
}
|
||||
16
services/discover-svc/tsconfig.json
Normal file
16
services/discover-svc/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user