chore: initial commit - CloudSearch v0.0.2

This commit is contained in:
2026-05-15 05:50:50 +08:00
commit d83225d736
102 changed files with 37926 additions and 0 deletions

View File

@@ -0,0 +1,351 @@
// 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<string, number> = {
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<RankingItem[]> {
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<RankingItem[]> {
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<any[]> {
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<CategorizedResponse> {
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<CategorizedResponse> {
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<RankingItem[]> {
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<string[]> {
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);
}