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,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<Topic, { query: string[]; links: string[] }>
> = {
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<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}`, {
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;