- Сужено боковое меню (56px), убрана иконка Home - Субменю историй при наведении: полная высота, на всю ширину, z-9999 - Класс text-fade для плавного обрезания длинного текста - Убраны скругления в субменю - Chatwoot, изменения в posts-mcs и прочие обновления Co-authored-by: Cursor <cursoragent@cursor.com>
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import { searchSearxng, type SearxngSearchResult } from '@/lib/searxng';
|
||
import configManager from '@/lib/config';
|
||
import { getSearxngURL } from '@/lib/config/serverRegistry';
|
||
|
||
const GHOST_URL = process.env.GHOST_URL?.trim() ?? '';
|
||
const GHOST_CONTENT_API_KEY = process.env.GHOST_CONTENT_API_KEY?.trim() ?? '';
|
||
const PLACEHOLDER_IMAGE = 'https://placehold.co/400x225/e5e7eb/6b7280?text=Post';
|
||
|
||
type Region = 'america' | 'eu' | 'russia' | 'china';
|
||
type Topic = 'tech' | 'finance' | 'art' | 'sports' | 'entertainment' | 'gooseek';
|
||
|
||
interface GhostPost {
|
||
title: string;
|
||
excerpt?: string | null;
|
||
custom_excerpt?: string | null;
|
||
meta_description?: string | null;
|
||
plaintext?: string | null;
|
||
html?: string | null;
|
||
feature_image?: string | null;
|
||
url: string;
|
||
}
|
||
|
||
function stripHtml(html: string): string {
|
||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
const SOURCES_BY_REGION: Record<
|
||
Region,
|
||
Record<Topic, { query: string[]; links: string[] }>
|
||
> = {
|
||
america: {
|
||
gooseek: { query: [], links: [] },
|
||
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'],
|
||
},
|
||
},
|
||
eu: {
|
||
gooseek: { query: [], links: [] },
|
||
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: {
|
||
gooseek: { query: [], links: [] },
|
||
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: {
|
||
gooseek: { query: [], links: [] },
|
||
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'],
|
||
},
|
||
},
|
||
};
|
||
|
||
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',
|
||
};
|
||
|
||
async function fetchDooseekPosts(): Promise<
|
||
{ title: string; content: string; url: string; thumbnail: string }[]
|
||
> {
|
||
if (!GHOST_URL || !GHOST_CONTENT_API_KEY) {
|
||
throw new Error(
|
||
'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env',
|
||
);
|
||
}
|
||
const base = GHOST_URL.replace(/\/$/, '');
|
||
const apiUrl = `${base}/ghost/api/content/posts/?key=${GHOST_CONTENT_API_KEY}&limit=50&fields=title,excerpt,custom_excerpt,meta_description,html,feature_image,url&formats=html`;
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||
try {
|
||
const res = await fetch(apiUrl, {
|
||
signal: controller.signal,
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
clearTimeout(timeoutId);
|
||
if (!res.ok) {
|
||
if (res.status === 401) {
|
||
throw new Error(
|
||
'Неверный Ghost Content API Key. Получите ключ в Ghost Admin → Settings → Integrations → Add custom integration.',
|
||
);
|
||
}
|
||
throw new Error(`Ghost API: HTTP ${res.status}`);
|
||
}
|
||
const data = await res.json();
|
||
const posts: GhostPost[] = data.posts ?? [];
|
||
return posts.map((p) => {
|
||
const excerpt =
|
||
p.custom_excerpt?.trim() ||
|
||
p.meta_description?.trim() ||
|
||
p.excerpt?.trim() ||
|
||
p.plaintext?.trim() ||
|
||
(p.html ? stripHtml(p.html) : '');
|
||
const content = excerpt
|
||
? excerpt.slice(0, 300) + (excerpt.length > 300 ? '…' : '')
|
||
: (p.title ? `${p.title}. Читать далее →` : 'Читать далее →');
|
||
return {
|
||
title: p.title || 'Без названия',
|
||
content,
|
||
url: p.url,
|
||
thumbnail: p.feature_image?.trim() || PLACEHOLDER_IMAGE,
|
||
};
|
||
});
|
||
} catch (e) {
|
||
clearTimeout(timeoutId);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
export const GET = async (req: Request) => {
|
||
try {
|
||
const params = new URL(req.url).searchParams;
|
||
|
||
const mode: 'normal' | 'preview' =
|
||
(params.get('mode') as 'normal' | 'preview') || 'normal';
|
||
const topic: Topic = (params.get('topic') as Topic) || 'tech';
|
||
|
||
if (topic === 'gooseek') {
|
||
try {
|
||
const blogs = await fetchDooseekPosts();
|
||
return Response.json({ blogs }, { status: 200 });
|
||
} catch (e) {
|
||
const msg =
|
||
e instanceof Error ? e.message : String(e);
|
||
const isConnect =
|
||
msg.includes('ECONNREFUSED') ||
|
||
msg.includes('fetch failed') ||
|
||
msg.includes('Failed to fetch') ||
|
||
msg.includes('AbortError');
|
||
return Response.json(
|
||
{
|
||
message:
|
||
!GHOST_URL || !GHOST_CONTENT_API_KEY
|
||
? 'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env'
|
||
: isConnect
|
||
? 'Не удалось подключиться к Ghost. Проверьте GHOST_URL и доступность сайта.'
|
||
: `Ошибка загрузки постов: ${msg}`,
|
||
},
|
||
{ status: isConnect ? 503 : 500 },
|
||
);
|
||
}
|
||
}
|
||
|
||
const searxngURL = getSearxngURL();
|
||
if (!searxngURL?.trim()) {
|
||
return Response.json(
|
||
{
|
||
message:
|
||
'SearxNG is not configured. Please set the SearxNG URL in Settings → Search.',
|
||
},
|
||
{ status: 503 },
|
||
);
|
||
}
|
||
const regionParam = params.get('region') as Region | null;
|
||
|
||
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();
|
||
|
||
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<SearxngSearchResult[]> => r.status === 'fulfilled')
|
||
.flatMap((r) => r.value);
|
||
|
||
data = allResults
|
||
.flat()
|
||
.filter((item) => {
|
||
const url = item.url?.toLowerCase().trim();
|
||
if (seenUrls.has(url)) return false;
|
||
seenUrls.add(url);
|
||
return true;
|
||
})
|
||
.sort(() => Math.random() - 0.5);
|
||
} else {
|
||
data = (
|
||
await searchSearxng(
|
||
`site:${selectedTopic.links[Math.floor(Math.random() * selectedTopic.links.length)]} ${selectedTopic.query[Math.floor(Math.random() * selectedTopic.query.length)]}`,
|
||
{
|
||
engines: ['bing news'],
|
||
pageno: 1,
|
||
language: searchLang,
|
||
},
|
||
)
|
||
).results;
|
||
}
|
||
|
||
return Response.json(
|
||
{
|
||
blogs: data,
|
||
},
|
||
{
|
||
status: 200,
|
||
},
|
||
);
|
||
} catch (err) {
|
||
const message =
|
||
err instanceof Error ? err.message : 'An error has occurred';
|
||
console.error(`Discover route error:`, err);
|
||
return Response.json(
|
||
{
|
||
message:
|
||
message.includes('fetch') || message.includes('ECONNREFUSED')
|
||
? 'Cannot connect to SearxNG. Check that it is running and the URL is correct in Settings.'
|
||
: message,
|
||
},
|
||
{ status: 500 },
|
||
);
|
||
}
|
||
};
|