From f56f45d85f871bed0a951e00307a62821de66b88 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 10 May 2026 18:02:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9F=AD=E5=89=A7=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E6=9B=B4=E5=A4=9A=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/duanju/categories/route.ts | 89 ++++++++ src/app/api/duanju/videos/route.ts | 148 +++++++++++++ src/app/duanju/page.tsx | 282 +++++++++++++++++++++++++ src/app/page.tsx | 7 + src/lib/duanju.ts | 37 +++- 5 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 src/app/api/duanju/categories/route.ts create mode 100644 src/app/api/duanju/videos/route.ts create mode 100644 src/app/duanju/page.tsx diff --git a/src/app/api/duanju/categories/route.ts b/src/app/api/duanju/categories/route.ts new file mode 100644 index 0000000..975ec62 --- /dev/null +++ b/src/app/api/duanju/categories/route.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { API_CONFIG, getCacheTime, getConfig } from '@/lib/config'; +import { getDuanjuSources, isDuanjuTypeName } from '@/lib/duanju'; +import { yellowWords } from '@/lib/yellow'; + +export const runtime = 'nodejs'; + +interface CmsClassResponse { + class?: Array<{ + type_id: string | number; + type_name: string; + }>; +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const sourceKey = searchParams.get('source'); + + if (!sourceKey) { + return NextResponse.json( + { code: 400, message: '缺少参数: source', data: [] }, + { status: 400 } + ); + } + + try { + const sources = await getDuanjuSources(); + const targetSource = sources.find((source) => source.key === sourceKey); + + if (!targetSource) { + return NextResponse.json( + { code: 404, message: `未找到短剧采集源: ${sourceKey}`, data: [] }, + { status: 404 } + ); + } + + const response = await fetch(`${targetSource.api}?ac=list`, { + headers: API_CONFIG.search.headers, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new Error('获取分类列表失败'); + } + + const data: CmsClassResponse = await response.json(); + const config = await getConfig(); + + const categories = (data.class || []) + .filter((item) => { + const typeName = item.type_name || ''; + if (!isDuanjuTypeName(typeName)) return false; + if (!config.SiteConfig.DisableYellowFilter) { + return !yellowWords.some((word: string) => typeName.includes(word)); + } + return true; + }) + .map((item) => ({ + id: item.type_id.toString(), + name: item.type_name, + })); + + const defaultCategory = categories[0] || null; + + const cacheTime = await getCacheTime(); + return NextResponse.json( + { code: 200, message: '获取成功', data: categories, defaultCategory }, + { + headers: { + 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, + }, + } + ); + } catch (error) { + console.error('获取短剧分类失败:', error); + return NextResponse.json( + { + code: 500, + message: '获取短剧分类失败', + data: [], + error: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/duanju/videos/route.ts b/src/app/api/duanju/videos/route.ts new file mode 100644 index 0000000..a66875f --- /dev/null +++ b/src/app/api/duanju/videos/route.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { API_CONFIG, getCacheTime } from '@/lib/config'; +import { getDuanjuSources } from '@/lib/duanju'; +import { SearchResult } from '@/lib/types'; +import { cleanHtmlTags } from '@/lib/utils'; + +export const runtime = 'nodejs'; + +interface CmsVideoItem { + vod_id: string | number; + vod_name: string; + vod_pic?: string; + vod_remarks?: string; + vod_year?: string; + vod_play_from?: string; + vod_play_url?: string; + vod_class?: string; + vod_content?: string; + vod_douban_id?: number; + type_name?: string; +} + +interface CmsVideoResponse { + list?: CmsVideoItem[]; + total?: number; + page?: number; + pagecount?: number; +} + +function parseEpisodes(item: CmsVideoItem) { + const episodes: string[] = []; + const episodesTitles: string[] = []; + + if (!item.vod_play_url) { + return { episodes, episodesTitles }; + } + + const playSources = item.vod_play_url.split('$$$'); + playSources.forEach((sourceUrl) => { + sourceUrl.split('#').forEach((episodeStr) => { + const [name, url] = episodeStr.split('$'); + if (name && url) { + episodes.push(url.trim()); + episodesTitles.push(name.trim()); + } + }); + }); + + return { episodes, episodesTitles }; +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const sourceKey = searchParams.get('source'); + const categoryId = searchParams.get('categoryId'); + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1); + + if (!sourceKey) { + return NextResponse.json( + { code: 400, message: '缺少参数: source', data: [] }, + { status: 400 } + ); + } + + if (!categoryId) { + return NextResponse.json( + { code: 400, message: '缺少参数: categoryId', data: [] }, + { status: 400 } + ); + } + + try { + const sources = await getDuanjuSources(); + const targetSource = sources.find((source) => source.key === sourceKey); + + if (!targetSource) { + return NextResponse.json( + { code: 404, message: `未找到短剧采集源: ${sourceKey}`, data: [] }, + { status: 404 } + ); + } + + const response = await fetch( + `${targetSource.api}?ac=videolist&t=${encodeURIComponent(categoryId)}&pg=${page}`, + { + headers: API_CONFIG.search.headers, + signal: AbortSignal.timeout(10000), + } + ); + + if (!response.ok) { + throw new Error('获取短剧列表失败'); + } + + const videoData: CmsVideoResponse = await response.json(); + + const results: SearchResult[] = (videoData.list || []).map((item) => { + const { episodes, episodesTitles } = parseEpisodes(item); + + return { + id: item.vod_id.toString(), + title: (item.vod_name || '').trim().replace(/\s+/g, ' '), + poster: item.vod_pic || '', + year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || item.vod_year : 'unknown', + episodes, + episodes_titles: episodesTitles, + source: targetSource.key, + source_name: targetSource.name, + class: item.vod_class, + desc: cleanHtmlTags(item.vod_content || ''), + type_name: item.type_name, + douban_id: item.vod_douban_id, + vod_remarks: item.vod_remarks, + }; + }); + + const cacheTime = await getCacheTime(); + return NextResponse.json( + { + code: 200, + message: '获取成功', + data: results, + total: videoData.total || 0, + page: videoData.page || page, + pageCount: videoData.pagecount || (results.length > 0 ? page + 1 : page), + }, + { + headers: { + 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, + }, + } + ); + } catch (error) { + console.error('获取短剧列表失败:', error); + return NextResponse.json( + { + code: 500, + message: '获取短剧列表失败', + data: [], + error: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/duanju/page.tsx b/src/app/duanju/page.tsx new file mode 100644 index 0000000..317638c --- /dev/null +++ b/src/app/duanju/page.tsx @@ -0,0 +1,282 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */ +'use client'; + +import { ArrowLeft, Loader2 } from 'lucide-react'; +import Link from 'next/link'; +import { Suspense, useEffect, useRef, useState } from 'react'; + +import { SearchResult } from '@/lib/types'; + +import PageLayout from '@/components/PageLayout'; +import VideoCard from '@/components/VideoCard'; + +interface DuanjuSource { + key: string; + name: string; + api: string; + typeId?: string; + typeName?: string; +} + +function DuanjuPageClient() { + const [sources, setSources] = useState([]); + const [selectedSource, setSelectedSource] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(''); + const [videos, setVideos] = useState([]); + const [isLoadingSources, setIsLoadingSources] = useState(true); + const [isLoadingVideos, setIsLoadingVideos] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const loadMoreRef = useRef(null); + const sourceScrollContainerRef = useRef(null); + const isDraggingRef = useRef(false); + const startXRef = useRef(0); + const scrollLeftRef = useRef(0); + + useEffect(() => { + const fetchSources = async () => { + setIsLoadingSources(true); + try { + const response = await fetch('/api/duanju/sources'); + const data = await response.json(); + if (data.code === 200 && Array.isArray(data.data)) { + setSources(data.data); + if (data.data.length > 0) { + setSelectedSource(data.data[0].key); + setSelectedCategory(data.data[0].typeId || ''); + } + } + } catch (error) { + console.error('Failed to load duanju sources:', error); + } finally { + setIsLoadingSources(false); + } + }; + + fetchSources(); + }, []); + + const handleSourceChange = (sourceKey: string) => { + const source = sources.find((item) => item.key === sourceKey); + setSelectedSource(sourceKey); + setSelectedCategory(source?.typeId || ''); + setCurrentPage(1); + setVideos([]); + setHasMore(true); + }; + + useEffect(() => { + if (!selectedSource || !selectedCategory) return; + + const fetchVideos = async () => { + setIsLoadingVideos(true); + try { + const response = await fetch( + `/api/duanju/videos?source=${encodeURIComponent(selectedSource)}&categoryId=${encodeURIComponent(selectedCategory)}&page=${currentPage}` + ); + const data = await response.json(); + if (data.code === 200 && Array.isArray(data.data)) { + if (currentPage === 1) { + setVideos(data.data); + } else { + setVideos((prev) => [...prev, ...data.data]); + } + setHasMore(data.page < data.pageCount); + } + } catch (error) { + console.error('Failed to load duanju videos:', error); + } finally { + setIsLoadingVideos(false); + } + }; + + fetchVideos(); + }, [selectedSource, selectedCategory, currentPage]); + + useEffect(() => { + if (!loadMoreRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + const target = entries[0]; + if (target.isIntersecting && hasMore && !isLoadingVideos) { + setCurrentPage((prev) => prev + 1); + } + }, + { rootMargin: '240px 0px', threshold: 0.1 } + ); + + observer.observe(loadMoreRef.current); + + return () => { + observer.disconnect(); + }; + }, [hasMore, isLoadingVideos]); + + return ( + +
+
+
+

+ 短剧 +

+

+ 浏览所有采集源中的短剧内容 +

+
+ + + 返回首页 + +
+ +
+
+
+ 服务 +
+ {isLoadingSources ? ( +
+ + + 加载采集源中... + +
+ ) : sources.length === 0 ? ( +
+ + 暂无包含短剧分类的采集源 + +
+ ) : ( +
+
{ + if (!sourceScrollContainerRef.current) return; + isDraggingRef.current = true; + startXRef.current = e.pageX - sourceScrollContainerRef.current.offsetLeft; + scrollLeftRef.current = sourceScrollContainerRef.current.scrollLeft; + sourceScrollContainerRef.current.style.cursor = 'grabbing'; + sourceScrollContainerRef.current.style.userSelect = 'none'; + }} + onMouseLeave={() => { + if (!sourceScrollContainerRef.current) return; + isDraggingRef.current = false; + sourceScrollContainerRef.current.style.cursor = 'grab'; + sourceScrollContainerRef.current.style.userSelect = 'auto'; + }} + onMouseUp={() => { + if (!sourceScrollContainerRef.current) return; + isDraggingRef.current = false; + sourceScrollContainerRef.current.style.cursor = 'grab'; + sourceScrollContainerRef.current.style.userSelect = 'auto'; + }} + onMouseMove={(e) => { + if (!isDraggingRef.current || !sourceScrollContainerRef.current) return; + e.preventDefault(); + const x = e.pageX - sourceScrollContainerRef.current.offsetLeft; + const walk = (x - startXRef.current) * 2; + sourceScrollContainerRef.current.scrollLeft = scrollLeftRef.current - walk; + }} + > +
+ {sources.map((source) => ( + + ))} +
+
+
+ )} +
+
+ + {selectedSource && !selectedCategory && ( +
+ 当前采集源暂无短剧分类 +
+ )} + + {selectedSource && selectedCategory && ( +
+
+

+ 短剧列表 +

+
+ + {isLoadingVideos && currentPage === 1 ? ( +
+
+
+ ) : videos.length === 0 ? ( +
+ 暂无短剧 +
+ ) : ( + <> +
+ {videos.map((item) => ( +
+ +
+ ))} +
+ +
+ {isLoadingVideos && ( +
+ )} + {!hasMore && videos.length > 0 && ( + + 没有更多了 + + )} +
+ + )} +
+ )} +
+
+ ); +} + +export default function DuanjuPage() { + return ( + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d8060c1..f3251c8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -501,6 +501,13 @@ function HomeClient() {

热播短剧

+ + 查看更多 + + {loading diff --git a/src/lib/duanju.ts b/src/lib/duanju.ts index 095101c..d99b8ff 100644 --- a/src/lib/duanju.ts +++ b/src/lib/duanju.ts @@ -14,6 +14,17 @@ export interface DuanjuSource { key: string; name: string; api: string; + typeId?: string; + typeName?: string; +} + +export function isDuanjuTypeName(typeName: string): boolean { + const normalizedTypeName = typeName.toLowerCase(); + return ( + normalizedTypeName.includes('短剧') || + normalizedTypeName.includes('短视频') || + normalizedTypeName.includes('微短剧') + ); } /** @@ -26,7 +37,16 @@ export async function getDuanjuSources(): Promise { if (cachedData !== null) { // 有缓存,直接返回(getGlobalValue 已经处理了序列化问题) - return cachedData ? JSON.parse(cachedData) : []; + const cachedSources: DuanjuSource[] = cachedData ? JSON.parse(cachedData) : []; + // 旧版本缓存只保存采集源,不包含短剧分类 ID。缺少 typeId 时自动重建缓存。 + if ( + cachedSources.length === 0 || + cachedSources.every((source) => source.typeId) + ) { + return cachedSources; + } + + console.log('短剧视频源缓存缺少分类信息,重新筛选...'); } // 没有缓存,开始筛选 @@ -56,20 +76,17 @@ export async function getDuanjuSources(): Promise { // 检查是否有短剧分类 if (data.class && Array.isArray(data.class)) { - const hasDuanju = data.class.some((item) => { - const typeName = item.type_name?.toLowerCase() || ''; - return ( - typeName.includes('短剧') || - typeName.includes('短视频') || - typeName.includes('微短剧') - ); - }); + const duanjuType = data.class.find((item) => + isDuanjuTypeName(item.type_name || '') + ); - if (hasDuanju) { + if (duanjuType) { return { key: source.key, name: source.name, api: source.api, + typeId: duanjuType.type_id.toString(), + typeName: duanjuType.type_name, }; } }