增加更多推荐
This commit is contained in:
@@ -6181,11 +6181,11 @@ const SiteConfigComponent = ({
|
|||||||
评论配置
|
评论配置
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 开启评论 */}
|
{/* 开启评论与相似推荐 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
开启评论
|
开启评论与相似推荐
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
@@ -6208,7 +6208,7 @@ const SiteConfigComponent = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
开启后将显示豆瓣评论。评论为逆向抓取,请自行承担责任。
|
开启后将显示豆瓣评论与相似推荐。评论为逆向抓取,请自行承担责任。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6253,7 +6253,7 @@ const SiteConfigComponent = ({
|
|||||||
<div className='p-6'>
|
<div className='p-6'>
|
||||||
<div className='flex items-center justify-between mb-6'>
|
<div className='flex items-center justify-between mb-6'>
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
开启评论功能
|
开启评论与相似推荐功能
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEnableCommentsModal(false)}
|
onClick={() => setShowEnableCommentsModal(false)}
|
||||||
|
|||||||
104
src/app/api/douban-recommendations/route.ts
Normal file
104
src/app/api/douban-recommendations/route.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
interface DoubanRecommendation {
|
||||||
|
doubanId: string;
|
||||||
|
title: string;
|
||||||
|
poster: string;
|
||||||
|
rating: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const doubanId = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!doubanId) {
|
||||||
|
return NextResponse.json({ error: 'Missing douban ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 请求豆瓣电影页面,使用和其他豆瓣API相同的请求头
|
||||||
|
const url = `https://movie.douban.com/subject/${doubanId}/`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
|
Referer: 'https://movie.douban.com/',
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
Origin: 'https://movie.douban.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch douban page' },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const recommendations: DoubanRecommendation[] = [];
|
||||||
|
|
||||||
|
console.log('开始解析豆瓣推荐');
|
||||||
|
|
||||||
|
// 解析推荐模块
|
||||||
|
$('.recommendations-bd dl').each((index, element) => {
|
||||||
|
const $dl = $(element);
|
||||||
|
|
||||||
|
// 提取链接和豆瓣ID
|
||||||
|
const $link = $dl.find('dt a');
|
||||||
|
const href = $link.attr('href') || '';
|
||||||
|
const doubanIdMatch = href.match(/subject\/(\d+)/);
|
||||||
|
const recDoubanId = doubanIdMatch ? doubanIdMatch[1] : '';
|
||||||
|
|
||||||
|
// 提取图片 - 返回原始豆瓣URL,由客户端processImageUrl根据配置处理
|
||||||
|
const poster = $link.find('img').attr('src') || '';
|
||||||
|
|
||||||
|
// 提取标题
|
||||||
|
const title = $dl.find('dd a').first().text().trim();
|
||||||
|
|
||||||
|
// 提取评分
|
||||||
|
const rating = $dl.find('dd .subject-rate').text().trim();
|
||||||
|
|
||||||
|
if (recDoubanId && title) {
|
||||||
|
recommendations.push({
|
||||||
|
doubanId: recDoubanId,
|
||||||
|
title,
|
||||||
|
poster,
|
||||||
|
rating,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('解析到推荐数:', recommendations.length);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
recommendations,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Douban recommendations fetch error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to parse douban recommendations' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const imageResponse = await fetch(imageUrl, {
|
const imageResponse = await fetch(imageUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Referer: 'https://movie.douban.com/',
|
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
|
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import EpisodeSelector from '@/components/EpisodeSelector';
|
|||||||
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import DoubanComments from '@/components/DoubanComments';
|
import DoubanComments from '@/components/DoubanComments';
|
||||||
|
import DoubanRecommendations from '@/components/DoubanRecommendations';
|
||||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
@@ -434,6 +435,20 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, currentEpisodeIndex]);
|
}, [searchParams, currentEpisodeIndex]);
|
||||||
|
|
||||||
|
// 监听 URL 参数变化,当切换到不同视频时重新加载页面
|
||||||
|
useEffect(() => {
|
||||||
|
const urlTitle = searchParams.get('title') || '';
|
||||||
|
const urlSource = searchParams.get('source') || '';
|
||||||
|
const urlId = searchParams.get('id') || '';
|
||||||
|
|
||||||
|
// 只在切换到不同视频时重新加载页面(title变化)
|
||||||
|
// 换源(source/id变化)由播放器自己处理,不需要刷新页面
|
||||||
|
if (urlTitle && urlTitle !== videoTitle) {
|
||||||
|
console.log('[PlayPage] Title changed, reloading page');
|
||||||
|
window.location.href = window.location.href;
|
||||||
|
}
|
||||||
|
}, [searchParams, videoTitle]);
|
||||||
|
|
||||||
const currentSourceRef = useRef(currentSource);
|
const currentSourceRef = useRef(currentSource);
|
||||||
const currentIdRef = useRef(currentId);
|
const currentIdRef = useRef(currentId);
|
||||||
const videoTitleRef = useRef(videoTitle);
|
const videoTitleRef = useRef(videoTitle);
|
||||||
@@ -5197,6 +5212,28 @@ function PlayPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 豆瓣推荐区域 */}
|
||||||
|
{videoDoubanId !== 0 && enableComments && (
|
||||||
|
<div className='mt-6 px-4'>
|
||||||
|
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50 overflow-hidden'>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className='px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
|
||||||
|
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
|
||||||
|
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/>
|
||||||
|
</svg>
|
||||||
|
更多推荐
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 推荐内容 */}
|
||||||
|
<div className='p-6'>
|
||||||
|
<DoubanRecommendations doubanId={videoDoubanId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 豆瓣评论区域 */}
|
{/* 豆瓣评论区域 */}
|
||||||
{videoDoubanId !== 0 && enableComments && (
|
{videoDoubanId !== 0 && enableComments && (
|
||||||
<div className='mt-6 px-4'>
|
<div className='mt-6 px-4'>
|
||||||
|
|||||||
132
src/components/DoubanRecommendations.tsx
Normal file
132
src/components/DoubanRecommendations.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
import ScrollableRow from '@/components/ScrollableRow';
|
||||||
|
|
||||||
|
interface DoubanRecommendation {
|
||||||
|
doubanId: string;
|
||||||
|
title: string;
|
||||||
|
poster: string;
|
||||||
|
rating: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DoubanRecommendationsProps {
|
||||||
|
doubanId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DoubanRecommendations({ doubanId }: DoubanRecommendationsProps) {
|
||||||
|
const [recommendations, setRecommendations] = useState<DoubanRecommendation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const enableComments = useEnableComments();
|
||||||
|
|
||||||
|
const fetchRecommendations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('正在获取推荐');
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 检查localStorage缓存
|
||||||
|
const cacheKey = `douban_recommendations_${doubanId}`;
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
const cacheAge = Date.now() - timestamp;
|
||||||
|
const cacheMaxAge = 7 * 24 * 60 * 60 * 1000; // 7天
|
||||||
|
|
||||||
|
if (cacheAge < cacheMaxAge) {
|
||||||
|
console.log('使用缓存的推荐数据');
|
||||||
|
setRecommendations(data);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析缓存失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/douban-recommendations?id=${doubanId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取推荐失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('获取到推荐:', result.recommendations);
|
||||||
|
|
||||||
|
const recommendationsData = result.recommendations || [];
|
||||||
|
setRecommendations(recommendationsData);
|
||||||
|
|
||||||
|
// 保存到localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify({
|
||||||
|
data: recommendationsData,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存缓存失败:', e);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取推荐失败:', err);
|
||||||
|
setError(err instanceof Error ? err.message : '获取推荐失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [doubanId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableComments && doubanId) {
|
||||||
|
fetchRecommendations();
|
||||||
|
}
|
||||||
|
}, [enableComments, doubanId, fetchRecommendations]);
|
||||||
|
|
||||||
|
if (!enableComments) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className='flex justify-center items-center py-8'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableRow scrollDistance={600}>
|
||||||
|
{recommendations.map((rec) => (
|
||||||
|
<div
|
||||||
|
key={rec.doubanId}
|
||||||
|
className='min-w-[96px] w-24 sm:min-w-[140px] sm:w-[140px]'
|
||||||
|
>
|
||||||
|
<VideoCard
|
||||||
|
title={rec.title}
|
||||||
|
poster={rec.poster}
|
||||||
|
rate={rec.rating}
|
||||||
|
douban_id={parseInt(rec.doubanId)}
|
||||||
|
from='douban'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -398,7 +398,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||||
showHeart: false,
|
showHeart: false,
|
||||||
showCheckCircle: false,
|
showCheckCircle: false,
|
||||||
showDoubanLink: true,
|
showDoubanLink: false,
|
||||||
showRating: !!rate,
|
showRating: !!rate,
|
||||||
showYear: false,
|
showYear: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ function getDoubanImageProxyConfig(): {
|
|||||||
export function processImageUrl(originalUrl: string): string {
|
export function processImageUrl(originalUrl: string): string {
|
||||||
if (!originalUrl) return originalUrl;
|
if (!originalUrl) return originalUrl;
|
||||||
|
|
||||||
|
// 如果已经是代理URL,直接返回
|
||||||
|
if (originalUrl.startsWith('/api/image-proxy')) {
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// 仅处理豆瓣图片代理
|
// 仅处理豆瓣图片代理
|
||||||
if (!originalUrl.includes('doubanio.com')) {
|
if (!originalUrl.includes('doubanio.com')) {
|
||||||
return originalUrl;
|
return originalUrl;
|
||||||
|
|||||||
Reference in New Issue
Block a user