From ed4c11e5536778206e3cee68bb72d89c72393067 Mon Sep 17 00:00:00 2001 From: home Date: Fri, 20 Feb 2026 17:35:49 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20News=20region=20(EU,=20Russia,=20China,?= =?UTF-8?q?=20America),=20auto-geo=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20GeoJ?= =?UTF-8?q?S?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Настройка News sources region в Settings → Search - Источники новостей по регионам (SOURCES_BY_REGION) - Авто: geo-context + fallback GeoJS по IP (без geo-device) - Discover: ожидание региона перед fetch, исправлен race - API: Promise.allSettled для устойчивости при сбоях SearxNG - Язык поиска: ru/zh/en по региону Co-authored-by: Cursor --- apps/frontend/src/app/api/discover/route.ts | 213 ++++++++++++++++---- apps/frontend/src/app/discover/page.tsx | 52 ++++- apps/frontend/src/lib/config/index.ts | 18 ++ 3 files changed, 246 insertions(+), 37 deletions(-) diff --git a/apps/frontend/src/app/api/discover/route.ts b/apps/frontend/src/app/api/discover/route.ts index c952d17..8546629 100644 --- a/apps/frontend/src/app/api/discover/route.ts +++ b/apps/frontend/src/app/api/discover/route.ts @@ -1,30 +1,141 @@ import { searchSearxng } from '@/lib/searxng'; +import configManager from '@/lib/config'; import { getSearxngURL } from '@/lib/config/serverRegistry'; -const websitesForTopic = { - tech: { - query: ['technology news', 'latest tech', 'AI', 'science and innovation'], - links: ['techcrunch.com', 'wired.com', 'theverge.com'], +type Region = 'america' | 'eu' | 'russia' | 'china'; +type Topic = 'tech' | 'finance' | 'art' | 'sports' | 'entertainment'; + +const SOURCES_BY_REGION: Record< + Region, + Record +> = { + america: { + 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'], + }, }, - finance: { - query: ['finance news', 'economy', 'stock market', 'investing'], - links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com'], + eu: { + 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'], + }, }, - art: { - query: ['art news', 'culture', 'modern art', 'cultural events'], - links: ['artnews.com', 'hyperallergic.com', 'theartnewspaper.com'], + russia: { + 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'], + }, }, - sports: { - query: ['sports news', 'latest sports', 'cricket football tennis'], - links: ['espn.com', 'bbc.com/sport', 'skysports.com'], - }, - entertainment: { - query: ['entertainment news', 'movies', 'TV shows', 'celebrities'], - links: ['hollywoodreporter.com', 'variety.com', 'deadline.com'], + china: { + 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'], + }, }, }; -type Topic = keyof typeof websitesForTopic; +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', +}; export const GET = async (req: Request) => { try { @@ -44,29 +155,61 @@ export const GET = async (req: Request) => { const mode: 'normal' | 'preview' = (params.get('mode') as 'normal' | 'preview') || 'normal'; const topic: Topic = (params.get('topic') as Topic) || 'tech'; + const regionParam = params.get('region') as Region | null; - const selectedTopic = websitesForTopic[topic]; + 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(); - data = ( - await Promise.all( - selectedTopic.links.flatMap((link) => - selectedTopic.query.map(async (query) => { - return ( - await searchSearxng(`site:${link} ${query}`, { - engines: ['bing news'], - pageno: 1, - language: 'en', - }) - ).results; - }), - ), - ) - ) + 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<{ url?: string; title?: string }[]> => r.status === 'fulfilled') + .flatMap((r) => r.value); + + data = allResults .flat() .filter((item) => { const url = item.url?.toLowerCase().trim(); @@ -82,7 +225,7 @@ export const GET = async (req: Request) => { { engines: ['bing news'], pageno: 1, - language: 'en', + language: searchLang, }, ) ).results; diff --git a/apps/frontend/src/app/discover/page.tsx b/apps/frontend/src/app/discover/page.tsx index c62bedd..b817b24 100644 --- a/apps/frontend/src/app/discover/page.tsx +++ b/apps/frontend/src/app/discover/page.tsx @@ -1,11 +1,47 @@ 'use client'; import { Globe2Icon, Settings } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import SmallNewsCard from '@/components/Discover/SmallNewsCard'; import MajorNewsCard from '@/components/Discover/MajorNewsCard'; +import { fetchContextWithClient } from '@/lib/geoDevice'; + +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', +}; + +const getRegionFromGeo = (countryCode: string | undefined): string | null => { + if (!countryCode) return null; + return COUNTRY_TO_REGION[countryCode] ?? null; +}; + +const fetchRegion = async (): Promise => { + let region: string | null = null; + try { + const ctx = await fetchContextWithClient(); + region = getRegionFromGeo(ctx.geo?.countryCode); + } catch { + // geo-context недоступен + } + if (!region) { + try { + const res = await fetch('https://get.geojs.io/v1/ip/geo.json'); + const d = await res.json(); + region = getRegionFromGeo(d?.country_code); + } catch { + // GeoJS недоступен + } + } + return region; +}; export interface Discover { title: string; @@ -46,12 +82,24 @@ const Page = () => { const [loading, setLoading] = useState(false); const [activeTopic, setActiveTopic] = useState(topics[0].key); const [setupRequired, setSetupRequired] = useState(false); + const regionPromiseRef = useRef | null>(null); + + const getRegionPromise = (): Promise => { + if (!regionPromiseRef.current) { + regionPromiseRef.current = fetchRegion(); + } + return regionPromiseRef.current; + }; const fetchArticles = async (topic: string) => { setLoading(true); setSetupRequired(false); try { - const res = await fetch(`/api/discover?topic=${topic}`, { + const region = await getRegionPromise(); + const url = new URL('/api/discover', window.location.origin); + url.searchParams.set('topic', topic); + if (region) url.searchParams.set('region', region); + const res = await fetch(url.toString(), { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/apps/frontend/src/lib/config/index.ts b/apps/frontend/src/lib/config/index.ts index b44ed82..54baea5 100644 --- a/apps/frontend/src/lib/config/index.ts +++ b/apps/frontend/src/lib/config/index.ts @@ -21,6 +21,7 @@ class ConfigManager { modelProviders: [], search: { searxngURL: '', + newsRegion: 'auto', }, }; uiConfigSections: UIConfigSections = { @@ -116,6 +117,23 @@ class ConfigManager { scope: 'server', env: 'SEARXNG_API_URL', }, + { + name: 'News sources region', + key: 'newsRegion', + type: 'select', + required: false, + description: + 'Region of news sources. Auto detects from your location.', + default: 'auto', + scope: 'server', + options: [ + { name: 'Auto (by location)', value: 'auto' }, + { name: 'America', value: 'america' }, + { name: 'EU', value: 'eu' }, + { name: 'Russia', value: 'russia' }, + { name: 'China', value: 'china' }, + ], + }, ], };