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:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user