feat: News region (EU, Russia, China, America), auto-geo через GeoJS

- Настройка 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 <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-20 17:35:49 +03:00
parent 8ba3f5495a
commit ed4c11e553
3 changed files with 246 additions and 37 deletions

View File

@@ -1,14 +1,22 @@
import { searchSearxng } from '@/lib/searxng';
import configManager from '@/lib/config';
import { getSearxngURL } from '@/lib/config/serverRegistry';
const websitesForTopic = {
type Region = 'america' | 'eu' | 'russia' | 'china';
type Topic = 'tech' | 'finance' | 'art' | 'sports' | 'entertainment';
const SOURCES_BY_REGION: Record<
Region,
Record<Topic, { query: string[]; links: string[] }>
> = {
america: {
tech: {
query: ['technology news', 'latest tech', 'AI', 'science and innovation'],
links: ['techcrunch.com', 'wired.com', 'theverge.com'],
links: ['techcrunch.com', 'wired.com', 'theverge.com', 'arstechnica.com'],
},
finance: {
query: ['finance news', 'economy', 'stock market', 'investing'],
links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com'],
links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com', 'reuters.com'],
},
art: {
query: ['art news', 'culture', 'modern art', 'cultural events'],
@@ -16,15 +24,118 @@ const websitesForTopic = {
},
sports: {
query: ['sports news', 'latest sports', 'cricket football tennis'],
links: ['espn.com', 'bbc.com/sport', 'skysports.com'],
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: {
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: {
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: {
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<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',
};
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}`, {
const searchPromises = selectedTopic.links.flatMap((link) =>
selectedTopic.query.map((query) =>
searchSearxng(`site:${link} ${query}`, {
engines: ['bing news'],
pageno: 1,
language: 'en',
})
).results;
}),
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;

View File

@@ -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<string, string> = {
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<string | null> => {
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<string>(topics[0].key);
const [setupRequired, setSetupRequired] = useState(false);
const regionPromiseRef = useRef<Promise<string | null> | null>(null);
const getRegionPromise = (): Promise<string | null> => {
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',

View File

@@ -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' },
],
},
],
};