From 61cd291574933429832b757e1be31cdb56c43489 Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 15 Jul 2025 00:35:28 +0800 Subject: [PATCH] feat: add local settings --- README.md | 21 ++- package.json | 1 + pnpm-lock.yaml | 12 ++ proxy.worker.js | 240 +++++++++++++++++++++++++++++ src/app/admin/page.tsx | 41 ----- src/app/api/admin/site/route.ts | 6 +- src/app/api/image-proxy/route.ts | 2 +- src/app/douban/page.tsx | 35 ++--- src/app/layout.tsx | 4 - src/app/page.tsx | 19 +-- src/app/play/page.tsx | 4 +- src/app/search/page.tsx | 20 ++- src/components/EpisodeSelector.tsx | 4 +- src/components/MobileHeader.tsx | 28 ++-- src/components/PageLayout.tsx | 2 + src/components/SettingsButton.tsx | 185 ++++++++++++++++++++++ src/components/VideoCard.tsx | 3 +- src/lib/admin.types.ts | 1 - src/lib/config.ts | 8 - src/lib/douban.client.ts | 206 +++++++++++++++++++++++++ src/lib/utils.ts | 25 +++ 21 files changed, 741 insertions(+), 126 deletions(-) create mode 100644 proxy.worker.js create mode 100644 src/components/SettingsButton.tsx create mode 100644 src/lib/douban.client.ts diff --git a/README.md b/README.md index 6b98cac..1aa324a 100644 --- a/README.md +++ b/README.md @@ -180,17 +180,16 @@ networks: ## 环境变量 -| 变量 | 说明 | 可选值 | 默认值 | -| ----------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) | -| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) | -| SITE_NAME | 站点名称 | 任意字符串 | MoonTV | -| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | -| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage(本地浏览器存储)、redis(仅 docker 支持) | localstorage | -| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 | -| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在 redis 部署时生效 | true / false | false | -| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | -| NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT | 搜索结果默认是否按标题和年份聚合 | true / false | true | +| 变量 | 说明 | 可选值 | 默认值 | +| --------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) | +| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) | +| SITE_NAME | 站点名称 | 任意字符串 | MoonTV | +| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | +| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage(本地浏览器存储)、redis(仅 docker 支持) | localstorage | +| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 | +| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在 redis 部署时生效 | true / false | false | +| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | ## 配置说明 diff --git a/package.json b/package.json index 69669ca..6a31d5f 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@testing-library/react": "^15.0.7", "@types/node": "24.0.3", "@types/react": "^18.3.18", + "@types/react-dom": "^19.1.6", "@types/testing-library__jest-dom": "^5.14.9", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45f5795..20569b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: '@types/react': specifier: ^18.3.18 version: 18.3.23 + '@types/react-dom': + specifier: ^19.1.6 + version: 19.1.6(@types/react@18.3.23) '@types/testing-library__jest-dom': specifier: ^5.14.9 version: 5.14.9 @@ -1918,6 +1921,11 @@ packages: peerDependencies: '@types/react': ^18.0.0 + '@types/react-dom@19.1.6': + resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} + peerDependencies: + '@types/react': ^19.0.0 + '@types/react@18.3.23': resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} @@ -8342,6 +8350,10 @@ snapshots: dependencies: '@types/react': 18.3.23 + '@types/react-dom@19.1.6(@types/react@18.3.23)': + dependencies: + '@types/react': 18.3.23 + '@types/react@18.3.23': dependencies: '@types/prop-types': 15.7.15 diff --git a/proxy.worker.js b/proxy.worker.js new file mode 100644 index 0000000..b31c97f --- /dev/null +++ b/proxy.worker.js @@ -0,0 +1,240 @@ +/* eslint-disable */ + +addEventListener('fetch', (event) => { + event.respondWith(handleRequest(event.request)); +}); + +async function handleRequest(request) { + try { + const url = new URL(request.url); + + // 如果访问根目录,返回HTML + if (url.pathname === '/') { + return new Response(getRootHtml(), { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + } + + // 从请求路径中提取目标 URL + let actualUrlStr = decodeURIComponent(url.pathname.replace('/', '')); + + // 判断用户输入的 URL 是否带有协议 + actualUrlStr = ensureProtocol(actualUrlStr, url.protocol); + + // 保留查询参数 + actualUrlStr += url.search; + + // 创建新 Headers 对象,排除以 'cf-' 开头的请求头 + const newHeaders = filterHeaders( + request.headers, + (name) => !name.startsWith('cf-') + ); + + // 创建一个新的请求以访问目标 URL + const modifiedRequest = new Request(actualUrlStr, { + headers: newHeaders, + method: request.method, + body: request.body, + redirect: 'manual', + }); + + // 发起对目标 URL 的请求 + const response = await fetch(modifiedRequest); + let body = response.body; + + // 处理重定向 + if ([301, 302, 303, 307, 308].includes(response.status)) { + body = response.body; + // 创建新的 Response 对象以修改 Location 头部 + return handleRedirect(response, body); + } else if (response.headers.get('Content-Type')?.includes('text/html')) { + body = await handleHtmlContent( + response, + url.protocol, + url.host, + actualUrlStr + ); + } + + // 创建修改后的响应对象 + const modifiedResponse = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + + // 添加禁用缓存的头部 + setNoCacheHeaders(modifiedResponse.headers); + + // 添加 CORS 头部,允许跨域访问 + setCorsHeaders(modifiedResponse.headers); + + return modifiedResponse; + } catch (error) { + // 如果请求目标地址时出现错误,返回带有错误消息的响应和状态码 500(服务器错误) + return jsonResponse( + { + error: error.message, + }, + 500 + ); + } +} + +// 确保 URL 带有协议 +function ensureProtocol(url, defaultProtocol) { + return url.startsWith('http://') || url.startsWith('https://') + ? url + : defaultProtocol + '//' + url; +} + +// 处理重定向 +function handleRedirect(response, body) { + const location = new URL(response.headers.get('location')); + const modifiedLocation = `/${encodeURIComponent(location.toString())}`; + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: { + ...response.headers, + Location: modifiedLocation, + }, + }); +} + +// 处理 HTML 内容中的相对路径 +async function handleHtmlContent(response, protocol, host, actualUrlStr) { + const originalText = await response.text(); + const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g'); + let modifiedText = replaceRelativePaths( + originalText, + protocol, + host, + new URL(actualUrlStr).origin + ); + + return modifiedText; +} + +// 替换 HTML 内容中的相对路径 +function replaceRelativePaths(text, protocol, host, origin) { + const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g'); + return text.replace(regex, `$1${protocol}//${host}/${origin}/`); +} + +// 返回 JSON 格式的响应 +function jsonResponse(data, status) { + return new Response(JSON.stringify(data), { + status: status, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }); +} + +// 过滤请求头 +function filterHeaders(headers, filterFunc) { + return new Headers([...headers].filter(([name]) => filterFunc(name))); +} + +// 设置禁用缓存的头部 +function setNoCacheHeaders(headers) { + headers.set('Cache-Control', 'no-store'); +} + +// 设置 CORS 头部 +function setCorsHeaders(headers) { + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); + headers.set('Access-Control-Allow-Headers', '*'); +} + +// 返回根目录的 HTML +function getRootHtml() { + return ` + + + + + Proxy Everything + + + + + + + + + + + + + + + +
+
+
+
+
+
+ linkProxy Everything +
+
+ + +
+ +
+
+
+
+
+
+
+ + + +`; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8e4f490..9e17e5d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -50,7 +50,6 @@ interface SiteConfig { Announcement: string; SearchDownstreamMaxPage: number; SiteInterfaceCacheTime: number; - SearchResultDefaultAggregate: boolean; } // 视频源数据类型 @@ -948,7 +947,6 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => { Announcement: '', SearchDownstreamMaxPage: 1, SiteInterfaceCacheTime: 7200, - SearchResultDefaultAggregate: false, }); // 保存状态 const [saving, setSaving] = useState(false); @@ -1094,45 +1092,6 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => { /> - {/* 默认按标题和年份聚合 */} -
- - -
- {/* 操作按钮 */}
+
+ + {/* 设置项 */} +
+ {/* 默认聚合搜索结果 */} +
+
+

+ 默认聚合搜索结果 +

+

+ 搜索时默认按标题和年份聚合显示结果 +

+
+ +
+ + {/* 豆瓣代理设置 */} +
+
+

+ 豆瓣数据代理 +

+

+ 设置代理URL以绕过豆瓣访问限制,留空则使用服务端API +

+
+ handleDoubanProxyUrlChange(e.target.value)} + /> +
+ + {/* 图片代理设置 */} +
+
+

+ 图片代理 +

+

+ 设置代理URL以加速图片加载,留空则直接加载原图 +

+
+ handleImageProxyUrlChange(e.target.value)} + /> +
+
+ + {/* 底部说明 */} +
+

+ 这些设置保存在本地浏览器中 +

+
+ + + ); + + return ( + <> + + + {/* 使用 Portal 将设置面板渲染到 document.body */} + {isOpen && mounted && createPortal(settingsPanel, document.body)} + + ); +}; diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index be6ff28..6c400f7 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -13,6 +13,7 @@ import { toggleFavorite, } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; +import { processImageUrl } from '@/lib/utils'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; @@ -265,7 +266,7 @@ export default function VideoCard({ {/* 图片加载动画 - 改进淡入和锐化效果 */} {actualTitle} { '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; adminConfig.UserConfig.AllowRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true'; - adminConfig.SiteConfig.SearchResultDefaultAggregate = - process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false'; cachedConfig = adminConfig; } else { // DB 无配置,执行一次初始化 @@ -283,8 +277,6 @@ export async function resetConfig() { SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, SiteInterfaceCacheTime: fileConfig.cache_time || 7200, - SearchResultDefaultAggregate: - process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false', }, UserConfig: { AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', diff --git a/src/lib/douban.client.ts b/src/lib/douban.client.ts new file mode 100644 index 0000000..726e3f0 --- /dev/null +++ b/src/lib/douban.client.ts @@ -0,0 +1,206 @@ +import { DoubanItem, DoubanResult } from './types'; + +interface DoubanApiResponse { + subjects: Array<{ + id: string; + title: string; + cover: string; + rate: string; + }>; +} + +interface DoubanClientParams { + type: 'tv' | 'movie'; + tag: string; + pageSize?: number; + pageStart?: number; +} + +/** + * 浏览器端豆瓣数据获取函数 + */ +export async function fetchDoubanDataClient( + params: DoubanClientParams +): Promise { + const { type, tag, pageSize = 16, pageStart = 0 } = params; + + // 验证参数 + if (!['tv', 'movie'].includes(type)) { + throw new Error('type 参数必须是 tv 或 movie'); + } + + if (pageSize < 1 || pageSize > 100) { + throw new Error('pageSize 必须在 1-100 之间'); + } + + if (pageStart < 0) { + throw new Error('pageStart 不能小于 0'); + } + + // 处理 top250 特殊情况 + if (tag === 'top250') { + return handleTop250Client(pageStart); + } + + const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`; + + try { + const response = await fetchWithTimeout(target); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const doubanData: DoubanApiResponse = await response.json(); + + // 转换数据格式 + const list: DoubanItem[] = doubanData.subjects.map((item) => ({ + id: item.id, + title: item.title, + poster: item.cover, + rate: item.rate, + })); + + return { + code: 200, + message: '获取成功', + list: list, + }; + } catch (error) { + throw new Error(`获取豆瓣数据失败: ${(error as Error).message}`); + } +} + +/** + * 处理豆瓣 Top250 数据获取 + */ +async function handleTop250Client(pageStart: number): Promise { + const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`; + + try { + const response = await fetchWithTimeout(target, { + 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,image/webp,*/*;q=0.8', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + // 获取 HTML 内容 + const html = await response.text(); + + // 通过正则同时捕获影片 id、标题、封面以及评分 + const moviePattern = + /
[\s\S]*?]+href="https?:\/\/movie\.douban\.com\/subject\/(\d+)\/"[\s\S]*?]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?]*>([^<]*)<\/span>[\s\S]*?<\/div>/g; + const movies: DoubanItem[] = []; + let match; + + while ((match = moviePattern.exec(html)) !== null) { + const id = match[1]; + const title = match[2]; + const cover = match[3]; + const rate = match[4] || ''; + + // 处理图片 URL,确保使用 HTTPS + const processedCover = cover.replace(/^http:/, 'https:'); + + movies.push({ + id: id, + title: title, + poster: processedCover, + rate: rate, + }); + } + + return { + code: 200, + message: '获取成功', + list: movies, + }; + } catch (error) { + throw new Error(`获取豆瓣 Top250 数据失败: ${(error as Error).message}`); + } +} + +/** + * 带超时的 fetch 请求 + */ +async function fetchWithTimeout( + url: string, + options: RequestInit = {} +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 + + // 检查是否使用代理 + const proxyUrl = getDoubanProxyUrl(); + const finalUrl = proxyUrl ? `${proxyUrl}${encodeURIComponent(url)}` : url; + + const fetchOptions: RequestInit = { + ...options, + 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: 'application/json, text/plain, */*', + ...options.headers, + }, + }; + + try { + const response = await fetch(finalUrl, fetchOptions); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +} + +/** + * 获取豆瓣代理 URL 设置 + */ +export function getDoubanProxyUrl(): string | null { + if (typeof window === 'undefined') return null; + + const doubanProxyUrl = localStorage.getItem('doubanProxyUrl'); + return doubanProxyUrl && doubanProxyUrl.trim() ? doubanProxyUrl.trim() : null; +} + +/** + * 检查是否应该使用客户端获取豆瓣数据 + */ +export function shouldUseDoubanClient(): boolean { + return getDoubanProxyUrl() !== null; +} + +/** + * 统一的豆瓣数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取 + */ +export async function getDoubanData( + params: DoubanClientParams +): Promise { + if (shouldUseDoubanClient()) { + // 使用客户端代理获取(当设置了代理 URL 时) + return fetchDoubanDataClient(params); + } else { + // 使用服务端 API(当没有设置代理 URL 时) + const { type, tag, pageSize = 16, pageStart = 0 } = params; + const response = await fetch( + `/api/douban?type=${type}&tag=${tag}&pageSize=${pageSize}&pageStart=${pageStart}` + ); + + if (!response.ok) { + throw new Error('获取豆瓣数据失败'); + } + + return response.json(); + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5c7b3a6..0e069e8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,6 +2,31 @@ import Hls from 'hls.js'; +/** + * 获取图片代理 URL 设置 + */ +export function getImageProxyUrl(): string | null { + if (typeof window === 'undefined') return null; + + const imageProxyUrl = localStorage.getItem('imageProxyUrl'); + return imageProxyUrl && imageProxyUrl.trim() ? imageProxyUrl.trim() : null; +} + +/** + * 处理图片 URL,如果设置了图片代理则使用代理 + */ +export function processImageUrl(originalUrl: string): string { + if (!originalUrl) return originalUrl; + + const proxyUrl = getImageProxyUrl(); + if (!proxyUrl) return originalUrl; + + // 如果原始 URL 已经是代理 URL,则不再处理 + if (originalUrl.includes(proxyUrl)) return originalUrl; + + return `${proxyUrl}${encodeURIComponent(originalUrl)}`; +} + export function cleanHtmlTags(text: string): string { if (!text) return ''; return text