import { searchSearxng, type SearxngSearchResult } from '@/lib/searxng'; import configManager from '@/lib/config'; import { getSearxngURL } from '@/lib/config/serverRegistry'; const GHOST_URL = process.env.GHOST_URL?.trim() ?? ''; const GHOST_CONTENT_API_KEY = process.env.GHOST_CONTENT_API_KEY?.trim() ?? ''; const PLACEHOLDER_IMAGE = 'https://placehold.co/400x225/e5e7eb/6b7280?text=Post'; 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; } function stripHtml(html: string): string { return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } const SOURCES_BY_REGION: Record< Region, Record > = { 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 = { 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 fetchDooseekPosts(): 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 → Add custom integration.', ); } 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; } } export const GET = async (req: Request) => { try { const params = new URL(req.url).searchParams; const mode: 'normal' | 'preview' = (params.get('mode') as 'normal' | 'preview') || 'normal'; const topic: Topic = (params.get('topic') as Topic) || 'tech'; if (topic === 'gooseek') { try { const blogs = await fetchDooseekPosts(); return Response.json({ blogs }, { status: 200 }); } 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 Response.json( { message: !GHOST_URL || !GHOST_CONTENT_API_KEY ? 'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env' : isConnect ? 'Не удалось подключиться к Ghost. Проверьте GHOST_URL и доступность сайта.' : `Ошибка загрузки постов: ${msg}`, }, { status: isConnect ? 503 : 500 }, ); } } const searxngURL = getSearxngURL(); if (!searxngURL?.trim()) { return Response.json( { message: 'SearxNG is not configured. Please set the SearxNG URL in Settings → Search.', }, { status: 503 }, ); } const regionParam = params.get('region') as Region | null; let region: Region = 'america'; const configRegion = configManager.getConfig('search.newsRegion', 'auto') as | Region | 'auto'; if (configRegion !== 'auto' && ['america', 'eu', 'russia', 'china'].includes(configRegion)) { region = configRegion; } else if (regionParam && ['america', 'eu', 'russia', 'china'].includes(regionParam)) { region = regionParam; } else if (configRegion === 'auto') { try { const geoUrl = process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002'; const geoRes = await fetch(`${geoUrl}/api/context`, { headers: { 'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '', 'x-real-ip': req.headers.get('x-real-ip') ?? '', 'user-agent': req.headers.get('user-agent') ?? '', }, }); const geoData = await geoRes.json(); const cc = geoData?.geo?.countryCode; if (cc && COUNTRY_TO_REGION[cc]) { region = COUNTRY_TO_REGION[cc]; } } catch { // keep default america } } const selectedTopic = SOURCES_BY_REGION[region][topic]; const searchLang = region === 'russia' ? 'ru' : region === 'china' ? 'zh' : 'en'; let data = []; if (mode === 'normal') { const seenUrls = new Set(); 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 => r.status === 'fulfilled') .flatMap((r) => r.value); data = allResults .flat() .filter((item) => { const url = item.url?.toLowerCase().trim(); if (seenUrls.has(url)) return false; seenUrls.add(url); return true; }) .sort(() => Math.random() - 0.5); } else { data = ( await searchSearxng( `site:${selectedTopic.links[Math.floor(Math.random() * selectedTopic.links.length)]} ${selectedTopic.query[Math.floor(Math.random() * selectedTopic.query.length)]}`, { engines: ['bing news'], pageno: 1, language: searchLang, }, ) ).results; } return Response.json( { blogs: data, }, { status: 200, }, ); } catch (err) { const message = err instanceof Error ? err.message : 'An error has occurred'; console.error(`Discover route error:`, err); return Response.json( { message: message.includes('fetch') || message.includes('ECONNREFUSED') ? 'Cannot connect to SearxNG. Check that it is running and the URL is correct in Settings.' : message, }, { status: 500 }, ); } };