feat: search page stops querying detail api

This commit is contained in:
shinya
2025-06-22 22:46:32 +08:00
parent 7061378c92
commit 201ad3c8e3
7 changed files with 255 additions and 272 deletions

View File

@@ -1,7 +1,198 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { getVideoDetail } from '@/lib/video';
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config';
import { VideoDetail } from '@/lib/types';
// 匹配 m3u8 链接的正则
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 清理 HTML 标签的工具函数
function cleanHtmlTags(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
.replace(/&nbsp;/g, ' ') // 将 &nbsp; 替换为空格
.trim(); // 去掉首尾空格
}
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === 'ffzy') {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
// 去重并清理链接前缀
matches = Array.from(new Set(matches)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf('(');
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
// 提取标题
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
// 提取描述
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
// 提取封面
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
return {
code: 200,
episodes: matches,
detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error('获取到的详情内容无效');
}
const videoDetail = data.list[0];
let episodes: string[] = [];
// 处理播放源拆分
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split('$$$');
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split('#');
episodes = episodeList
.map((ep: string) => {
const parts = ep.split('$');
return parts.length > 1 ? parts[1] : '';
})
.filter(
(url: string) =>
url && (url.startsWith('http://') || url.startsWith('https://'))
);
}
}
// 如果播放源为空,则尝试从内容中解析 m3u8
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
}
return {
code: 200,
episodes,
detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year,
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
// 获取视频详情的主要方法
async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
if (!id) {
throw new Error('缺少视频ID参数');
}
if (!/^[\w-]+$/.test(id)) {
throw new Error('无效的视频ID格式');
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error('无效的API来源');
}
if (apiSite.detail) {
return handleSpecialSourceDetail(id, apiSite);
}
return getDetailFromApi(apiSite, id);
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);

View File

@@ -1,7 +1,6 @@
import { NextResponse } from 'next/server';
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config';
import { getVideoDetail } from '@/lib/video';
export interface SearchResult {
id: string;
@@ -17,6 +16,7 @@ interface ApiSearchItem {
vod_name: string;
vod_pic: string;
vod_remarks?: string;
vod_play_url?: string;
}
async function searchFromApi(
@@ -56,14 +56,25 @@ async function searchFromApi(
}
// 处理第一页结果
const results = data.list.map((item: ApiSearchItem) => ({
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes: item.vod_remarks ? parseInt(item.vod_remarks) : undefined,
source: apiSite.key,
source_name: apiName,
}));
const results = data.list.map((item: ApiSearchItem) => {
let episodes: number | undefined = undefined;
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
const matches = item.vod_play_url.match(m3u8Regex);
episodes = matches ? matches.length : undefined;
}
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
};
});
// 获取总页数
const pageCount = data.pagecount || 1;
@@ -106,16 +117,25 @@ async function searchFromApi(
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
return [];
return pageData.list.map((item: ApiSearchItem) => ({
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes: item.vod_remarks
? parseInt(item.vod_remarks)
: undefined,
source: apiSite.key,
source_name: apiName,
}));
return pageData.list.map((item: ApiSearchItem) => {
let episodes: number | undefined = undefined;
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
const matches = item.vod_play_url.match(m3u8Regex);
episodes = matches ? matches.length : undefined;
}
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
};
});
} catch (error) {
return [];
}
@@ -135,20 +155,7 @@ async function searchFromApi(
});
}
// 获取每个结果的详情
const detailPromises = results.map(async (result: SearchResult) => {
try {
const detail = await getVideoDetail(result.id, result.source);
if (detail.episodes.length > 1) {
return { ...result, episodes: detail.episodes.length };
}
return result;
} catch (error) {
return result;
}
});
return await Promise.all(detailPromises);
return results;
} catch (error) {
return [];
}

View File

@@ -14,7 +14,7 @@ import {
isFavorited,
toggleFavorite,
} from '@/lib/db.client';
import { VideoDetail } from '@/lib/video';
import { VideoDetail } from '@/lib/types';
import PageLayout from '@/components/PageLayout';

View File

@@ -16,7 +16,7 @@ import {
savePlayRecord,
toggleFavorite,
} from '@/lib/db.client';
import { VideoDetail } from '@/lib/video';
import { VideoDetail } from '@/lib/types';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
@@ -491,28 +491,6 @@ function PlayPageClient() {
},
// 控制栏配置
controls: [
{
position: 'left',
index: 10,
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" fill="currentColor"/></svg></i>',
tooltip: '后退10秒',
click: function () {
if (artPlayerRef.current) {
artPlayerRef.current.backward = 10;
}
},
},
{
position: 'left',
index: 12,
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 13c0 4.4 3.6 8 8 8s8-3.6 8-8h-2c0 3.3-2.7 6-6 6s-6-2.7-6-6 2.7-6 6-6v4l5-5-5-5v4c-4.4 0-8 3.6-8 8z" fill="currentColor"/></svg></i>',
tooltip: '前进10秒',
click: function () {
if (artPlayerRef.current) {
artPlayerRef.current.forward = 10;
}
},
},
{
position: 'left',
index: 13,

View File

@@ -99,7 +99,7 @@ function SearchPageClient() {
return (
<PageLayout activePath='/search'>
<div className='px-10 py-8 overflow-visible'>
<div className='px-10 py-8 overflow-visible mb-10'>
{/* 搜索框 */}
<div className='mb-8'>
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>

19
src/lib/types.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover?: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
}

View File

@@ -1,212 +0,0 @@
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
// 匹配 m3u8 链接的正则
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 清理 HTML 标签的工具函数
function cleanHtmlTags(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
.replace(/&nbsp;/g, ' ') // 将 &nbsp; 替换为空格
.trim(); // 去掉首尾空格
}
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover?: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
}
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === 'ffzy') {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
// 去重并清理链接前缀
matches = Array.from(new Set(matches)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf('(');
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
// 提取标题
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
// 提取描述
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
// 提取封面
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
return {
code: 200,
episodes: matches,
detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error('获取到的详情内容无效');
}
const videoDetail = data.list[0];
let episodes: string[] = [];
// 处理播放源拆分
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split('$$$');
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split('#');
episodes = episodeList
.map((ep: string) => {
const parts = ep.split('$');
return parts.length > 1 ? parts[1] : '';
})
.filter(
(url: string) =>
url && (url.startsWith('http://') || url.startsWith('https://'))
);
}
}
// 如果播放源为空,则尝试从内容中解析 m3u8
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
}
return {
code: 200,
episodes,
detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year,
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
// 对外导出统一的获取详情方法
export async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
if (!id) {
throw new Error('缺少视频ID参数');
}
if (!/^[\w-]+$/.test(id)) {
throw new Error('无效的视频ID格式');
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error('无效的API来源');
}
if (apiSite.detail) {
return handleSpecialSourceDetail(id, apiSite);
}
return getDetailFromApi(apiSite, id);
}