删除多余的豆瓣文件,优化文件结构,增加微信读取获取

This commit is contained in:
lsy 2025-05-19 12:59:10 +08:00
parent 2f1fc8d285
commit 7c00f32271
11 changed files with 1072 additions and 806 deletions

View File

@ -1,324 +1,469 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactMasonryCss from 'react-masonry-css';
interface DoubanItem {
imageUrl: string;
title: string;
subtitle: string;
link: string;
intro: string;
rating: number;
date: string;
}
interface Pagination {
current: number;
total: number;
hasNext: boolean;
hasPrev: boolean;
}
import React, { useEffect, useRef, useState, useCallback } from "react";
interface DoubanCollectionProps {
type: 'movie' | 'book';
doubanId?: string; // 可选参数,使其与 MediaGrid 保持一致
className?: string; // 添加自定义类名
type: "movie" | "book";
doubanId: string;
className?: string; // 添加可选的className属性以提高灵活性
}
interface DoubanItem {
title: string;
imageUrl: string;
link: string;
}
const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type, doubanId, className = '' }) => {
const [items, setItems] = useState<DoubanItem[]>([]);
const [pagination, setPagination] = useState<Pagination>({ current: 1, total: 1, hasNext: false, hasPrev: false });
const [loading, setLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const [isPageChanging, setIsPageChanging] = useState(false);
// 使用 ref 避免竞态条件
const itemsPerPage = 15;
const contentListRef = useRef<HTMLDivElement>(null);
const lastScrollTime = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
const isMountedRef = useRef(true);
const observerRef = useRef<IntersectionObserver | null>(null);
const scrollDetectorRef = useRef<HTMLDivElement | null>(null);
// 添加一个 ref 来标记组件是否已挂载
const isMountedRef = useRef<boolean>(true);
// 标题文本
const titleText = useMemo(() =>
type === 'movie' ? '观影记录' : '读书记录',
[type]);
// 使用ref来跟踪关键状态避免闭包问题
const stateRef = useRef({
isLoading: false,
hasMoreContent: true,
currentPage: 1,
error: null as string | null,
});
// 加载动画组件
const LoadingSpinner = useCallback(() => (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
), []);
// 封装fetch函数使用useCallback避免重新创建
const fetchDoubanData = useCallback(async (page = 1, append = false) => {
// 使用ref中的最新状态
if (
stateRef.current.isLoading ||
(!append && !stateRef.current.hasMoreContent) ||
(append && !stateRef.current.hasMoreContent)
) {
return;
}
// 公共标题组件
const Title = useCallback(() => (
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
{titleText}
</h2>
), [titleText]);
const fetchData = useCallback(async (start = 0) => {
// 如果已经有一个请求在进行中,取消它
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 创建新的 AbortController
// 创建新的AbortController
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
const params = new URLSearchParams();
params.append('type', type);
params.append('start', start.toString());
if (doubanId) {
params.append('doubanId', doubanId);
// 更新状态和ref
setIsLoading(true);
stateRef.current.isLoading = true;
// 只在首次加载时清除错误
if (!append) {
setError(null);
stateRef.current.error = null;
}
const url = `/api/douban?${params.toString()}`;
const start = (page - 1) * itemsPerPage;
try {
const response = await fetch(url, {
signal: abortControllerRef.current.signal
});
const response = await fetch(
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
{ signal: abortControllerRef.current.signal }
);
// 如果组件已卸载,不继续处理
if (!isMountedRef.current) return;
if (!response.ok) {
throw new Error(`获取数据失败:状态码 ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
setItems(data.items || []);
setPagination(data.pagination || { current: 1, total: 1, hasNext: false, hasPrev: false });
} catch (err) {
// 如果是取消请求的错误,不设置错误状态
if (err instanceof Error && err.name === 'AbortError') {
// 检查组件是否已卸载,如果卸载则不继续处理
if (!isMountedRef.current) {
return;
}
// 如果组件已卸载,不设置状态
if (!isMountedRef.current) return;
if (!response.ok) {
// 解析响应内容,获取详细错误信息
let errorMessage = `获取${type === "movie" ? "电影" : "图书"}数据失败`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMessage = errorData.error;
if (errorData.message) {
errorMessage += `: ${errorData.message}`;
}
}
} catch (e) {
// 无法解析JSON使用默认错误信息
}
// 针对不同错误提供更友好的提示
if (response.status === 403) {
errorMessage = "豆瓣接口访问受限,可能是请求过于频繁,请稍后再试";
} else if (response.status === 404) {
// 对于404错误如果是追加模式说明已经到了最后一页设置hasMoreContent为false
if (append) {
setHasMoreContent(false);
stateRef.current.hasMoreContent = false;
setIsLoading(false);
stateRef.current.isLoading = false;
return; // 直接返回,不设置错误,不清空已有数据
} else {
errorMessage = "未找到相关内容请检查豆瓣ID是否正确";
}
}
// 设置错误状态和ref
setError(errorMessage);
stateRef.current.error = errorMessage;
// 只有非追加模式才清空数据
if (!append) {
setItems([]);
}
throw new Error(errorMessage);
}
const data = await response.json();
// 再次检查组件是否已卸载
if (!isMountedRef.current) {
return;
}
if (data.items.length === 0) {
// 如果返回的项目为空,则认为已经没有更多内容
setHasMoreContent(false);
stateRef.current.hasMoreContent = false;
if (!append) {
setItems([]);
}
} else {
if (append) {
setItems((prev) => {
const newItems = [...prev, ...data.items];
return newItems;
});
} else {
setItems(data.items);
}
// 更新页码状态和ref
setCurrentPage(data.pagination.current);
stateRef.current.currentPage = data.pagination.current;
// 更新是否有更多内容的状态和ref
const newHasMoreContent = data.pagination.hasNext;
setHasMoreContent(newHasMoreContent);
stateRef.current.hasMoreContent = newHasMoreContent;
}
} catch (error) {
// 检查组件是否已卸载
if (!isMountedRef.current) {
return;
}
console.error('获取豆瓣数据失败:', err);
setError(err instanceof Error ? err.message : '未知错误');
setItems([]);
// 如果是取消的请求,不显示错误
if (error instanceof Error && error.name === 'AbortError') {
console.log('请求被取消', error.message);
// 如果是取消请求,重置加载状态但不显示错误
setIsLoading(false);
stateRef.current.isLoading = false;
return;
}
// 只有在非追加模式下才清空已加载的内容
if (!append) {
setItems([]);
}
} finally {
// 如果组件已卸载,不设置状态
if (!isMountedRef.current) return;
setLoading(false);
setIsPageChanging(false);
// 检查组件是否已卸载
if (isMountedRef.current) {
// 重置加载状态
setIsLoading(false);
stateRef.current.isLoading = false;
}
}
}, [type, doubanId]);
// 处理滚动事件
const handleScroll = useCallback(() => {
// 获取关键滚动值
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollPosition = scrollY + windowHeight;
const threshold = documentHeight - 300;
// 限制滚动日志频率,每秒最多输出一次
const now = Date.now();
if (now - lastScrollTime.current < 1000) {
return;
}
lastScrollTime.current = now;
// 使用ref中的最新状态来检查
if (
stateRef.current.isLoading ||
!stateRef.current.hasMoreContent ||
stateRef.current.error
) {
return;
}
// 当滚动到距离底部300px时加载更多
if (scrollPosition >= threshold) {
fetchDoubanData(stateRef.current.currentPage + 1, true);
}
}, [fetchDoubanData]);
// 更新ref值以跟踪状态变化
useEffect(() => {
// 组件挂载时设置标记
stateRef.current.isLoading = isLoading;
}, [isLoading]);
useEffect(() => {
stateRef.current.hasMoreContent = hasMoreContent;
}, [hasMoreContent]);
useEffect(() => {
stateRef.current.currentPage = currentPage;
}, [currentPage]);
useEffect(() => {
stateRef.current.error = error;
}, [error]);
// 设置和清理IntersectionObserver
const setupIntersectionObserver = useCallback(() => {
// 清理旧的Observer
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
// 清理旧的检测元素
if (scrollDetectorRef.current) {
scrollDetectorRef.current.remove();
scrollDetectorRef.current = null;
}
// 创建新的IntersectionObserver
const observerOptions = {
root: null,
rootMargin: "300px",
threshold: 0.1,
};
observerRef.current = new IntersectionObserver((entries) => {
const entry = entries[0];
if (
entry.isIntersecting &&
!stateRef.current.isLoading &&
stateRef.current.hasMoreContent &&
!stateRef.current.error
) {
fetchDoubanData(stateRef.current.currentPage + 1, true);
}
}, observerOptions);
// 创建并添加检测底部的元素
const footer = document.createElement("div");
footer.id = "scroll-detector";
footer.style.width = "100%";
footer.style.height = "10px";
scrollDetectorRef.current = footer;
// 确保contentListRef有父元素
if (contentListRef.current && contentListRef.current.parentElement) {
// 插入到grid后面而不是内部
contentListRef.current.parentElement.insertBefore(
footer,
contentListRef.current.nextSibling,
);
observerRef.current.observe(footer);
}
}, [fetchDoubanData]);
// 组件初始化和依赖变化时重置
useEffect(() => {
// 设置组件挂载状态
isMountedRef.current = true;
fetchData();
// 重置状态
setCurrentPage(1);
stateRef.current.currentPage = 1;
setHasMoreContent(true);
stateRef.current.hasMoreContent = true;
setError(null);
stateRef.current.error = null;
setIsLoading(false);
stateRef.current.isLoading = false;
// 清空列表
setItems([]);
// 取消可能存在的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 加载第一页数据
fetchDoubanData(1, false);
// 设置滚动事件监听器
window.addEventListener("scroll", handleScroll, { passive: true });
// 组件卸载时清理
// 设置IntersectionObserver
setupIntersectionObserver();
// 初始检查一次,以防内容不足一屏
const timeoutId = setTimeout(() => {
if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) {
handleScroll();
}
}, 500);
// 清理函数
return () => {
// 标记组件已卸载
isMountedRef.current = false;
clearTimeout(timeoutId);
window.removeEventListener("scroll", handleScroll);
// 清理IntersectionObserver
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
// 清理scroll detector元素
if (scrollDetectorRef.current) {
scrollDetectorRef.current.remove();
scrollDetectorRef.current = null;
}
// 取消正在进行的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [fetchData]);
}, [type, doubanId, handleScroll, fetchDoubanData, setupIntersectionObserver]);
const handlePageChange = useCallback((page: number) => {
if (isPageChanging) return;
setIsPageChanging(true);
// 计算新页面的起始项
const start = (page - 1) * 15;
// 更新分页状态
setPagination(prev => ({
...prev,
current: page
}));
// 获取新页面的数据
fetchData(start);
}, [fetchData, isPageChanging]);
// 错误提示组件
const ErrorMessage = () => {
if (!error) return null;
const renderStars = useCallback((rating: number) => {
return (
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<svg
key={star}
className={`w-4 h-4 ${star <= rating ? 'text-accent-400' : 'text-secondary-300 dark:text-secondary-600'}`}
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
<div className="col-span-full text-center bg-red-50 p-4 rounded-md">
<div className="flex flex-col items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 text-red-500 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
))}
</div>
);
}, []);
<h3 className="text-lg font-medium text-red-800">访</h3>
<p className="mt-1 text-sm text-red-700">{error}</p>
<button
onClick={() => {
// 重置错误和加载状态
setError(null);
stateRef.current.error = null;
const breakpointColumnsObj = {
default: 3,
1100: 2,
700: 1
};
// 渲染内容的容器
const Container = useCallback(({ children }: { children: React.ReactNode }) => (
<div className={`douban-collection ${className}`}>
<Title />
{children}
</div>
), [className, Title]);
// 允许再次加载
setHasMoreContent(true);
stateRef.current.hasMoreContent = true;
// 加载中状态
if (loading && items.length === 0) {
return (
<Container>
<div className="flex justify-center items-center p-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
<p className="ml-2 text-gray-600 dark:text-gray-400">...</p>
</div>
</Container>
);
}
// 错误状态
if (error) {
return (
<Container>
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p>: {error}</p>
</div>
<button
onClick={() => fetchData()}
className="mt-3 px-4 py-2 bg-red-100 dark:bg-red-800/30 hover:bg-red-200 dark:hover:bg-red-800/50 text-red-700 dark:text-red-300 rounded"
// 重新获取当前页
fetchDoubanData(currentPage, false);
}}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
</button>
</div>
</Container>
</div>
);
}
};
// 数据为空状态
if (items.length === 0) {
return (
<Container>
<div className="text-center p-8 text-gray-500 dark:text-gray-400">
{type === 'movie' ? '观影' : '读书'}
</div>
</Container>
);
}
// 没有更多内容提示
const EndMessage = () => {
if (isLoading || !items.length || error) return null;
// 渲染分页按钮
const renderPaginationButton = useCallback((
direction: 'prev' | 'next',
onClick: () => void,
disabled: boolean
) => {
const buttonText = direction === 'prev' ? '上一页' : '下一页';
const buttonClass = `px-4 py-2 rounded ${disabled
? 'bg-secondary-200 dark:bg-secondary-700 text-secondary-500 dark:text-secondary-500 cursor-not-allowed'
: 'bg-primary-600 text-white hover:bg-primary-700 dark:bg-primary-700 dark:hover:bg-primary-600'}`;
return (
<button
onClick={onClick}
disabled={disabled}
className={buttonClass}
aria-label={buttonText}
>
{isPageChanging ? (
<span className="flex items-center">
<LoadingSpinner />
</span>
) : buttonText}
</button>
<div className="text-center py-8">
<p className="text-gray-600"></p>
</div>
);
}, [isPageChanging, LoadingSpinner]);
};
return (
<Container>
<ReactMasonryCss
breakpointCols={breakpointColumnsObj}
className="flex -ml-4 w-auto"
columnClassName="pl-4 bg-clip-padding"
<div className={`w-full ${className}`}>
<div
ref={contentListRef}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
{items.map((item, index) => (
<div
key={`${item.title}-${index}`}
className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg"
>
<a href={item.link} target="_blank" rel="noopener noreferrer" className="block">
<div className="relative pb-[140%] overflow-hidden">
<img
src={item.imageUrl}
alt={item.title}
className="absolute inset-0 w-full h-full object-cover hover:scale-105"
{error && items.length === 0 ? (
<ErrorMessage />
) : items.length > 0 ? (
items.map((item, index) => (
<div
key={`${item.title}-${index}`}
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl"
>
<div className="relative pb-[150%] overflow-hidden">
<img
src={item.imageUrl}
alt={item.title}
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYwIiBoZWlnaHQ9IjIyNCIgdmlld0JveD0iMCAwIDE2MCAyMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjE2MCIgaGVpZ2h0PSIyMjQiIGZpbGw9IiNmMWYxZjEiLz48dGV4dCB4PSI4MCIgeT0iMTEyIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM5OTk5OTkiPuWbuuWumuWbvueJh+acquivu+WPlzwvdGV4dD48L3N2Zz4=';
}}
/>
</div>
<div className="p-4">
<h3 className="font-bold text-lg mb-1 line-clamp-1 text-primary-800 dark:text-primary-300">{item.title}</h3>
{item.subtitle && <p className="text-secondary-600 dark:text-secondary-400 text-sm mb-2 line-clamp-1">{item.subtitle}</p>}
<div className="flex justify-between items-center mb-2">
{renderStars(item.rating)}
<span className="text-sm text-secondary-500 dark:text-secondary-400">{item.date}</span>
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
<h3 className="font-bold text-white text-sm line-clamp-2">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="hover:text-blue-300"
>
{item.title}
</a>
</h3>
</div>
<p className="text-secondary-700 dark:text-secondary-300 text-sm line-clamp-3">{item.intro}</p>
</div>
</a>
</div>
))
) : !isLoading ? (
<div className="col-span-full text-center">
{type === "movie" ? "电影" : "图书"}
</div>
))}
</ReactMasonryCss>
) : null}
</div>
{/* 分页 */}
{pagination.total > 1 && (
<div className="flex justify-center mt-8 space-x-2">
{renderPaginationButton(
'prev',
() => handlePageChange(pagination.current - 1),
!pagination.hasPrev || isPageChanging
)}
<span className="px-4 py-2 bg-secondary-100 dark:bg-secondary-800 rounded">
{pagination.current} / {pagination.total}
</span>
{renderPaginationButton(
'next',
() => handlePageChange(pagination.current + 1),
!pagination.hasNext || isPageChanging
)}
{error && items.length > 0 && (
<div className="mt-4">
<ErrorMessage />
</div>
)}
</Container>
{isLoading && (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
<p className="mt-2 text-gray-600">...{currentPage}</p>
</div>
)}
{!hasMoreContent && items.length > 0 && !isLoading && <EndMessage />}
</div>
);
};
export default DoubanCollection;
export default DoubanCollection;

View File

@ -280,7 +280,7 @@ const {
</script>
<script>
import '../scripts/swup-init.js';
import './swup-init.js';
</script>
</head>
<body

View File

@ -1,468 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
interface MediaGridProps {
type: "movie" | "book";
doubanId: string;
}
interface MediaItem {
title: string;
imageUrl: string;
link: string;
}
const MediaGrid: React.FC<MediaGridProps> = ({ type, doubanId }) => {
const [items, setItems] = useState<MediaItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const itemsPerPage = 15;
const mediaListRef = useRef<HTMLDivElement>(null);
const lastScrollTime = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const scrollDetectorRef = useRef<HTMLDivElement | null>(null);
// 添加一个 ref 来标记组件是否已挂载
const isMountedRef = useRef<boolean>(true);
// 使用ref来跟踪关键状态避免闭包问题
const stateRef = useRef({
isLoading: false,
hasMoreContent: true,
currentPage: 1,
error: null as string | null,
});
// 封装fetch函数使用useCallback避免重新创建
const fetchMedia = useCallback(async (page = 1, append = false) => {
// 使用ref中的最新状态
if (
stateRef.current.isLoading ||
(!append && !stateRef.current.hasMoreContent) ||
(append && !stateRef.current.hasMoreContent)
) {
return;
}
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 创建新的AbortController
abortControllerRef.current = new AbortController();
// 更新状态和ref
setIsLoading(true);
stateRef.current.isLoading = true;
// 只在首次加载时清除错误
if (!append) {
setError(null);
stateRef.current.error = null;
}
const start = (page - 1) * itemsPerPage;
try {
const response = await fetch(
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
{ signal: abortControllerRef.current.signal }
);
// 检查组件是否已卸载,如果卸载则不继续处理
if (!isMountedRef.current) {
return;
}
if (!response.ok) {
// 解析响应内容,获取详细错误信息
let errorMessage = `获取${type === "movie" ? "电影" : "图书"}数据失败`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMessage = errorData.error;
if (errorData.message) {
errorMessage += `: ${errorData.message}`;
}
}
} catch (e) {
// 无法解析JSON使用默认错误信息
}
// 针对不同错误提供更友好的提示
if (response.status === 403) {
errorMessage = "豆瓣接口访问受限,可能是请求过于频繁,请稍后再试";
} else if (response.status === 404) {
// 对于404错误如果是追加模式说明已经到了最后一页设置hasMoreContent为false
if (append) {
setHasMoreContent(false);
stateRef.current.hasMoreContent = false;
setIsLoading(false);
stateRef.current.isLoading = false;
return; // 直接返回,不设置错误,不清空已有数据
} else {
errorMessage = "未找到相关内容请检查豆瓣ID是否正确";
}
}
// 设置错误状态和ref
setError(errorMessage);
stateRef.current.error = errorMessage;
// 只有非追加模式才清空数据
if (!append) {
setItems([]);
}
throw new Error(errorMessage);
}
const data = await response.json();
// 再次检查组件是否已卸载
if (!isMountedRef.current) {
return;
}
if (data.items.length === 0) {
// 如果返回的项目为空,则认为已经没有更多内容
setHasMoreContent(false);
stateRef.current.hasMoreContent = false;
if (!append) {
setItems([]);
}
} else {
if (append) {
setItems((prev) => {
const newItems = [...prev, ...data.items];
return newItems;
});
} else {
setItems(data.items);
}
// 更新页码状态和ref
setCurrentPage(data.pagination.current);
stateRef.current.currentPage = data.pagination.current;
// 更新是否有更多内容的状态和ref
const newHasMoreContent = data.pagination.hasNext;
setHasMoreContent(newHasMoreContent);
stateRef.current.hasMoreContent = newHasMoreContent;
}
} catch (error) {
// 检查组件是否已卸载
if (!isMountedRef.current) {
return;
}
// 如果是取消的请求,不显示错误
if (error instanceof Error && error.name === 'AbortError') {
console.log('请求被取消', error.message);
// 如果是取消请求,重置加载状态但不显示错误
setIsLoading(false);
stateRef.current.isLoading = false;
return;
}
// 只有在非追加模式下才清空已加载的内容
if (!append) {
setItems([]);
}
} finally {
// 检查组件是否已卸载
if (isMountedRef.current) {
// 重置加载状态
setIsLoading(false);
stateRef.current.isLoading = false;
}
}
}, [type, doubanId]);
// 处理滚动事件
const handleScroll = useCallback(() => {
// 获取关键滚动值
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollPosition = scrollY + windowHeight;
const threshold = documentHeight - 300;
// 限制滚动日志频率,每秒最多输出一次
const now = Date.now();
if (now - lastScrollTime.current < 1000) {
return;
}
lastScrollTime.current = now;
// 使用ref中的最新状态来检查
if (
stateRef.current.isLoading ||
!stateRef.current.hasMoreContent ||
stateRef.current.error
) {
return;
}
// 当滚动到距离底部300px时加载更多
if (scrollPosition >= threshold) {
fetchMedia(stateRef.current.currentPage + 1, true);
}
}, [fetchMedia]);
// 更新ref值以跟踪状态变化
useEffect(() => {
stateRef.current.isLoading = isLoading;
}, [isLoading]);
useEffect(() => {
stateRef.current.hasMoreContent = hasMoreContent;
}, [hasMoreContent]);
useEffect(() => {
stateRef.current.currentPage = currentPage;
}, [currentPage]);
useEffect(() => {
stateRef.current.error = error;
}, [error]);
// 设置和清理IntersectionObserver
const setupIntersectionObserver = useCallback(() => {
// 清理旧的Observer
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
// 清理旧的检测元素
if (scrollDetectorRef.current) {
scrollDetectorRef.current.remove();
scrollDetectorRef.current = null;
}
// 创建新的IntersectionObserver
const observerOptions = {
root: null,
rootMargin: "300px",
threshold: 0.1,
};
observerRef.current = new IntersectionObserver((entries) => {
const entry = entries[0];
if (
entry.isIntersecting &&
!stateRef.current.isLoading &&
stateRef.current.hasMoreContent &&
!stateRef.current.error
) {
fetchMedia(stateRef.current.currentPage + 1, true);
}
}, observerOptions);
// 创建并添加检测底部的元素
const footer = document.createElement("div");
footer.id = "scroll-detector";
footer.style.width = "100%";
footer.style.height = "10px";
scrollDetectorRef.current = footer;
// 确保mediaListRef有父元素
if (mediaListRef.current && mediaListRef.current.parentElement) {
// 插入到grid后面而不是内部
mediaListRef.current.parentElement.insertBefore(
footer,
mediaListRef.current.nextSibling,
);
observerRef.current.observe(footer);
}
}, [fetchMedia]);
// 组件初始化和依赖变化时重置
useEffect(() => {
// 设置组件挂载状态
isMountedRef.current = true;
// 重置状态
setCurrentPage(1);
stateRef.current.currentPage = 1;
setHasMoreContent(true);
stateRef.current.hasMoreContent = true;
setError(null);
stateRef.current.error = null;
setIsLoading(false);
stateRef.current.isLoading = false;
// 清空列表
setItems([]);
// 取消可能存在的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 加载第一页数据
fetchMedia(1, false);
// 设置滚动事件监听器
window.addEventListener("scroll", handleScroll, { passive: true });
// 设置IntersectionObserver
setupIntersectionObserver();
// 初始检查一次,以防内容不足一屏
const timeoutId = setTimeout(() => {
if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) {
handleScroll();
}
}, 500);
// 清理函数
return () => {
// 标记组件已卸载
isMountedRef.current = false;
clearTimeout(timeoutId);
window.removeEventListener("scroll", handleScroll);
// 清理IntersectionObserver
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
// 清理scroll detector元素
if (scrollDetectorRef.current) {
scrollDetectorRef.current.remove();
scrollDetectorRef.current = null;
}
// 取消正在进行的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [type, doubanId, handleScroll, fetchMedia, setupIntersectionObserver]);
// 错误提示组件
const ErrorMessage = () => {
if (!error) return null;
return (
<div className="col-span-full text-center bg-red-50 p-4 rounded-md">
<div className="flex flex-col items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 text-red-500 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h3 className="text-lg font-medium text-red-800">访</h3>
<p className="mt-1 text-sm text-red-700">{error}</p>
<button
onClick={() => {
// 重置错误和加载状态
setError(null);
stateRef.current.error = null;
// 允许再次加载
setHasMoreContent(true);
stateRef.current.hasMoreContent = true;
// 重新获取当前页
fetchMedia(currentPage, false);
}}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
</button>
</div>
</div>
);
};
// 没有更多内容提示
const EndMessage = () => {
if (isLoading || !items.length || error) return null;
return (
<div className="text-center py-8">
<p className="text-gray-600"></p>
</div>
);
};
return (
<div className="w-full">
<div
ref={mediaListRef}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
{error && items.length === 0 ? (
<ErrorMessage />
) : items.length > 0 ? (
items.map((item, index) => (
<div
key={`${item.title}-${index}`}
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl"
>
<div className="relative pb-[150%] overflow-hidden">
<img
src={item.imageUrl}
alt={item.title}
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
loading="lazy"
/>
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
<h3 className="font-bold text-white text-sm line-clamp-2">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="hover:text-blue-300"
>
{item.title}
</a>
</h3>
</div>
</div>
</div>
))
) : !isLoading ? (
<div className="col-span-full text-center">
{type === "movie" ? "电影" : "图书"}
</div>
) : null}
</div>
{error && items.length > 0 && (
<div className="mt-4">
<ErrorMessage />
</div>
)}
{isLoading && (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
<p className="mt-2 text-gray-600">...{currentPage}</p>
</div>
)}
{!hasMoreContent && items.length > 0 && !isLoading && <EndMessage />}
</div>
);
};
export default MediaGrid;

View File

@ -0,0 +1,160 @@
import React, { useEffect, useState } from "react";
interface WereadBookListProps {
listId: string;
}
interface WereadBook {
title: string;
author: string;
imageUrl: string;
link: string;
}
const WereadBookList: React.FC<WereadBookListProps> = ({ listId }) => {
const [books, setBooks] = useState<WereadBook[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 获取微信读书数据
const fetchWereadData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/weread?listId=${listId}`);
if (!response.ok) {
// 解析响应内容,获取详细错误信息
let errorMessage = `获取微信读书数据失败`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMessage = errorData.error;
if (errorData.message) {
errorMessage += `: ${errorData.message}`;
}
}
} catch (e) {
// 无法解析JSON使用默认错误信息
}
// 针对不同错误提供更友好的提示
if (response.status === 403) {
errorMessage = "微信读书接口访问受限,可能是请求过于频繁,请稍后再试";
} else if (response.status === 404) {
errorMessage = "未找到相关内容请检查书单ID是否正确";
}
setError(errorMessage);
setBooks([]);
return;
}
const data = await response.json();
if (data.books && Array.isArray(data.books)) {
setBooks(data.books);
} else {
setBooks([]);
}
} catch (error) {
setError("获取微信读书数据失败: " + (error instanceof Error ? error.message : "未知错误"));
setBooks([]);
} finally {
setIsLoading(false);
}
};
// 组件初始化时获取数据
useEffect(() => {
fetchWereadData();
}, [listId]);
// 错误提示组件
const ErrorMessage = () => {
if (!error) return null;
return (
<div className="text-center bg-red-50 p-4 rounded-md">
<div className="flex flex-col items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 text-red-500 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h3 className="text-lg font-medium text-red-800">访</h3>
<p className="mt-1 text-sm text-red-700">{error}</p>
<button
onClick={fetchWereadData}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
</button>
</div>
</div>
);
};
return (
<div className="w-full">
{error ? (
<ErrorMessage />
) : isLoading ? (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent"></div>
<p className="mt-2 text-gray-600">...</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{books.length > 0 ? (
books.map((book, index) => (
<div
key={`${book.title}-${index}`}
className="bg-white rounded-lg overflow-hidden shadow-md"
>
<div className="relative pb-[150%] overflow-hidden">
<img
src={book.imageUrl}
alt={book.title}
className="absolute top-0 left-0 w-full h-full object-cover"
loading="lazy"
/>
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
<h3 className="font-bold text-white text-sm line-clamp-2">
<a
href={book.link}
target="_blank"
rel="noopener noreferrer"
>
{book.title}
</a>
</h3>
<p className="text-white/80 text-xs mt-1 line-clamp-1">
{book.author}
</p>
</div>
</div>
</div>
))
) : (
<div className="col-span-full text-center">
</div>
)}
</div>
)}
</div>
);
};
export default WereadBookList;

View File

@ -174,8 +174,6 @@ document.addEventListener('DOMContentLoaded', () => {
const spinner = createLoadingSpinner();
// 页面状态跟踪
let isLoading = false;
let contentReady = false;
let animationInProgress = false;

View File

@ -9,8 +9,8 @@ export const NAV_STRUCTURE = [
href: '/'
},
{
id: 'douban',
text: '豆瓣',
id: 'art',
text: '艺术',
items: [
{ id: 'movies', text: '观影', href: '/movies' },
{ id: 'books', text: '读书', href: '/books' }

View File

@ -135,8 +135,6 @@ export const ARTICLE_EXPIRY_CONFIG = {
用于展示 Git 平台的项目列表。
基本用法:
```astro
---
import GitProjectCollection from '@/components/GitProjectCollection';
@ -156,43 +154,58 @@ import { GitPlatform } from '@/components/GitProjectCollection';
## 观影和读书记录
### MediaGrid 组件
`MediaGrid` 组件用于展示豆瓣的观影和读书记录。
基本用法:
用于展示豆瓣的观影和读书记录。
```astro
---
import MediaGrid from '@/components/MediaGrid.astro';
import DoubanCollection from '@/components/DoubanCollection.astro';
---
// 展示电影记录
<MediaGrid
<DoubanCollection
type="movie" // 类型movie 或 book
title="我看过的电影" // 显示标题
doubanId="id" // 豆瓣ID
doubanId="lsy22" // 豆瓣ID
/>
// 展示读书记录
<MediaGrid
<DoubanCollection
type="book"
title="我读过的书"
doubanId="id"
doubanId="lsy22"
/>
```
## 微信读书书单组件
用于展示微信读书的书单内容,支持错误处理和优雅的加载状态。
```astro
---
import WereadBookList from '@/components/WereadBookList';
---
<WereadBookList
listId="12345678" // 必填微信读书书单ID从书单URL中获取
client:load // Astro 指令:客户端加载
/>
```
### 获取微信读书书单ID
1. **打开微信读书**:在浏览器中访问微信读书网页版或使用微信读书小程序/App
2. **打开书单页面**:找到你想展示的书单并打开,点击分享,分享到浏览器
3. **获取书单ID**从URL中提取书单ID例如`https://weread.qq.com/misc/booklist/12345678` 中的 `12345678` 即为书单ID
## 旅行足迹组件
`WorldHeatmap` 组件用于展示你去过的地方,以热力图的形式在世界地图上显示。
用于展示你去过的地方,以热力图的形式在世界地图上显示。
基本用法:
`src/consts.ts` 中配置你去过的地方:
```typescript
```astro
---
import WorldHeatmap from '@/components/WorldHeatmap';
// 配置你去过的地方
export const VISITED_PLACES = [
const VISITED_PLACES = [
// 国内地区格式:'中国-省份/城市'
"中国-黑龙江",
"中国-北京",
@ -202,28 +215,12 @@ export const VISITED_PLACES = [
"泰国",
"美国",
];
```
然后在页面中使用:
```astro
---
import Layout from "@/components/Layout.astro";
import WorldHeatmap from '@/components/WorldHeatmap';
import { VISITED_PLACES } from '@/consts';
---
<Layout title="旅行足迹">
<section>
<h2 class="text-3xl font-semibold text-center mb-6">我的旅行足迹</h2>
<div class="mx-auto bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<WorldHeatmap
client:only="react"
visitedPlaces={VISITED_PLACES}
/>
</div>
</section>
</Layout>
<WorldHeatmap
client:only="react"
visitedPlaces={VISITED_PLACES}
/>
```
## 代码块与 Mermaid 图表支持

View File

@ -4,35 +4,6 @@ date: 2023-05-26T20:21:00+00:00
tags: ["Docker-compose"]
---
## 准备数据库
### 1. 登录到数据库
```bash
mysql -u root -p
```
### 2. 创建一个将被 Gitea 使用的数据库用户,并使用密码进行身份验证
```sql
CREATE USER 'gitea' IDENTIFIED BY 'Password';
```
> 将`Password`改为自己的密码
### 3. 使用 UTF-8 字符集和大小写敏感的排序规则创建数据库
```sql
CREATE DATABASE giteadb CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_bin';
```
### 4. 将数据库上的所有权限授予上述创建的数据库用户
```sql
GRANT ALL PRIVILEGES ON giteadb.* TO 'gitea';
FLUSH PRIVILEGES;
```
## 直通配置
### 1. 创建一个名为 `git` 的用户

464
src/pages/api/weread.ts Normal file
View File

@ -0,0 +1,464 @@
import type { APIRoute } from 'astro';
import { load } from 'cheerio';
// 添加服务器渲染标记
export const prerender = false;
// 请求配置常量
const MAX_RETRIES = 1; // 最大重试次数
const RETRY_DELAY = 1500; // 重试延迟(毫秒)
const REQUEST_TIMEOUT = 10000; // 请求超时时间(毫秒)
// 添加延迟函数
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 带超时的 fetch 函数
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number) {
// 检查是否已经提供了信号
const existingSignal = options.signal;
// 创建我们自己的 AbortController 用于超时
const timeoutController = new AbortController();
const timeoutSignal = timeoutController.signal;
// 设置超时
const timeout = setTimeout(() => {
timeoutController.abort();
}, timeoutMs);
try {
// 使用已有的信号和我们的超时信号
if (existingSignal) {
// 如果已经取消了,直接抛出异常
if (existingSignal.aborted) {
throw new DOMException('已被用户取消', 'AbortError');
}
// 创建一个监听器,当外部信号中止时,也中止我们的控制器
const abortListener = () => timeoutController.abort();
existingSignal.addEventListener('abort', abortListener);
// 进行请求,但只使用我们的超时信号
const response = await fetch(url, {
...options,
signal: timeoutSignal
});
// 移除监听器
existingSignal.removeEventListener('abort', abortListener);
return response;
} else {
// 如果没有提供信号,只使用我们的超时信号
return await fetch(url, {
...options,
signal: timeoutSignal
});
}
} finally {
clearTimeout(timeout);
}
}
// 从脚本中提取书籍数据(简单直接的方法)
function extractBooksFromScript(html: string): { title: string; author: string; cover: string; }[] | null {
try {
// 1. 提取函数参数列表,用于解析参数引用
const paramsMatch = html.match(/\(function\(([^)]*)\)/);
if (!paramsMatch) {
console.error('未找到函数参数列表');
return null;
}
const params = paramsMatch[1].split(',').map(p => p.trim());
// 2. 提取bookEntities对象
const bookEntitiesMatch = html.match(/bookEntities:(\{.*?\}),bookIds/s);
if (!bookEntitiesMatch) {
console.error('未找到bookEntities数据');
return null;
}
// 3. 提取bookIds数组
const bookIdsMatch = html.match(/bookIds:\[(.*?)\]/s);
if (!bookIdsMatch) {
console.error('未找到bookIds数据');
return null;
}
const bookIdsStr = bookIdsMatch[1];
const bookIds = bookIdsStr.split(',')
.map(id => id.trim().replace(/"/g, ''))
.filter(id => id && id !== '');
// 4. 创建参数到实际ID的映射
// 首先提取所有bookId参数引用
const bookIdParamPattern = /"([^"]+)":\{bookId:([^,]+),/g;
const paramToIdMap = new Map<string, string>();
let bookIdMatch;
while ((bookIdMatch = bookIdParamPattern.exec(bookEntitiesMatch[1])) !== null) {
const entityId = bookIdMatch[1]; // 如 "728774"
const paramRef = bookIdMatch[2]; // 如 "h"
if (paramRef.length === 1 && /[a-z]/.test(paramRef)) {
paramToIdMap.set(paramRef, entityId);
}
}
// 5. 解析每本书的信息
const bookMap = new Map();
const bookPattern = /"([^"]+)":\{bookId:[^,]+,.*?author:([^,]+),.*?title:"([^"]+)",.*?cover:"([^"]+)"/g;
let match;
let bookCount = 0;
while ((match = bookPattern.exec(bookEntitiesMatch[1])) !== null) {
const bookId = match[1];
const authorParam = match[2];
const title = match[3];
const cover = match[4].replace(/\\u002F/g, '/');
// 处理作者参数引用
let author = authorParam;
if (authorParam.length === 1 && /[a-z]/.test(authorParam)) {
const paramIndex = authorParam.charCodeAt(0) - 'a'.charCodeAt(0);
if (paramIndex >= 0 && paramIndex < params.length) {
author = params[paramIndex].replace(/"/g, '');
}
} else if (authorParam.startsWith('"') && authorParam.endsWith('"')) {
author = authorParam.substring(1, authorParam.length - 1);
}
// 同时用实体ID和参数引用作为键存储书籍信息
bookMap.set(bookId, { title, author, cover });
bookCount++;
}
// 6. 按照bookIds的顺序返回书籍使用参数映射
const orderedBooks = [];
for (const paramId of bookIds) {
// 如果是参数引用使用映射查找实际ID
const actualId = paramToIdMap.get(paramId);
if (actualId) {
const book = bookMap.get(actualId);
if (book) {
orderedBooks.push(book);
continue;
}
}
// 尝试直接使用paramId查找
const directBook = bookMap.get(paramId);
if (directBook) {
orderedBooks.push(directBook);
}
}
// 如果没有找到任何书籍,返回所有书籍
if (orderedBooks.length === 0) {
return Array.from(bookMap.values());
}
return orderedBooks;
} catch (error) {
console.error('从脚本提取书籍数据时出错:', error);
return null;
}
}
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const listId = url.searchParams.get('listId'); // 从查询参数获取微信读书书单ID
if (!listId) {
return new Response(JSON.stringify({ error: '缺少微信读书书单ID' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
// 尝试从缓存获取数据
try {
// 重试逻辑
let retries = 0;
let lastError: Error | null = null;
while (retries <= MAX_RETRIES) {
try {
const wereadUrl = `https://weread.qq.com/misc/booklist/${listId}`;
// 使用带超时的fetch发送请求
const response = await fetchWithTimeout(wereadUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
}, REQUEST_TIMEOUT);
if (!response.ok) {
// 根据状态码提供更详细的错误信息
let errorMessage = `微信读书请求失败,状态码: ${response.status}`;
if (response.status === 403) {
errorMessage = `微信读书接口返回403禁止访问可能是请求频率受限`;
console.error(errorMessage);
// 返回更友好的错误信息
return new Response(JSON.stringify({
error: '微信读书接口暂时不可用',
message: '请求频率过高,服务器已限制访问,请稍后再试',
status: 403
}), {
status: 403,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
} else if (response.status === 404) {
errorMessage = `未找到微信读书书单 (ID: ${listId})`;
} else if (response.status === 429) {
errorMessage = '微信读书API请求过于频繁被限流';
} else if (response.status >= 500) {
errorMessage = '微信读书服务器内部错误';
}
throw new Error(errorMessage);
}
const html = await response.text();
// 检查是否包含验证码页面的特征
if (html.includes('验证码') || html.includes('captcha')) {
const errorMessage = '请求被微信读书限制,需要验证码';
console.error(errorMessage);
// 返回更友好的错误信息
return new Response(JSON.stringify({
error: '微信读书接口暂时不可用',
message: '请求需要验证码验证,可能是因为请求过于频繁',
status: 403
}), {
status: 403,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
// 从脚本中提取书籍数据
const scriptBooks = extractBooksFromScript(html);
if (scriptBooks && scriptBooks.length > 0) {
// 将提取的数据转换为API响应格式
const books = scriptBooks.map(book => ({
title: book.title,
author: book.author,
imageUrl: book.cover,
link: `https://weread.qq.com/web/search/books?keyword=${encodeURIComponent(book.title)}`
}));
return new Response(JSON.stringify({ books }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300', // 5分钟服务器缓存
'CDN-Cache-Control': 'public, max-age=300' // CDN缓存
}
});
}
// 如果从脚本提取失败尝试从HTML解析
const $ = load(html);
// 定义书籍项目接口
interface WereadBook {
title: string;
author: string;
imageUrl: string;
link: string;
}
const books: WereadBook[] = [];
// 从HTML中解析书籍列表
$('.booklist_books li').each((_, element) => {
try {
const $element = $(element);
// 提取标题和作者
const title = $element.find('.booklist_book_title').text().trim();
const author = $element.find('.booklist_book_author').text().trim();
// 尝试直接从HTML中提取图片URL
const imageUrl = $element.find('.wr_bookCover_img').attr('src') || '';
// 只有在找到标题和作者的情况下才添加书籍
if (title && author) {
books.push({
title,
author,
imageUrl,
link: `https://weread.qq.com/web/search/books?keyword=${encodeURIComponent(title)}`
});
}
} catch (error) {
console.error('解析书籍时出错:', error);
}
});
if (books.length > 0) {
return new Response(JSON.stringify({ books }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300',
'CDN-Cache-Control': 'public, max-age=300'
}
});
} else {
throw new Error('未能从页面中提取书籍数据');
}
} catch (error) {
console.error(`尝试第 ${retries + 1}/${MAX_RETRIES + 1} 次失败:`, error);
// 判断是否是请求被中止
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) {
console.warn('请求被中止:', error.message);
// 对于中止请求我们可以直接返回404
return new Response(JSON.stringify({
error: '请求被中止',
message: '请求已被用户或服务器中止',
status: 499 // 使用499代表客户端中止请求
}), {
status: 499,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
lastError = error instanceof Error ? error : new Error(String(error));
if (retries < MAX_RETRIES) {
retries++;
// 增加重试延迟,避免频繁请求
await delay(RETRY_DELAY * retries);
} else {
break;
}
}
}
// 所有尝试都失败了
console.error('所有尝试都失败了:', lastError);
// 检查是否是常见错误类型并返回对应错误信息
const errorMessage = lastError?.message || '未知错误';
// 检查是否是中止错误
if (lastError && (lastError.name === 'AbortError' || errorMessage.includes('aborted'))) {
return new Response(JSON.stringify({
error: '请求被中止',
message: '请求已被用户或系统中止',
status: 499
}), {
status: 499,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
// 根据错误信息判断错误类型
if (errorMessage.includes('403') || errorMessage.includes('禁止访问') || errorMessage.includes('频繁')) {
return new Response(JSON.stringify({
error: '微信读书接口访问受限',
message: '请求频率过高,服务器已限制访问,请稍后再试',
status: 403
}), {
status: 403,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
if (errorMessage.includes('404') || errorMessage.includes('未找到')) {
return new Response(JSON.stringify({
error: '未找到微信读书书单',
message: `未找到ID为 ${listId} 的书单内容`,
status: 404
}), {
status: 404,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
if (errorMessage.includes('超时')) {
return new Response(JSON.stringify({
error: '微信读书接口请求超时',
message: '请求微信读书服务器超时,请稍后再试',
status: 408
}), {
status: 408,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
return new Response(JSON.stringify({
error: '获取微信读书数据失败',
message: errorMessage
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
} catch (error) {
console.error('处理请求时出错:', error);
// 判断是否是中止错误
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) {
return new Response(JSON.stringify({
error: '请求被中止',
message: '请求已被用户或系统中止',
status: 499
}), {
status: 499,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
return new Response(JSON.stringify({
error: '获取微信读书数据失败',
message: error instanceof Error ? error.message : '未知错误'
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}
}

View File

@ -1,18 +1,17 @@
---
import Layout from "@/components/Layout.astro";
import MediaGrid from "@/components/MediaGrid.tsx";
import WereadBookList from "@/components/WereadBookList";
---
<Layout
title={`豆瓣图书`}
title={`书`}
description={`我读过的书`}
skipSrTitle={false}
>
<h1 class="text-3xl font-bold mb-6">我读过的书</h1>
<MediaGrid
type="book"
doubanId="lsy22"
<WereadBookList
listId="333895983_80fTRWHwy"
client:load
/>
</Layout>
</Layout>

View File

@ -1,6 +1,6 @@
---
import Layout from "@/components/Layout.astro";
import MediaGrid from "@/components/MediaGrid.tsx";
import DoubanCollection from "@/components/DoubanCollection";
---
<Layout
@ -10,7 +10,7 @@ import MediaGrid from "@/components/MediaGrid.tsx";
>
<h1 class="text-3xl font-bold mb-6">我看过的电影</h1>
<MediaGrid
<DoubanCollection
type="movie"
doubanId="lsy22"
client:load