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,30 +1,141 @@
|
|||||||
import { searchSearxng } from '@/lib/searxng';
|
import { searchSearxng } from '@/lib/searxng';
|
||||||
|
import configManager from '@/lib/config';
|
||||||
import { getSearxngURL } from '@/lib/config/serverRegistry';
|
import { getSearxngURL } from '@/lib/config/serverRegistry';
|
||||||
|
|
||||||
const websitesForTopic = {
|
type Region = 'america' | 'eu' | 'russia' | 'china';
|
||||||
tech: {
|
type Topic = 'tech' | 'finance' | 'art' | 'sports' | 'entertainment';
|
||||||
query: ['technology news', 'latest tech', 'AI', 'science and innovation'],
|
|
||||||
links: ['techcrunch.com', 'wired.com', 'theverge.com'],
|
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: {
|
eu: {
|
||||||
query: ['finance news', 'economy', 'stock market', 'investing'],
|
tech: {
|
||||||
links: ['bloomberg.com', 'cnbc.com', 'marketwatch.com'],
|
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: {
|
russia: {
|
||||||
query: ['art news', 'culture', 'modern art', 'cultural events'],
|
tech: {
|
||||||
links: ['artnews.com', 'hyperallergic.com', 'theartnewspaper.com'],
|
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: {
|
china: {
|
||||||
query: ['sports news', 'latest sports', 'cricket football tennis'],
|
tech: {
|
||||||
links: ['espn.com', 'bbc.com/sport', 'skysports.com'],
|
query: ['technology news', 'tech', 'AI', 'innovation'],
|
||||||
},
|
links: ['scmp.com', 'xinhuanet.com', 'chinadaily.com.cn', 'reuters.com'],
|
||||||
entertainment: {
|
},
|
||||||
query: ['entertainment news', 'movies', 'TV shows', 'celebrities'],
|
finance: {
|
||||||
links: ['hollywoodreporter.com', 'variety.com', 'deadline.com'],
|
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) => {
|
export const GET = async (req: Request) => {
|
||||||
try {
|
try {
|
||||||
@@ -44,29 +155,61 @@ export const GET = async (req: Request) => {
|
|||||||
const mode: 'normal' | 'preview' =
|
const mode: 'normal' | 'preview' =
|
||||||
(params.get('mode') as 'normal' | 'preview') || 'normal';
|
(params.get('mode') as 'normal' | 'preview') || 'normal';
|
||||||
const topic: Topic = (params.get('topic') as Topic) || 'tech';
|
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 = [];
|
let data = [];
|
||||||
|
|
||||||
if (mode === 'normal') {
|
if (mode === 'normal') {
|
||||||
const seenUrls = new Set();
|
const seenUrls = new Set();
|
||||||
|
|
||||||
data = (
|
const searchPromises = selectedTopic.links.flatMap((link) =>
|
||||||
await Promise.all(
|
selectedTopic.query.map((query) =>
|
||||||
selectedTopic.links.flatMap((link) =>
|
searchSearxng(`site:${link} ${query}`, {
|
||||||
selectedTopic.query.map(async (query) => {
|
engines: ['bing news'],
|
||||||
return (
|
pageno: 1,
|
||||||
await searchSearxng(`site:${link} ${query}`, {
|
language: searchLang,
|
||||||
engines: ['bing news'],
|
}).then((r) => r.results),
|
||||||
pageno: 1,
|
),
|
||||||
language: 'en',
|
);
|
||||||
})
|
const settled = await Promise.allSettled(searchPromises);
|
||||||
).results;
|
const allResults = settled
|
||||||
}),
|
.filter((r): r is PromiseFulfilledResult<{ url?: string; title?: string }[]> => r.status === 'fulfilled')
|
||||||
),
|
.flatMap((r) => r.value);
|
||||||
)
|
|
||||||
)
|
data = allResults
|
||||||
.flat()
|
.flat()
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const url = item.url?.toLowerCase().trim();
|
const url = item.url?.toLowerCase().trim();
|
||||||
@@ -82,7 +225,7 @@ export const GET = async (req: Request) => {
|
|||||||
{
|
{
|
||||||
engines: ['bing news'],
|
engines: ['bing news'],
|
||||||
pageno: 1,
|
pageno: 1,
|
||||||
language: 'en',
|
language: searchLang,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
).results;
|
).results;
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Globe2Icon, Settings } from 'lucide-react';
|
import { Globe2Icon, Settings } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import SmallNewsCard from '@/components/Discover/SmallNewsCard';
|
import SmallNewsCard from '@/components/Discover/SmallNewsCard';
|
||||||
import MajorNewsCard from '@/components/Discover/MajorNewsCard';
|
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 {
|
export interface Discover {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -46,12 +82,24 @@ const Page = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
|
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
|
||||||
const [setupRequired, setSetupRequired] = useState(false);
|
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) => {
|
const fetchArticles = async (topic: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSetupRequired(false);
|
setSetupRequired(false);
|
||||||
try {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class ConfigManager {
|
|||||||
modelProviders: [],
|
modelProviders: [],
|
||||||
search: {
|
search: {
|
||||||
searxngURL: '',
|
searxngURL: '',
|
||||||
|
newsRegion: 'auto',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
uiConfigSections: UIConfigSections = {
|
uiConfigSections: UIConfigSections = {
|
||||||
@@ -116,6 +117,23 @@ class ConfigManager {
|
|||||||
scope: 'server',
|
scope: 'server',
|
||||||
env: 'SEARXNG_API_URL',
|
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