Files
CloudSearch/source_clean/src/content/content.service.ts
2026-05-18 05:11:57 +08:00

326 lines
11 KiB
TypeScript
Executable File
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 { buildImageUrl, buildWebUrl, buildSearchUrl, buildDetailUrl } from './tmdb-api';
// Native fetch available in Node 20+
import { getDb } from '../database/database';
import { localTimestamp } from '../utils/time';
export interface ContentInfo {
keyword: string;
title: string;
description: string;
tags: string[];
cover: string;
source: string;
/** TMDB 详情页链接 */
tmdb_url?: string;
/** 评分 e.g. "7.3" */
rating?: string;
/** 评分人数 e.g. "12345" */
rating_count?: string;
/** 发布年份 e.g. "2025" */
year?: string;
/** 类型标签 e.g. ["动作", "科幻"] */
genres?: string[];
/** 导演 e.g. "克里斯托弗·诺兰" */
directors?: string;
/** 演员前5个 e.g. "基里安·墨菲 / 艾米莉·布朗特" */
actors?: string;
/** 制片国家/地区 e.g. "美国 / 英国" */
region?: string;
/** 片长 e.g. "180分钟" */
duration?: string;
}
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export async function getContentInfo(keyword: string): Promise<ContentInfo | null> {
if (!keyword || keyword.length < 1) return null;
const db = getDb();
const tmdbToken = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
if (!tmdbToken) return null;
const cached = db.prepare('SELECT * FROM content_cache WHERE keyword = ?').get(keyword) as any;
if (cached) {
const age = Date.now() - new Date(cached.updated_at + 'Z').getTime();
if (age < CACHE_TTL_MS) {
return rowToContentInfo(cached);
}
}
try {
const info = await fetchFromTMDB(keyword, tmdbToken);
if (info) {
db.prepare(`
INSERT OR REPLACE INTO content_cache
(keyword, title, description, tags, cover, douban_url, source, updated_at,
rating, rating_count, year, genres, directors, actors, region, duration)
VALUES (?, ?, ?, ?, ?, ?, 'tmdb', ?,
?, ?, ?, ?, ?, ?, ?, ?)
`).run(
keyword, info.title, info.description, JSON.stringify(info.tags), info.cover, info.tmdb_url || '', localTimestamp(),
info.rating || '', info.rating_count || '', info.year || '',
JSON.stringify(info.genres || []), info.directors || '', info.actors || '',
info.region || '', info.duration || ''
);
return info;
}
} catch (err) {
console.error(`[Content] Failed to fetch for "${keyword}":`, err);
}
return null;
}
function rowToContentInfo(row: any): ContentInfo {
return {
keyword: row.keyword,
title: row.title || '',
description: row.description || '',
tags: safeParseTags(row.tags),
cover: row.cover || '',
source: row.source || (row.title ? 'tmdb' : 'cache'),
tmdb_url: row.douban_url || '',
rating: row.rating || '',
rating_count: row.rating_count || '',
year: row.year || '',
genres: safeParseTags(row.genres),
directors: row.directors || '',
actors: row.actors || '',
region: row.region || '',
duration: row.duration || '',
};
}
async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<ContentInfo | null> {
// Step 1: TMDB search — search both movie and TV in parallel
let movieResults: any[] = [];
let tvResults: any[] = [];
try {
const searchUrl = buildSearchUrl('movie', keyword);
const searchResp = await fetch(searchUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
if (searchResp.ok) {
const searchData = await searchResp.json() as any;
if (Array.isArray(searchData.results)) {
movieResults = searchData.results;
}
}
} catch {
console.warn(`[Content] TMDB movie search failed for "${keyword}"`);
}
try {
const searchUrl = buildSearchUrl('tv', keyword);
const searchResp = await fetch(searchUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
if (searchResp.ok) {
const searchData = await searchResp.json() as any;
if (Array.isArray(searchData.results)) {
tvResults = searchData.results;
}
}
} catch {
console.warn(`[Content] TMDB TV search failed for "${keyword}"`);
}
// Step 2: Score and rank all results
const isChineseKeyword = /[\u4e00-\u9fff]/.test(keyword);
const kwLower = keyword.toLowerCase();
// Score function: higher = better match
function scoreResult(item: any, type: 'tv' | 'movie'): number {
const name = (type === 'tv' ? (item.name || item.original_name || '') : (item.title || item.original_title || '')).toLowerCase();
// Exact match gets highest priority
if (name === kwLower) return 100;
// Name starts with keyword
if (name.startsWith(kwLower)) return 80;
// Name contains keyword as a standalone segment
if (name.includes(kwLower)) return 60;
// Keyword contains significant portion of name
const cleanName = name.replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
if (kwLower.includes(cleanName) && cleanName.length >= 2) return 40;
// Partial match
if (name.includes(kwLower) || kwLower.includes(cleanName)) return 20;
return 0;
}
// Score all TV results
const scoredTV = tvResults.map((r: any) => ({ item: r, score: scoreResult(r, 'tv') })).filter(r => r.score > 0);
// Score all movie results
const scoredMovie = movieResults.map((r: any) => ({ item: r, score: scoreResult(r, 'movie') })).filter(r => r.score > 0);
// Sort by score descending
scoredTV.sort((a, b) => b.score - a.score);
scoredMovie.sort((a, b) => b.score - a.score);
const tvBest = scoredTV[0]?.item || null;
const movieBest = scoredMovie[0]?.item || null;
const tvBestScore = scoredTV[0]?.score || 0;
const movieBestScore = scoredMovie[0]?.score || 0;
let best: any = null;
let mediaType: 'movie' | 'tv' = 'movie';
let movie: any = null;
if (tvBest && movieBest) {
// Both have matches — score-based comparison
// For Chinese keywords: TV gets +15 score bonus to prefer series over movies
const tvScore = tvBestScore + (isChineseKeyword ? 15 : 0);
const movieScore = movieBestScore;
if (tvScore > movieScore) {
best = tvBest;
mediaType = 'tv';
} else if (movieScore > tvScore) {
best = movieBest;
mediaType = 'movie';
} else {
// Tie — prefer TV for Chinese keywords, otherwise pick higher vote count
if (isChineseKeyword) {
best = tvBest;
mediaType = 'tv';
} else {
const tvVotes = tvBest.vote_count || 0;
const movieVotes = movieBest.vote_count || 0;
best = tvVotes >= movieVotes ? tvBest : movieBest;
mediaType = tvVotes >= movieVotes ? 'tv' : 'movie';
}
}
} else if (tvBest) {
best = tvBest;
mediaType = 'tv';
} else if (movieBest) {
best = movieBest;
mediaType = 'movie';
} else if (scoredTV.length > 0 && !scoredMovie.length) {
best = scoredTV[0].item;
mediaType = 'tv';
} else if (scoredMovie.length > 0) {
best = scoredMovie[0].item;
mediaType = 'movie';
} else if (tvResults.length > 0 && !movieResults.length) {
best = tvResults[0];
mediaType = 'tv';
} else if (movieResults.length > 0) {
best = movieResults[0];
mediaType = 'movie';
} else {
return null;
}
let tmdbId = best.id;
try {
const detailUrl = buildDetailUrl(mediaType as 'movie' | 'tv', tmdbId);
const detailResp = await fetch(detailUrl, {
headers: { 'Authorization': `Bearer ${tmdbToken}` },
signal: AbortSignal.timeout(8000),
});
if (detailResp.ok) {
movie = await detailResp.json() as any;
}
} catch {
console.warn(`[Content] TMDB detail failed for ${mediaType} id ${tmdbId}`);
return null;
}
if (!movie) return null;
// Extract TMDB data (use title for movie, name for TV)
const title = movie.title || movie.name || keyword;
const rating = movie.vote_average > 0 ? String(Math.round(movie.vote_average * 10) / 10) : '';
const ratingCount = movie.vote_count ? String(movie.vote_count) : '';
// Use release_date for movie, first_air_date for TV
const year = movie.release_date ? movie.release_date.substring(0, 4) : (movie.first_air_date ? movie.first_air_date.substring(0, 4) : '');
const genres = Array.isArray(movie.genres) ? movie.genres.map((g: any) => g.name).filter(Boolean) : [];
// Directors: tv shows have limited crew data, fall back to "creator" for TV
const directors = Array.isArray(movie.credits?.crew)
? movie.credits.crew.filter((c: any) => c.job === 'Director').map((c: any) => c.name).filter(Boolean).join(' / ')
: '';
const actors = Array.isArray(movie.credits?.cast)
? movie.credits.cast.slice(0, 5).map((c: any) => c.name).filter(Boolean).join(' / ')
: '';
const region = Array.isArray(movie.production_countries)
? movie.production_countries.map((c: any) => c.name).filter(Boolean).join(' / ')
: (Array.isArray(movie.origin_country) ? movie.origin_country.join(' / ') : '');
const duration = mediaType === 'movie'
? (movie.runtime > 0 ? `${movie.runtime}分钟` : '')
: (movie.episode_run_time && movie.episode_run_time.length > 0 ? `每集${movie.episode_run_time[0]}分钟` : '');
const description = movie.overview ? movie.overview.substring(0, 200) : '';
const cover = movie.poster_path ? buildImageUrl(movie.poster_path) : '';
// TMDB detail page URL
const tmdbUrl = buildWebUrl(mediaType as 'movie' | 'tv', tmdbId);
// Generate tags from keyword + title
const tags = genTags({ keyword, title });
// Build description fallback
let desc = description;
if (!desc) {
const parts: string[] = [];
if (year) parts.push(`${year}`);
if (genres.length > 0) parts.push(genres.slice(0, 3).join(' / '));
if (duration) parts.push(duration);
desc = parts.length > 0 ? parts.join(' · ') : '';
}
return {
keyword,
title,
description: desc,
tags,
cover,
source: 'tmdb',
tmdb_url: tmdbUrl,
rating,
rating_count: ratingCount,
year,
genres,
directors,
actors,
region,
duration,
};
}
function genTags(opts: { keyword: string; title: string }): string[] {
const { keyword, title } = opts;
const tags: string[] = [];
if (keyword.length <= 8) tags.push(keyword);
const txt = (title + ' ' + keyword).toLowerCase();
const isDonghua = /动画|动漫/i.test(txt);
if (isDonghua) {
tags.push('动画'); tags.push('国漫');
} else {
tags.push('电影');
}
const genreMap: Record<string, string[]> = {
'动画': ['动画'], '动漫': ['动漫'], '国漫': ['国漫'],
'剧场版': ['剧场版'], '年番': ['年番'],
'动作': ['动作'], '奇幻': ['奇幻'], '玄幻': ['玄幻'],
'仙侠': ['仙侠'], '古装': ['古装'], '爱情': ['爱情'],
'科幻': ['科幻'], '喜剧': ['喜剧'], '悬疑': ['悬疑'],
'冒险': ['冒险'], '战争': ['战争'], '纪录': ['纪录片'], '真人': ['真人秀'],
};
for (const [key, vals] of Object.entries(genreMap)) {
if (txt.includes(key)) {
for (const v of vals) { if (!tags.includes(v)) tags.push(v); }
}
}
return tags;
}
function safeParseTags(tagsStr: string | null | undefined): string[] {
if (!tagsStr) return [];
try {
const parsed = JSON.parse(tagsStr);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}