Files
gooseek/apps/frontend/src/app/api/discover/route.ts
home 8fc82a3b90 feat(sidebar): узкое меню, субменю историй с hover, эффект text-fade
- Сужено боковое меню (56px), убрана иконка Home
- Субменю историй при наведении: полная высота, на всю ширину, z-9999
- Класс text-fade для плавного обрезания длинного текста
- Убраны скругления в субменю
- Chatwoot, изменения в posts-mcs и прочие обновления

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 16:36:58 +03:00

357 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
);
}
};