2025-03-03 21:16:16 +08:00
|
|
|
|
import type { APIRoute } from 'astro';
|
|
|
|
|
import { load } from 'cheerio';
|
|
|
|
|
|
|
|
|
|
// 添加服务器渲染标记
|
|
|
|
|
export const prerender = false;
|
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
// 请求配置常量
|
|
|
|
|
const MAX_RETRIES = 0; // 最大重试次数
|
|
|
|
|
const RETRY_DELAY = 1500; // 重试延迟(毫秒)
|
|
|
|
|
const REQUEST_TIMEOUT = 10000; // 请求超时时间(毫秒)
|
|
|
|
|
|
2025-03-27 21:40:41 +08:00
|
|
|
|
// 生成随机的bid Cookie值
|
|
|
|
|
function generateBid() {
|
|
|
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
|
|
|
let result = '';
|
|
|
|
|
for (let i = 0; i < 11; i++) {
|
|
|
|
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
// 添加延迟函数
|
|
|
|
|
function delay(ms: number) {
|
|
|
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 带超时的 fetch 函数
|
|
|
|
|
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number) {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const { signal } = controller;
|
|
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
controller.abort();
|
|
|
|
|
}, timeoutMs);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
...options,
|
|
|
|
|
signal
|
|
|
|
|
});
|
|
|
|
|
return response;
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-03 21:16:16 +08:00
|
|
|
|
export const GET: APIRoute = async ({ request }) => {
|
|
|
|
|
const url = new URL(request.url);
|
|
|
|
|
const type = url.searchParams.get('type') || 'movie';
|
|
|
|
|
const start = parseInt(url.searchParams.get('start') || '0');
|
2025-03-10 17:22:18 +08:00
|
|
|
|
const doubanId = url.searchParams.get('doubanId'); // 从查询参数获取 doubanId
|
2025-03-03 21:16:16 +08:00
|
|
|
|
|
2025-03-10 17:22:18 +08:00
|
|
|
|
if (!doubanId) {
|
|
|
|
|
return new Response(JSON.stringify({ error: '缺少豆瓣ID' }), {
|
|
|
|
|
status: 400,
|
2025-04-19 11:43:47 +08:00
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Cache-Control': 'no-store, max-age=0'
|
|
|
|
|
}
|
2025-03-10 17:22:18 +08:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
// 添加缓存键的构建,用于区分不同的请求
|
|
|
|
|
const cacheKey = `douban_${type}_${doubanId}_${start}`;
|
|
|
|
|
|
|
|
|
|
// 尝试从缓存获取数据
|
2025-03-03 21:16:16 +08:00
|
|
|
|
try {
|
2025-04-19 11:43:47 +08:00
|
|
|
|
// 如果有缓存系统,可以在这里检查和返回缓存数据
|
|
|
|
|
|
|
|
|
|
// 重试逻辑
|
|
|
|
|
let retries = 0;
|
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
|
|
|
|
|
|
while (retries <= MAX_RETRIES) {
|
|
|
|
|
try {
|
|
|
|
|
let doubanUrl = '';
|
|
|
|
|
if (type === 'book') {
|
|
|
|
|
doubanUrl = `https://book.douban.com/people/${doubanId}/collect?start=${start}&sort=time&rating=all&filter=all&mode=grid`;
|
|
|
|
|
} else {
|
|
|
|
|
doubanUrl = `https://movie.douban.com/people/${doubanId}/collect?start=${start}&sort=time&rating=all&filter=all&mode=grid`;
|
|
|
|
|
}
|
2025-03-03 21:16:16 +08:00
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
// 生成随机bid
|
|
|
|
|
const bid = generateBid();
|
|
|
|
|
|
|
|
|
|
// 随机化一些请求参数,减少被检测的风险
|
|
|
|
|
const userAgents = [
|
|
|
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
|
|
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
|
|
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
|
|
|
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
|
2025-03-27 21:40:41 +08:00
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
// 使用带超时的fetch发送请求
|
|
|
|
|
const response = await fetchWithTimeout(doubanUrl, {
|
|
|
|
|
headers: {
|
|
|
|
|
'User-Agent': randomUserAgent,
|
|
|
|
|
'Sec-Fetch-Site': 'none',
|
|
|
|
|
'Sec-Fetch-Mode': 'navigate',
|
|
|
|
|
'Sec-Fetch-User': '?1',
|
|
|
|
|
'Sec-Fetch-Dest': 'document',
|
|
|
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
|
|
|
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
|
|
|
'Cache-Control': 'max-age=0',
|
|
|
|
|
'Cookie': `bid=${bid}`
|
|
|
|
|
}
|
|
|
|
|
}, REQUEST_TIMEOUT);
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`豆瓣请求失败,状态码: ${response.status}`);
|
2025-03-03 21:16:16 +08:00
|
|
|
|
}
|
2025-04-19 11:43:47 +08:00
|
|
|
|
|
|
|
|
|
const html = await response.text();
|
|
|
|
|
|
|
|
|
|
// 检查是否包含验证码页面的特征
|
|
|
|
|
if (html.includes('验证码') || html.includes('captcha') || html.includes('too many requests')) {
|
|
|
|
|
throw new Error('请求被豆瓣限制,需要验证码');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const $ = load(html);
|
|
|
|
|
|
|
|
|
|
// 添加类型定义
|
|
|
|
|
interface DoubanItem {
|
|
|
|
|
imageUrl: string;
|
|
|
|
|
title: string;
|
|
|
|
|
subtitle: string;
|
|
|
|
|
link: string;
|
|
|
|
|
intro: string;
|
|
|
|
|
rating: number;
|
|
|
|
|
date: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items: DoubanItem[] = [];
|
|
|
|
|
|
|
|
|
|
// 尝试不同的选择器
|
|
|
|
|
let itemSelector = '.item.comment-item';
|
|
|
|
|
let itemCount = $(itemSelector).length;
|
|
|
|
|
|
|
|
|
|
if (itemCount === 0) {
|
|
|
|
|
// 尝试其他可能的选择器
|
|
|
|
|
itemSelector = '.subject-item';
|
|
|
|
|
itemCount = $(itemSelector).length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (itemCount === 0) {
|
|
|
|
|
// 如果两个选择器都没有找到内容,可能是页面结构变化或被封锁
|
|
|
|
|
console.error('未找到内容,页面结构可能已变化');
|
|
|
|
|
|
|
|
|
|
// 记录HTML以便调试
|
|
|
|
|
console.debug('HTML片段:', html.substring(0, 500) + '...');
|
|
|
|
|
|
|
|
|
|
if (retries < MAX_RETRIES) {
|
|
|
|
|
retries++;
|
|
|
|
|
// 增加重试延迟,避免频繁请求
|
|
|
|
|
await delay(RETRY_DELAY * retries);
|
|
|
|
|
continue;
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error('未找到电影/图书内容,可能是页面结构已变化');
|
2025-03-27 21:40:41 +08:00
|
|
|
|
}
|
2025-03-03 21:16:16 +08:00
|
|
|
|
}
|
2025-03-27 21:40:41 +08:00
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
$(itemSelector).each((_, element) => {
|
|
|
|
|
const $element = $(element);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 根据选择器调整查找逻辑
|
|
|
|
|
let imageUrl = '';
|
|
|
|
|
let title = '';
|
|
|
|
|
let subtitle = '';
|
|
|
|
|
let link = '';
|
|
|
|
|
let intro = '';
|
|
|
|
|
let rating = 0;
|
|
|
|
|
let date = '';
|
|
|
|
|
|
|
|
|
|
if (itemSelector === '.item.comment-item') {
|
|
|
|
|
// 原始逻辑
|
|
|
|
|
imageUrl = $element.find('.pic img').attr('src') || '';
|
|
|
|
|
title = $element.find('.title a em').text().trim();
|
|
|
|
|
subtitle = $element.find('.title a').text().replace(title, '').trim();
|
|
|
|
|
link = $element.find('.title a').attr('href') || '';
|
|
|
|
|
intro = $element.find('.intro').text().trim();
|
|
|
|
|
|
|
|
|
|
// 获取评分,从rating1-t到rating5-t
|
|
|
|
|
for (let i = 1; i <= 5; i++) {
|
|
|
|
|
if ($element.find(`.rating${i}-t`).length > 0) {
|
|
|
|
|
rating = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
date = $element.find('.date').text().trim();
|
|
|
|
|
} else if (itemSelector === '.subject-item') {
|
|
|
|
|
// 新的图书页面结构
|
|
|
|
|
imageUrl = $element.find('.pic img').attr('src') || '';
|
|
|
|
|
title = $element.find('.info h2 a').text().trim();
|
|
|
|
|
link = $element.find('.info h2 a').attr('href') || '';
|
|
|
|
|
intro = $element.find('.info .pub').text().trim();
|
|
|
|
|
|
|
|
|
|
// 获取评分
|
|
|
|
|
const ratingClass = $element.find('.rating-star').attr('class') || '';
|
|
|
|
|
const ratingMatch = ratingClass.match(/rating(\d)-t/);
|
|
|
|
|
if (ratingMatch) {
|
|
|
|
|
rating = parseInt(ratingMatch[1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
date = $element.find('.info .date').text().trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 确保所有字段至少有空字符串
|
|
|
|
|
items.push({
|
|
|
|
|
imageUrl: imageUrl || '',
|
|
|
|
|
title: title || '',
|
|
|
|
|
subtitle: subtitle || '',
|
|
|
|
|
link: link || '',
|
|
|
|
|
intro: intro || '',
|
|
|
|
|
rating: rating || 0,
|
|
|
|
|
date: date || ''
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('解析项目时出错:', error);
|
|
|
|
|
// 继续处理下一个项目,而不是终止整个循环
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 改进分页信息获取逻辑
|
|
|
|
|
let currentPage = 1;
|
|
|
|
|
let totalPages = 1;
|
|
|
|
|
|
|
|
|
|
// 尝试从当前页码元素获取信息
|
|
|
|
|
if ($('.paginator .thispage').length > 0) {
|
|
|
|
|
currentPage = parseInt($('.paginator .thispage').text() || '1');
|
|
|
|
|
// 豆瓣可能不直接提供总页数,需要计算
|
|
|
|
|
const paginatorLinks = $('.paginator a');
|
|
|
|
|
let maxPage = currentPage;
|
|
|
|
|
paginatorLinks.each((_, el) => {
|
|
|
|
|
const pageNum = parseInt($(el).text());
|
|
|
|
|
if (!isNaN(pageNum) && pageNum > maxPage) {
|
|
|
|
|
maxPage = pageNum;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
totalPages = maxPage;
|
2025-03-27 21:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
const pagination = {
|
|
|
|
|
current: currentPage,
|
|
|
|
|
total: totalPages,
|
|
|
|
|
hasNext: $('.paginator .next a').length > 0,
|
|
|
|
|
hasPrev: $('.paginator .prev a').length > 0
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 如果有缓存系统,可以在这里保存数据到缓存
|
|
|
|
|
|
|
|
|
|
return new Response(JSON.stringify({ items, pagination }), {
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Cache-Control': 'public, s-maxage=300', // 5分钟服务器缓存
|
|
|
|
|
'CDN-Cache-Control': 'public, max-age=300' // CDN缓存
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`尝试第 ${retries + 1}/${MAX_RETRIES + 1} 次失败:`, error);
|
|
|
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
|
|
|
|
|
|
|
|
if (retries < MAX_RETRIES) {
|
|
|
|
|
retries++;
|
|
|
|
|
// 增加重试延迟,避免频繁请求
|
|
|
|
|
await delay(RETRY_DELAY * retries);
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
2025-03-03 21:16:16 +08:00
|
|
|
|
}
|
2025-04-19 11:43:47 +08:00
|
|
|
|
}
|
2025-03-03 21:16:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-04-19 11:43:47 +08:00
|
|
|
|
// 所有尝试都失败了
|
|
|
|
|
console.error('所有尝试都失败了:', lastError);
|
|
|
|
|
return new Response(JSON.stringify({
|
|
|
|
|
error: '获取豆瓣数据失败',
|
|
|
|
|
message: lastError?.message || '未知错误'
|
|
|
|
|
}), {
|
|
|
|
|
status: 500,
|
2025-03-03 21:16:16 +08:00
|
|
|
|
headers: {
|
2025-04-19 11:43:47 +08:00
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Cache-Control': 'no-store, max-age=0'
|
2025-03-03 21:16:16 +08:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
2025-04-19 11:43:47 +08:00
|
|
|
|
console.error('处理请求时出错:', error);
|
|
|
|
|
return new Response(JSON.stringify({
|
|
|
|
|
error: '获取豆瓣数据失败',
|
|
|
|
|
message: error instanceof Error ? error.message : '未知错误'
|
|
|
|
|
}), {
|
2025-03-03 21:16:16 +08:00
|
|
|
|
status: 500,
|
|
|
|
|
headers: {
|
2025-04-19 11:43:47 +08:00
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Cache-Control': 'no-store, max-age=0'
|
2025-03-03 21:16:16 +08:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|