// Native fetch available in Node 20+ import { getDb } from '../database/database'; import { getTimezone, formatLocalDateTime } from '../utils/time'; export interface RankingItem { keyword: string; searchCount: number; updatedAt: string; rating?: number; } export interface CategorizedRanking { category: string; label: string; hot: RankingItem[]; newest: RankingItem[]; } export interface CategorizedResponse { fetchedAt: string; categories: CategorizedRanking[]; } // ===== Bilibili PGC 排行榜配置 ===== interface BiliPgcDef { category: string; label: string; season_type: number; // 1=番剧, 2=电影, 3=纪录片, 4=国创, 5=电视剧, 7=综艺 } const BILI_PGC_CATEGORIES: BiliPgcDef[] = [ // 国创:凡人修仙传、灵笼、斗破苍穹等官方国产动画 { category: 'donghua', label: '国产动漫', season_type: 4 }, // 番剧:日漫等全球动画 { category: 'global_anime', label: '热门动漫', season_type: 1 }, ]; // ===== 百度热搜榜配置 ===== interface BaiduBoardDef { category: string; label: string; tab: string; // movie=电影热搜, teleplay=电视剧热搜 } const BAIDU_BOARDS: BaiduBoardDef[] = [ // 百度电影热搜:实时反映国内电影热度 { category: 'movie', label: '国内电影', tab: 'movie' }, // 百度电视剧热搜:国内剧集热度 { category: 'tv', label: '热门剧集', tab: 'teleplay' }, ]; // ===== TMDB 分类配置(保留欧美和冷门内容)===== interface TmdbCategoryDef { category: string; label: string; hotUrl: string; newestUrl: string; } const TMDB_CATEGORIES: TmdbCategoryDef[] = [ { category: 'western_movie', label: '欧美电影', hotUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10', newestUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=release_date.desc&vote_count.gte=1', }, { category: 'western_tv', label: '欧美剧集', hotUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10', newestUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=first_air_date.desc&vote_count.gte=10', }, { category: 'niche', label: '冷门佳片', hotUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=vote_average.desc&vote_count.gte=10&vote_count.lte=500', newestUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=release_date.desc&vote_count.gte=1&vote_count.lte=500', }, ]; // ===== 显示顺序 ===== const CATEGORY_ORDER: Record = { donghua: 1, movie: 2, tv: 3, global_anime: 4, western_movie: 5, western_tv: 6, niche: 7, hotsite: 8, }; // ===== 12小时缓存 ===== let cache: { data: CategorizedResponse; time: number } | null = null; const CACHE_TTL = 12 * 60 * 60 * 1000; function isCacheValid(): boolean { return cache !== null && (Date.now() - cache.time) < CACHE_TTL; } // ===== Bilibili PGC API ===== /** * 抓取 Bilibili PGC 排行榜(番剧/国创) */ async function fetchFromBiliPgc(season_type: number): Promise { try { const url = `https://api.bilibili.com/pgc/web/rank/list?season_type=${season_type}&day=7`; const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Referer': 'https://www.bilibili.com/', 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9', }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) { console.error(`[BiliPGC] HTTP ${resp.status} for season_type=${season_type}`); return []; } const json = await resp.json() as any; if (json.code !== 0 || !json.result?.list) { console.error(`[BiliPGC] API error code=${json.code} for season_type=${season_type}`); return []; } return json.result.list.slice(0, 20).map((item: any) => { const stat = item.stat || {}; const viewCount = stat.view || 0; const followCount = stat.follow || 0; const searchCount = viewCount > 0 ? viewCount : followCount; let rating = 0; if (item.rating) { const m = String(item.rating).match(/([\d.]+)/); if (m) rating = parseFloat(m[1]); } return { keyword: item.title || '', searchCount, updatedAt: item.new_ep?.index_show || item.new_ep?.cover || '', rating, }; }); } catch (err) { console.error(`[BiliPGC] Fetch error for season_type=${season_type}:`, (err as Error).message); return []; } } // ===== 百度热搜榜 API ===== /** * 抓取百度热搜榜 * tab: movie=电影, teleplay=电视剧 */ async function fetchFromBaidu(tab: string): Promise { try { const url = `https://top.baidu.com/api/board?tab=${tab}`; const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Referer': 'https://top.baidu.com/board', }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) { console.error(`[Baidu] HTTP ${resp.status} for tab=${tab}`); return []; } const json = await resp.json() as any; if (!json.success || !json.data?.cards) { console.error(`[Baidu] API error for tab=${tab}`); return []; } const results: RankingItem[] = []; for (const card of json.data.cards) { for (const item of (card.content || [])) { results.push({ keyword: item.word || '', // hotScore can be like "96438", parse as number searchCount: parseInt(item.hotScore || '0', 10) || 0, updatedAt: item.desc || '', rating: 0, }); } } return results.slice(0, 20); } catch (err) { console.error(`[Baidu] Fetch error for tab=${tab}:`, (err as Error).message); return []; } } // ===== TMDB ===== function getTmdbToken(): string { const db = getDb(); return (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || ''; } function tmdbResultToRanking(item: any): RankingItem { const title = item.title || item.name || ''; const date = item.release_date || item.first_air_date || ''; const rating = item.vote_average ? Math.round(item.vote_average * 10) / 10 : 0; return { keyword: title, searchCount: item.vote_count || 0, updatedAt: date, rating, }; } async function tmdbFetch(url: string, token: string): Promise { const fullUrl = `${url}${url.includes('?') ? '&' : '?'}language=zh-CN`; try { const resp = await fetch(fullUrl, { headers: { 'Authorization': `Bearer ${token}` }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) { console.error(`[TMDB] HTTP ${resp.status} for ${url}`); return []; } const data = await resp.json() as any; return (data.results || []).slice(0, 20); } catch (err) { console.error(`[TMDB] Fetch error for ${url}:`, err); return []; } } // ===== 主流程 ===== async function fetchRankings(): Promise { const fetchedAt = formatLocalDateTime(); // 1. 并行抓取 Bilibili PGC 数据(国漫、番剧) const biliPromises = BILI_PGC_CATEGORIES.map(async (cat) => { const results = await fetchFromBiliPgc(cat.season_type); const mid = Math.ceil(results.length / 2); return { category: cat.category, label: cat.label, hot: results.slice(0, mid), newest: results.slice(mid), }; }); // 2. 并行抓取百度热搜数据(电影、电视剧) // 百度只有热榜没有最新榜,全部放 hot const baiduPromises = BAIDU_BOARDS.map(async (board) => { const results = await fetchFromBaidu(board.tab); return { category: board.category, label: board.label, hot: results, newest: [], }; }); // 3. 并行抓取 TMDB 数据(欧美观影、剧集、冷门) const token = getTmdbToken(); let tmdbResults: CategorizedRanking[] = []; if (token) { const tmdbPromises = TMDB_CATEGORIES.map(async (cat) => { const [hotResults, newestResults] = await Promise.all([ tmdbFetch(cat.hotUrl, token), tmdbFetch(cat.newestUrl, token), ]); return { category: cat.category, label: cat.label, hot: hotResults.map(tmdbResultToRanking), newest: newestResults.map(tmdbResultToRanking), }; }); tmdbResults = await Promise.all(tmdbPromises); } // 4. 本站热搜 const db = getDb(); const rows = db.prepare( 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' ).all() as RankingItem[]; const newestRows = db.prepare( 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 20' ).all() as RankingItem[]; const hotsiteCategory: CategorizedRanking = { category: 'hotsite', label: '本站热搜', hot: rows, newest: newestRows, }; // 5. 合并所有结果 const [biliResults, baiduResults] = await Promise.all([ Promise.all(biliPromises), Promise.all(baiduPromises), ]); const allCategories = [...biliResults, ...baiduResults, ...tmdbResults, hotsiteCategory]; // 按 CATEGORY_ORDER 排序 allCategories.sort((a, b) => (CATEGORY_ORDER[a.category] || 99) - (CATEGORY_ORDER[b.category] || 99)); return { fetchedAt, categories: allCategories }; } export async function getCategorizedRankings(): Promise { if (isCacheValid()) { return cache!.data; } try { const data = await fetchRankings(); cache = { data, time: Date.now() }; return data; } catch (err) { console.error('[Rankings] Fetch error:', err); if (cache) return cache.data; const db = getDb(); const rows = db.prepare( 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' ).all() as RankingItem[]; return { fetchedAt: formatLocalDateTime(), categories: [{ category: 'hotsite', label: '本站热搜', hot: rows, newest: [...rows].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 20), }], }; } } export async function getRankings(): Promise { const db = getDb(); const rows = db.prepare( 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' ).all() as RankingItem[]; return rows; } export async function getHotKeywords(): Promise { const db = getDb(); const rows = db.prepare( 'SELECT keyword FROM hot_keywords ORDER BY search_count DESC LIMIT 20' ).all() as { keyword: string }[]; return rows.map(r => r.keyword); }