]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
326 | const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : '';
327 |
328 | return JSON.stringify({
329 | code: 200,
330 | episodes: matches,
331 | detailUrl: detailUrl,
332 | videoInfo: {
333 | title: titleText,
334 | desc: descText,
335 | source_name: API_SITES[sourceCode].name,
336 | source_code: sourceCode
337 | }
338 | });
339 | } catch (error) {
340 | console.error(`${API_SITES[sourceCode].name}详情获取失败:`, error);
341 | throw error;
342 | }
343 | }
344 |
345 | // 处理聚合搜索
346 | async function handleAggregatedSearch(searchQuery) {
347 | // 获取可用的API源列表(排除aggregated和custom)
348 | const availableSources = Object.keys(API_SITES).filter(key =>
349 | key !== 'aggregated' && key !== 'custom'
350 | );
351 |
352 | if (availableSources.length === 0) {
353 | throw new Error('没有可用的API源');
354 | }
355 |
356 | // 创建所有API源的搜索请求
357 | const searchPromises = availableSources.map(async (source) => {
358 | try {
359 | const apiUrl = `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;
360 |
361 | // 使用Promise.race添加超时处理
362 | const timeoutPromise = new Promise((_, reject) =>
363 | setTimeout(() => reject(new Error(`${source}源搜索超时`)), 8000)
364 | );
365 |
366 | const fetchPromise = fetch(PROXY_URL + encodeURIComponent(apiUrl), {
367 | headers: API_CONFIG.search.headers
368 | });
369 |
370 | const response = await Promise.race([fetchPromise, timeoutPromise]);
371 |
372 | if (!response.ok) {
373 | throw new Error(`${source}源请求失败: ${response.status}`);
374 | }
375 |
376 | const data = await response.json();
377 |
378 | if (!data || !Array.isArray(data.list)) {
379 | throw new Error(`${source}源返回的数据格式无效`);
380 | }
381 |
382 | // 为搜索结果添加源信息
383 | const results = data.list.map(item => ({
384 | ...item,
385 | source_name: API_SITES[source].name,
386 | source_code: source
387 | }));
388 |
389 | return results;
390 | } catch (error) {
391 | console.warn(`${source}源搜索失败:`, error);
392 | return []; // 返回空数组表示该源搜索失败
393 | }
394 | });
395 |
396 | try {
397 | // 并行执行所有搜索请求
398 | const resultsArray = await Promise.all(searchPromises);
399 |
400 | // 合并所有结果
401 | let allResults = [];
402 | resultsArray.forEach(results => {
403 | if (Array.isArray(results) && results.length > 0) {
404 | allResults = allResults.concat(results);
405 | }
406 | });
407 |
408 | // 如果没有搜索结果,返回空结果
409 | if (allResults.length === 0) {
410 | return JSON.stringify({
411 | code: 200,
412 | list: [],
413 | msg: '所有源均无搜索结果'
414 | });
415 | }
416 |
417 | // 去重(根据vod_id和source_code组合)
418 | const uniqueResults = [];
419 | const seen = new Set();
420 |
421 | allResults.forEach(item => {
422 | const key = `${item.source_code}_${item.vod_id}`;
423 | if (!seen.has(key)) {
424 | seen.add(key);
425 | uniqueResults.push(item);
426 | }
427 | });
428 |
429 | // 按照视频名称和来源排序
430 | uniqueResults.sort((a, b) => {
431 | // 首先按照视频名称排序
432 | const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || '');
433 | if (nameCompare !== 0) return nameCompare;
434 |
435 | // 如果名称相同,则按照来源排序
436 | return (a.source_name || '').localeCompare(b.source_name || '');
437 | });
438 |
439 | return JSON.stringify({
440 | code: 200,
441 | list: uniqueResults,
442 | });
443 | } catch (error) {
444 | console.error('聚合搜索处理错误:', error);
445 | return JSON.stringify({
446 | code: 400,
447 | msg: '聚合搜索处理失败: ' + error.message,
448 | list: []
449 | });
450 | }
451 | }
452 |
453 | // 处理多个自定义API源的聚合搜索
454 | async function handleMultipleCustomSearch(searchQuery, customApiUrls) {
455 | // 解析自定义API列表
456 | const apiUrls = customApiUrls.split(CUSTOM_API_CONFIG.separator)
457 | .map(url => url.trim())
458 | .filter(url => url.length > 0 && /^https?:\/\//.test(url))
459 | .slice(0, CUSTOM_API_CONFIG.maxSources);
460 |
461 | if (apiUrls.length === 0) {
462 | throw new Error('没有提供有效的自定义API地址');
463 | }
464 |
465 | // 为每个API创建搜索请求
466 | const searchPromises = apiUrls.map(async (apiUrl, index) => {
467 | try {
468 | const fullUrl = `${apiUrl}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;
469 |
470 | // 使用Promise.race添加超时处理
471 | const timeoutPromise = new Promise((_, reject) =>
472 | setTimeout(() => reject(new Error(`自定义API ${index+1} 搜索超时`)), 8000)
473 | );
474 |
475 | const fetchPromise = fetch(PROXY_URL + encodeURIComponent(fullUrl), {
476 | headers: API_CONFIG.search.headers
477 | });
478 |
479 | const response = await Promise.race([fetchPromise, timeoutPromise]);
480 |
481 | if (!response.ok) {
482 | throw new Error(`自定义API ${index+1} 请求失败: ${response.status}`);
483 | }
484 |
485 | const data = await response.json();
486 |
487 | if (!data || !Array.isArray(data.list)) {
488 | throw new Error(`自定义API ${index+1} 返回的数据格式无效`);
489 | }
490 |
491 | // 为搜索结果添加源信息
492 | const results = data.list.map(item => ({
493 | ...item,
494 | source_name: `${CUSTOM_API_CONFIG.namePrefix}${index+1}`,
495 | source_code: 'custom',
496 | api_url: apiUrl // 保存API URL以便详情获取
497 | }));
498 |
499 | return results;
500 | } catch (error) {
501 | console.warn(`自定义API ${index+1} 搜索失败:`, error);
502 | return []; // 返回空数组表示该源搜索失败
503 | }
504 | });
505 |
506 | try {
507 | // 并行执行所有搜索请求
508 | const resultsArray = await Promise.all(searchPromises);
509 |
510 | // 合并所有结果
511 | let allResults = [];
512 | resultsArray.forEach(results => {
513 | if (Array.isArray(results) && results.length > 0) {
514 | allResults = allResults.concat(results);
515 | }
516 | });
517 |
518 | // 如果没有搜索结果,返回空结果
519 | if (allResults.length === 0) {
520 | return JSON.stringify({
521 | code: 200,
522 | list: [],
523 | msg: '所有自定义API源均无搜索结果'
524 | });
525 | }
526 |
527 | // 去重(根据vod_id和api_url组合)
528 | const uniqueResults = [];
529 | const seen = new Set();
530 |
531 | allResults.forEach(item => {
532 | const key = `${item.api_url || ''}_${item.vod_id}`;
533 | if (!seen.has(key)) {
534 | seen.add(key);
535 | uniqueResults.push(item);
536 | }
537 | });
538 |
539 | return JSON.stringify({
540 | code: 200,
541 | list: uniqueResults,
542 | });
543 | } catch (error) {
544 | console.error('自定义API聚合搜索处理错误:', error);
545 | return JSON.stringify({
546 | code: 400,
547 | msg: '自定义API聚合搜索处理失败: ' + error.message,
548 | list: []
549 | });
550 | }
551 | }
552 |
553 | // 拦截API请求
554 | (function() {
555 | const originalFetch = window.fetch;
556 |
557 | window.fetch = async function(input, init) {
558 | const requestUrl = typeof input === 'string' ? new URL(input, window.location.origin) : input.url;
559 |
560 | if (requestUrl.pathname.startsWith('/api/')) {
561 | if (window.isPasswordProtected && window.isPasswordVerified) {
562 | if (window.isPasswordProtected() && !window.isPasswordVerified()) {
563 | return;
564 | }
565 | }
566 | try {
567 | const data = await handleApiRequest(requestUrl);
568 | return new Response(data, {
569 | headers: {
570 | 'Content-Type': 'application/json',
571 | 'Access-Control-Allow-Origin': '*',
572 | },
573 | });
574 | } catch (error) {
575 | return new Response(JSON.stringify({
576 | code: 500,
577 | msg: '服务器内部错误',
578 | }), {
579 | status: 500,
580 | headers: {
581 | 'Content-Type': 'application/json',
582 | },
583 | });
584 | }
585 | }
586 |
587 | // 非API请求使用原始fetch
588 | return originalFetch.apply(this, arguments);
589 | };
590 | })();
591 |
592 | async function testSiteAvailability(apiUrl) {
593 | try {
594 | // 使用更简单的测试查询
595 | const response = await fetch('/api/search?wd=test&customApi=' + encodeURIComponent(apiUrl), {
596 | // 添加超时
597 | signal: AbortSignal.timeout(5000)
598 | });
599 |
600 | // 检查响应状态
601 | if (!response.ok) {
602 | return false;
603 | }
604 |
605 | const data = await response.json();
606 |
607 | // 检查API响应的有效性
608 | return data && data.code !== 400 && Array.isArray(data.list);
609 | } catch (error) {
610 | console.error('站点可用性测试失败:', error);
611 | return false;
612 | }
613 | }
614 |
--------------------------------------------------------------------------------
/js/config.js:
--------------------------------------------------------------------------------
1 | // 全局常量配置
2 | const PROXY_URL = '/proxy/'; // 适用于 Cloudflare, Netlify (带重写), Vercel (带重写)
3 | // const HOPLAYER_URL = 'https://hoplayer.com/index.html';
4 | const SEARCH_HISTORY_KEY = 'videoSearchHistory';
5 | const MAX_HISTORY_ITEMS = 5;
6 |
7 | // 密码保护配置
8 | const PASSWORD_CONFIG = {
9 | localStorageKey: 'passwordVerified', // 存储验证状态的键名
10 | verificationTTL: 90 * 24 * 60 * 60 * 1000, // 验证有效期(90天,约3个月)
11 | };
12 |
13 | // 网站信息配置
14 | const SITE_CONFIG = {
15 | name: 'LibreTV',
16 | url: 'https://libretv.is-an.org',
17 | description: '免费在线视频搜索与观看平台',
18 | logo: 'https://images.icon-icons.com/38/PNG/512/retrotv_5520.png',
19 | version: '1.0.3'
20 | };
21 |
22 | // API站点配置
23 | const API_SITES = {
24 | heimuer: {
25 | api: 'https://json.heimuer.xyz',
26 | name: '黑木耳',
27 | detail: 'https://heimuer.tv'
28 | },
29 | ffzy: {
30 | api: 'http://ffzy5.tv',
31 | name: '非凡影视',
32 | detail: 'http://ffzy5.tv'
33 | },
34 | tyyszy: {
35 | api: 'https://tyyszy.com',
36 | name: '天涯资源',
37 | },
38 | ckzy: {
39 | api: 'https://www.ckzy1.com',
40 | name: 'CK资源',
41 | adult: true
42 | },
43 | zy360: {
44 | api: 'https://360zy.com',
45 | name: '360资源',
46 | },
47 | wolong: {
48 | api: 'https://wolongzyw.com',
49 | name: '卧龙资源',
50 | },
51 | cjhw: {
52 | api: 'https://cjhwba.com',
53 | name: '新华为',
54 | },
55 | hwba: {
56 | api: 'https://cjwba.com',
57 | name: '华为吧资源',
58 | },
59 | jisu: {
60 | api: 'https://jszyapi.com',
61 | name: '极速资源',
62 | detail: 'https://jszyapi.com'
63 | },
64 | dbzy: {
65 | api: 'https://dbzy.com',
66 | name: '豆瓣资源',
67 | },
68 | bfzy: {
69 | api: 'https://bfzyapi.com',
70 | name: '暴风资源',
71 | },
72 | mozhua: {
73 | api: 'https://mozhuazy.com',
74 | name: '魔爪资源',
75 | },
76 | mdzy: {
77 | api: 'https://www.mdzyapi.com',
78 | name: '魔都资源',
79 | },
80 | ruyi: {
81 | api: 'https://cj.rycjapi.com',
82 | name: '如意资源',
83 | },
84 | jkun: {
85 | api: 'https://jkunzyapi.com',
86 | name: 'jkun资源',
87 | adult: true
88 | },
89 | bwzy: {
90 | api: 'https://api.bwzym3u8.com',
91 | name: '百万资源',
92 | adult: true
93 | },
94 | souav: {
95 | api: 'https://api.souavzy.vip',
96 | name: 'souav资源',
97 | adult: true
98 | },
99 | r155: {
100 | api: 'https://155api.com',
101 | name: '155资源',
102 | adult: true
103 | },
104 | lsb: {
105 | api: 'https://apilsbzy1.com',
106 | name: 'lsb资源',
107 | adult: true
108 | },
109 | huangcang: {
110 | api: 'https://hsckzy.vip',
111 | name: '黄色仓库',
112 | adult: true,
113 | detail: 'https://hsckzy.vip'
114 | },
115 | zuid: {
116 | api: 'https://api.zuidapi.com',
117 | name: '最大资源'
118 | },
119 | yutu: {
120 | api: 'https://yutuzy10.com',
121 | name: '玉兔资源',
122 | adult: true
123 | }
124 | // 您可以按需添加更多源
125 | };
126 |
127 | // 添加聚合搜索的配置选项
128 | const AGGREGATED_SEARCH_CONFIG = {
129 | enabled: true, // 是否启用聚合搜索
130 | timeout: 8000, // 单个源超时时间(毫秒)
131 | maxResults: 10000, // 最大结果数量
132 | parallelRequests: true, // 是否并行请求所有源
133 | showSourceBadges: true // 是否显示来源徽章
134 | };
135 |
136 | // 抽象API请求配置
137 | const API_CONFIG = {
138 | search: {
139 | // 修改搜索接口为返回更多详细数据(包括视频封面、简介和播放列表)
140 | path: '/api.php/provide/vod/?ac=videolist&wd=',
141 | headers: {
142 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
143 | 'Accept': 'application/json'
144 | }
145 | },
146 | detail: {
147 | // 修改详情接口也使用videolist接口,但是通过ID查询,减少请求次数
148 | path: '/api.php/provide/vod/?ac=videolist&ids=',
149 | headers: {
150 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
151 | 'Accept': 'application/json'
152 | }
153 | }
154 | };
155 |
156 | // 优化后的正则表达式模式
157 | const M3U8_PATTERN = /\$https?:\/\/[^"'\s]+?\.m3u8/g;
158 |
159 | // 添加自定义播放器URL
160 | const CUSTOM_PLAYER_URL = 'player.html'; // 使用相对路径引用本地player.html
161 |
162 | // 增加视频播放相关配置
163 | const PLAYER_CONFIG = {
164 | autoplay: true,
165 | allowFullscreen: true,
166 | width: '100%',
167 | height: '600',
168 | timeout: 15000, // 播放器加载超时时间
169 | filterAds: true, // 是否启用广告过滤
170 | autoPlayNext: true, // 默认启用自动连播功能
171 | adFilteringEnabled: true, // 默认开启分片广告过滤
172 | adFilteringStorage: 'adFilteringEnabled' // 存储广告过滤设置的键名
173 | };
174 |
175 | // 增加错误信息本地化
176 | const ERROR_MESSAGES = {
177 | NETWORK_ERROR: '网络连接错误,请检查网络设置',
178 | TIMEOUT_ERROR: '请求超时,服务器响应时间过长',
179 | API_ERROR: 'API接口返回错误,请尝试更换数据源',
180 | PLAYER_ERROR: '播放器加载失败,请尝试其他视频源',
181 | UNKNOWN_ERROR: '发生未知错误,请刷新页面重试'
182 | };
183 |
184 | // 添加进一步安全设置
185 | const SECURITY_CONFIG = {
186 | enableXSSProtection: true, // 是否启用XSS保护
187 | sanitizeUrls: true, // 是否清理URL
188 | maxQueryLength: 100, // 最大搜索长度
189 | // allowedApiDomains 不再需要,因为所有请求都通过内部代理
190 | };
191 |
192 | // 添加多个自定义API源的配置
193 | const CUSTOM_API_CONFIG = {
194 | separator: ',', // 分隔符
195 | maxSources: 5, // 最大允许的自定义源数量
196 | testTimeout: 5000, // 测试超时时间(毫秒)
197 | namePrefix: 'Custom-', // 自定义源名称前缀
198 | validateUrl: true, // 验证URL格式
199 | cacheResults: true, // 缓存测试结果
200 | cacheExpiry: 5184000000, // 缓存过期时间(2个月)
201 | adultPropName: 'isAdult' // 用于标记成人内容的属性名
202 | };
203 |
204 | // 新增隐藏内置黄色采集站API的变量,默认为true
205 | const HIDE_BUILTIN_ADULT_APIS = true;
206 |
--------------------------------------------------------------------------------
/js/douban.js:
--------------------------------------------------------------------------------
1 | // 豆瓣热门电影电视剧推荐功能
2 |
3 | // 豆瓣标签列表
4 | let movieTags = ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '爱情', '科幻', '悬疑', '恐怖', '治愈'];
5 | let tvTags = ['热门', '美剧', '英剧', '韩剧', '日剧', '国产剧', '港剧', '日本动画', '综艺', '纪录片']
6 | let doubanMovieTvCurrentSwitch = 'movie';
7 | let doubanCurrentTag = '热门';
8 | let doubanPageStart = 0;
9 | const doubanPageSize = 16; // 一次显示的项目数量
10 |
11 | // 初始化豆瓣功能
12 | function initDouban() {
13 | // 设置豆瓣开关的初始状态
14 | const doubanToggle = document.getElementById('doubanToggle');
15 | if (doubanToggle) {
16 | const isEnabled = localStorage.getItem('doubanEnabled') === 'true';
17 | doubanToggle.checked = isEnabled;
18 |
19 | // 设置开关外观
20 | const toggleBg = doubanToggle.nextElementSibling;
21 | const toggleDot = toggleBg.nextElementSibling;
22 | if (isEnabled) {
23 | toggleBg.classList.add('bg-pink-600');
24 | toggleDot.classList.add('translate-x-6');
25 | }
26 |
27 | // 添加事件监听
28 | doubanToggle.addEventListener('change', function(e) {
29 | const isChecked = e.target.checked;
30 | localStorage.setItem('doubanEnabled', isChecked);
31 |
32 | // 更新开关外观
33 | if (isChecked) {
34 | toggleBg.classList.add('bg-pink-600');
35 | toggleDot.classList.add('translate-x-6');
36 | } else {
37 | toggleBg.classList.remove('bg-pink-600');
38 | toggleDot.classList.remove('translate-x-6');
39 | }
40 |
41 | // 更新显示状态
42 | updateDoubanVisibility();
43 | });
44 |
45 | // 初始更新显示状态
46 | updateDoubanVisibility();
47 | }
48 |
49 | // 获取豆瓣热门标签
50 | fetchDoubanTags();
51 |
52 | // 渲染电影/电视剧切换
53 | renderDoubanMovieTvSwitch();
54 |
55 | // 渲染豆瓣标签
56 | renderDoubanTags();
57 |
58 | // 换一批按钮事件监听
59 | setupDoubanRefreshBtn();
60 |
61 | // 初始加载热门内容
62 | if (localStorage.getItem('doubanEnabled') === 'true') {
63 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
64 | }
65 | }
66 |
67 | // 根据设置更新豆瓣区域的显示状态
68 | function updateDoubanVisibility() {
69 | const doubanArea = document.getElementById('doubanArea');
70 | if (!doubanArea) return;
71 |
72 | const isEnabled = localStorage.getItem('doubanEnabled') === 'true';
73 | const isSearching = document.getElementById('resultsArea') &&
74 | !document.getElementById('resultsArea').classList.contains('hidden');
75 |
76 | // 只有在启用且没有搜索结果显示时才显示豆瓣区域
77 | if (isEnabled && !isSearching) {
78 | doubanArea.classList.remove('hidden');
79 | // 如果豆瓣结果为空,重新加载
80 | if (document.getElementById('douban-results').children.length === 0) {
81 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
82 | }
83 | } else {
84 | doubanArea.classList.add('hidden');
85 | }
86 | }
87 |
88 | // 只填充搜索框,不执行搜索,让用户自主决定搜索时机
89 | function fillSearchInput(title) {
90 | if (!title) return;
91 |
92 | // 安全处理标题,防止XSS
93 | const safeTitle = title
94 | .replace(//g, '>')
96 | .replace(/"/g, '"');
97 |
98 | const input = document.getElementById('searchInput');
99 | if (input) {
100 | input.value = safeTitle;
101 |
102 | // 聚焦搜索框,便于用户立即使用键盘操作
103 | input.focus();
104 |
105 | // 显示一个提示,告知用户点击搜索按钮进行搜索
106 | showToast('已填充搜索内容,点击搜索按钮开始搜索', 'info');
107 | }
108 | }
109 |
110 | // 填充搜索框并执行搜索
111 | function fillAndSearch(title) {
112 | if (!title) return;
113 |
114 | // 安全处理标题,防止XSS
115 | const safeTitle = title
116 | .replace(//g, '>')
118 | .replace(/"/g, '"');
119 |
120 | const input = document.getElementById('searchInput');
121 | if (input) {
122 | input.value = safeTitle;
123 | search(); // 使用已有的search函数执行搜索
124 | }
125 | }
126 |
127 | // 填充搜索框,确保豆瓣资源API被选中,然后执行搜索
128 | function fillAndSearchWithDouban(title) {
129 | if (!title) return;
130 |
131 | // 安全处理标题,防止XSS
132 | const safeTitle = title
133 | .replace(//g, '>')
135 | .replace(/"/g, '"');
136 |
137 | // 确保豆瓣资源API被选中
138 | if (typeof selectedAPIs !== 'undefined' && !selectedAPIs.includes('dbzy')) {
139 | // 在设置中勾选豆瓣资源API复选框
140 | const doubanCheckbox = document.querySelector('input[id="api_dbzy"]');
141 | if (doubanCheckbox) {
142 | doubanCheckbox.checked = true;
143 |
144 | // 触发updateSelectedAPIs函数以更新状态
145 | if (typeof updateSelectedAPIs === 'function') {
146 | updateSelectedAPIs();
147 | } else {
148 | // 如果函数不可用,则手动添加到selectedAPIs
149 | selectedAPIs.push('dbzy');
150 | localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
151 |
152 | // 更新选中API计数(如果有这个元素)
153 | const countEl = document.getElementById('selectedAPICount');
154 | if (countEl) {
155 | countEl.textContent = selectedAPIs.length;
156 | }
157 | }
158 |
159 | showToast('已自动选择豆瓣资源API', 'info');
160 | }
161 | }
162 |
163 | // 填充搜索框并执行搜索
164 | const input = document.getElementById('searchInput');
165 | if (input) {
166 | input.value = safeTitle;
167 | search(); // 使用已有的search函数执行搜索
168 | }
169 | }
170 |
171 | // 渲染电影/电视剧切换器
172 | function renderDoubanMovieTvSwitch() {
173 | // 获取切换按钮元素
174 | const movieToggle = document.getElementById('douban-movie-toggle');
175 | const tvToggle = document.getElementById('douban-tv-toggle');
176 |
177 | if (!movieToggle ||!tvToggle) return;
178 |
179 | movieToggle.addEventListener('click', function() {
180 | if (doubanMovieTvCurrentSwitch !== 'movie') {
181 | // 更新按钮样式
182 | movieToggle.classList.add('bg-pink-600', 'text-white');
183 | movieToggle.classList.remove('text-gray-300');
184 |
185 | tvToggle.classList.remove('bg-pink-600', 'text-white');
186 | tvToggle.classList.add('text-gray-300');
187 |
188 | doubanMovieTvCurrentSwitch = 'movie';
189 | doubanCurrentTag = '热门';
190 |
191 | // 重新加载豆瓣内容
192 | renderDoubanTags(movieTags);
193 |
194 | // 换一批按钮事件监听
195 | setupDoubanRefreshBtn();
196 |
197 | // 初始加载热门内容
198 | if (localStorage.getItem('doubanEnabled') === 'true') {
199 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
200 | }
201 | }
202 | });
203 |
204 | // 电视剧按钮点击事件
205 | tvToggle.addEventListener('click', function() {
206 | if (doubanMovieTvCurrentSwitch !== 'tv') {
207 | // 更新按钮样式
208 | tvToggle.classList.add('bg-pink-600', 'text-white');
209 | tvToggle.classList.remove('text-gray-300');
210 |
211 | movieToggle.classList.remove('bg-pink-600', 'text-white');
212 | movieToggle.classList.add('text-gray-300');
213 |
214 | doubanMovieTvCurrentSwitch = 'tv';
215 | doubanCurrentTag = '热门';
216 |
217 | // 重新加载豆瓣内容
218 | renderDoubanTags(tvTags);
219 |
220 | // 换一批按钮事件监听
221 | setupDoubanRefreshBtn();
222 |
223 | // 初始加载热门内容
224 | if (localStorage.getItem('doubanEnabled') === 'true') {
225 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
226 | }
227 | }
228 | });
229 | }
230 |
231 | // 渲染豆瓣标签选择器
232 | function renderDoubanTags(tags = movieTags) {
233 | const tagContainer = document.getElementById('douban-tags');
234 | if (!tagContainer) return;
235 |
236 | tagContainer.innerHTML = '';
237 |
238 | tags.forEach(tag => {
239 | const btn = document.createElement('button');
240 | // 更新标签样式:统一高度,添加过渡效果,改进颜色对比度
241 | btn.className = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 ' +
242 | (tag === doubanCurrentTag ?
243 | 'bg-pink-600 text-white shadow-md' :
244 | 'bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white');
245 |
246 | btn.textContent = tag;
247 |
248 | btn.onclick = function() {
249 | if (doubanCurrentTag !== tag) {
250 | doubanCurrentTag = tag;
251 | doubanPageStart = 0;
252 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
253 | renderDoubanTags(tags);
254 | }
255 | };
256 |
257 | tagContainer.appendChild(btn);
258 | });
259 | }
260 |
261 | // 设置换一批按钮事件
262 | function setupDoubanRefreshBtn() {
263 | // 修复ID,使用正确的ID douban-refresh 而不是 douban-refresh-btn
264 | const btn = document.getElementById('douban-refresh');
265 | if (!btn) return;
266 |
267 | btn.onclick = function() {
268 | doubanPageStart += doubanPageSize;
269 | if (doubanPageStart > 9 * doubanPageSize) {
270 | doubanPageStart = 0;
271 | }
272 |
273 | renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
274 | };
275 | }
276 |
277 | function fetchDoubanTags() {
278 | const movieTagsTarget = `https://movie.douban.com/j/search_tags?type=movie`
279 | fetchDoubanData(movieTagsTarget)
280 | .then(data => {
281 | movieTags = data.tags;
282 | if (doubanMovieTvCurrentSwitch === 'movie') {
283 | renderDoubanTags(movieTags);
284 | }
285 | })
286 | .catch(error => {
287 | console.error("获取豆瓣热门电影标签失败:", error);
288 | });
289 | const tvTagsTarget = `https://movie.douban.com/j/search_tags?type=tv`
290 | fetchDoubanData(tvTagsTarget)
291 | .then(data => {
292 | tvTags = data.tags;
293 | if (doubanMovieTvCurrentSwitch === 'tv') {
294 | renderDoubanTags(tvTags);
295 | }
296 | })
297 | .catch(error => {
298 | console.error("获取豆瓣热门电视剧标签失败:", error);
299 | });
300 | }
301 |
302 | // 渲染热门推荐内容
303 | function renderRecommend(tag, pageLimit, pageStart) {
304 | const container = document.getElementById("douban-results");
305 | if (!container) return;
306 |
307 | // 显示加载状态
308 | container.innerHTML = `
309 |
313 | `;
314 |
315 | const target = `https://movie.douban.com/j/search_subjects?type=${doubanMovieTvCurrentSwitch}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
316 |
317 | // 使用通用请求函数
318 | fetchDoubanData(target)
319 | .then(data => {
320 | renderDoubanCards(data, container);
321 | })
322 | .catch(error => {
323 | console.error("获取豆瓣数据失败:", error);
324 | container.innerHTML = `
325 |
326 |
❌ 获取豆瓣数据失败,请稍后重试
327 |
提示:使用VPN可能有助于解决此问题
328 |
329 | `;
330 | });
331 | }
332 |
333 | async function fetchDoubanData(url) {
334 | // 添加超时控制
335 | const controller = new AbortController();
336 | const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
337 |
338 | // 设置请求选项,包括信号和头部
339 | const fetchOptions = {
340 | signal: controller.signal,
341 | headers: {
342 | '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',
343 | 'Referer': 'https://movie.douban.com/',
344 | 'Accept': 'application/json, text/plain, */*',
345 | }
346 | };
347 |
348 | try {
349 | // 尝试直接访问(豆瓣API可能允许部分CORS请求)
350 | const response = await fetch(PROXY_URL + encodeURIComponent(url), fetchOptions);
351 | clearTimeout(timeoutId);
352 |
353 | if (!response.ok) {
354 | throw new Error(`HTTP error! Status: ${response.status}`);
355 | }
356 |
357 | return await response.json();
358 | } catch (err) {
359 | console.error("豆瓣 API 请求失败(直接代理):", err);
360 |
361 | // 失败后尝试备用方法:作为备选
362 | const fallbackUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
363 |
364 | try {
365 | const fallbackResponse = await fetch(fallbackUrl);
366 |
367 | if (!fallbackResponse.ok) {
368 | throw new Error(`备用API请求失败! 状态: ${fallbackResponse.status}`);
369 | }
370 |
371 | const data = await fallbackResponse.json();
372 |
373 | // 解析原始内容
374 | if (data && data.contents) {
375 | return JSON.parse(data.contents);
376 | } else {
377 | throw new Error("无法获取有效数据");
378 | }
379 | } catch (fallbackErr) {
380 | console.error("豆瓣 API 备用请求也失败:", fallbackErr);
381 | throw fallbackErr; // 向上抛出错误,让调用者处理
382 | }
383 | }
384 | }
385 |
386 | // 抽取渲染豆瓣卡片的逻辑到单独函数
387 | function renderDoubanCards(data, container) {
388 | // 创建文档片段以提高性能
389 | const fragment = document.createDocumentFragment();
390 |
391 | // 如果没有数据
392 | if (!data.subjects || data.subjects.length === 0) {
393 | const emptyEl = document.createElement("div");
394 | emptyEl.className = "col-span-full text-center py-8";
395 | emptyEl.innerHTML = `
396 |
❌ 暂无数据,请尝试其他分类或刷新
397 | `;
398 | fragment.appendChild(emptyEl);
399 | } else {
400 | // 循环创建每个影视卡片
401 | data.subjects.forEach(item => {
402 | const card = document.createElement("div");
403 | card.className = "bg-[#111] hover:bg-[#222] transition-all duration-300 rounded-lg overflow-hidden flex flex-col transform hover:scale-105 shadow-md hover:shadow-lg";
404 |
405 | // 生成卡片内容,确保安全显示(防止XSS)
406 | const safeTitle = item.title
407 | .replace(//g, '>')
409 | .replace(/"/g, '"');
410 |
411 | const safeRate = (item.rate || "暂无")
412 | .replace(//g, '>');
414 |
415 | // 处理图片URL
416 | // 1. 直接使用豆瓣图片URL (添加no-referrer属性)
417 | const originalCoverUrl = item.cover;
418 |
419 | // 2. 也准备代理URL作为备选
420 | const proxiedCoverUrl = PROXY_URL + encodeURIComponent(originalCoverUrl);
421 |
422 | // 为不同设备优化卡片布局
423 | card.innerHTML = `
424 |
425 |
429 |
430 |
431 | ★ ${safeRate}
432 |
433 |
438 |
439 |
440 |
443 | ${safeTitle}
444 |
445 |
446 | `;
447 |
448 | fragment.appendChild(card);
449 | });
450 | }
451 |
452 | // 清空并添加所有新元素
453 | container.innerHTML = "";
454 | container.appendChild(fragment);
455 | }
456 |
457 | // 重置到首页
458 | function resetToHome() {
459 | resetSearchArea();
460 | updateDoubanVisibility();
461 | }
462 |
463 | // 加载豆瓣首页内容
464 | document.addEventListener('DOMContentLoaded', initDouban);
--------------------------------------------------------------------------------
/js/password.js:
--------------------------------------------------------------------------------
1 | // 密码保护功能
2 |
3 | /**
4 | * 检查是否设置了密码保护
5 | * 通过读取页面上嵌入的环境变量来检查
6 | */
7 | function isPasswordProtected() {
8 | // 检查页面上嵌入的环境变量
9 | const pwd = window.__ENV__ && window.__ENV__.PASSWORD;
10 | // 只有当密码 hash 存在且为64位(SHA-256十六进制长度)才认为启用密码保护
11 | return typeof pwd === 'string' && pwd.length === 64 && !/^0+$/.test(pwd);
12 | }
13 |
14 | /**
15 | * 检查用户是否已通过密码验证
16 | * 检查localStorage中的验证状态和时间戳是否有效,并确认密码哈希未更改
17 | */
18 | function isPasswordVerified() {
19 | try {
20 | // 如果没有设置密码保护,则视为已验证
21 | if (!isPasswordProtected()) {
22 | return true;
23 | }
24 |
25 | const verificationData = JSON.parse(localStorage.getItem(PASSWORD_CONFIG.localStorageKey) || '{}');
26 | const { verified, timestamp, passwordHash } = verificationData;
27 |
28 | // 获取当前环境中的密码哈希
29 | const currentHash = window.__ENV__ && window.__ENV__.PASSWORD;
30 |
31 | // 验证是否已验证、未过期,且密码哈希未更改
32 | if (verified && timestamp && passwordHash === currentHash) {
33 | const now = Date.now();
34 | const expiry = timestamp + PASSWORD_CONFIG.verificationTTL;
35 | return now < expiry;
36 | }
37 |
38 | return false;
39 | } catch (error) {
40 | console.error('验证密码状态时出错:', error);
41 | return false;
42 | }
43 | }
44 |
45 | window.isPasswordProtected = isPasswordProtected;
46 | window.isPasswordVerified = isPasswordVerified;
47 |
48 | /**
49 | * 验证用户输入的密码是否正确(异步,使用SHA-256哈希)
50 | */
51 | async function verifyPassword(password) {
52 | const correctHash = window.__ENV__ && window.__ENV__.PASSWORD;
53 | if (!correctHash) return false;
54 | const inputHash = await sha256(password);
55 | const isValid = inputHash === correctHash;
56 | if (isValid) {
57 | const verificationData = {
58 | verified: true,
59 | timestamp: Date.now(),
60 | passwordHash: correctHash // 保存当前密码的哈希值
61 | };
62 | localStorage.setItem(PASSWORD_CONFIG.localStorageKey, JSON.stringify(verificationData));
63 | }
64 | return isValid;
65 | }
66 |
67 | // SHA-256实现,可用Web Crypto API
68 | async function sha256(message) {
69 | if (window.crypto && crypto.subtle && crypto.subtle.digest) {
70 | const msgBuffer = new TextEncoder().encode(message);
71 | const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
72 | const hashArray = Array.from(new Uint8Array(hashBuffer));
73 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
74 | }
75 | // HTTP 下调用原始 js‑sha256
76 | if (typeof window._jsSha256 === 'function') {
77 | return window._jsSha256(message);
78 | }
79 | throw new Error('No SHA-256 implementation available.');
80 | }
81 |
82 | /**
83 | * 显示密码验证弹窗
84 | */
85 | function showPasswordModal() {
86 | const passwordModal = document.getElementById('passwordModal');
87 | if (passwordModal) {
88 | passwordModal.style.display = 'flex';
89 |
90 | // 确保输入框获取焦点
91 | setTimeout(() => {
92 | const passwordInput = document.getElementById('passwordInput');
93 | if (passwordInput) {
94 | passwordInput.focus();
95 | }
96 | }, 100);
97 | }
98 | }
99 |
100 | /**
101 | * 隐藏密码验证弹窗
102 | */
103 | function hidePasswordModal() {
104 | const passwordModal = document.getElementById('passwordModal');
105 | if (passwordModal) {
106 | passwordModal.style.display = 'none';
107 | }
108 | }
109 |
110 | /**
111 | * 显示密码错误信息
112 | */
113 | function showPasswordError() {
114 | const errorElement = document.getElementById('passwordError');
115 | if (errorElement) {
116 | errorElement.classList.remove('hidden');
117 | }
118 | }
119 |
120 | /**
121 | * 隐藏密码错误信息
122 | */
123 | function hidePasswordError() {
124 | const errorElement = document.getElementById('passwordError');
125 | if (errorElement) {
126 | errorElement.classList.add('hidden');
127 | }
128 | }
129 |
130 | /**
131 | * 处理密码提交事件(异步)
132 | */
133 | async function handlePasswordSubmit() {
134 | const passwordInput = document.getElementById('passwordInput');
135 | const password = passwordInput ? passwordInput.value.trim() : '';
136 | if (await verifyPassword(password)) {
137 | hidePasswordError();
138 | hidePasswordModal();
139 | } else {
140 | showPasswordError();
141 | if (passwordInput) {
142 | passwordInput.value = '';
143 | passwordInput.focus();
144 | }
145 | }
146 | }
147 |
148 | /**
149 | * 初始化密码验证系统(需适配异步事件)
150 | */
151 | function initPasswordProtection() {
152 | if (!isPasswordProtected()) {
153 | return; // 如果未设置密码保护,则不进行任何操作
154 | }
155 |
156 | // 如果未验证密码,则显示密码验证弹窗
157 | if (!isPasswordVerified()) {
158 | showPasswordModal();
159 |
160 | // 设置密码提交按钮事件监听
161 | const submitButton = document.getElementById('passwordSubmitBtn');
162 | if (submitButton) {
163 | submitButton.addEventListener('click', handlePasswordSubmit);
164 | }
165 |
166 | // 设置密码输入框回车键监听
167 | const passwordInput = document.getElementById('passwordInput');
168 | if (passwordInput) {
169 | passwordInput.addEventListener('keypress', function(e) {
170 | if (e.key === 'Enter') {
171 | handlePasswordSubmit();
172 | }
173 | });
174 | }
175 | }
176 | }
177 |
178 | // 在页面加载完成后初始化密码保护
179 | document.addEventListener('DOMContentLoaded', initPasswordProtection);
--------------------------------------------------------------------------------
/js/sha256.js:
--------------------------------------------------------------------------------
1 | export async function sha256(message) {
2 | const msgBuffer = new TextEncoder().encode(message);
3 | const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
4 | const hashArray = Array.from(new Uint8Array(hashBuffer));
5 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
6 | }
7 |
--------------------------------------------------------------------------------
/js/ui.js:
--------------------------------------------------------------------------------
1 | // UI相关函数
2 | function toggleSettings(e) {
3 | // 密码保护校验
4 | if (window.isPasswordProtected && window.isPasswordVerified) {
5 | if (window.isPasswordProtected() && !window.isPasswordVerified()) {
6 | showPasswordModal && showPasswordModal();
7 | return;
8 | }
9 | }
10 | // 阻止事件冒泡,防止触发document的点击事件
11 | e && e.stopPropagation();
12 | const panel = document.getElementById('settingsPanel');
13 | panel.classList.toggle('show');
14 | }
15 |
16 | // 改进的Toast显示函数 - 支持队列显示多个Toast
17 | const toastQueue = [];
18 | let isShowingToast = false;
19 |
20 | function showToast(message, type = 'error') {
21 | // 将新的toast添加到队列
22 | toastQueue.push({ message, type });
23 |
24 | // 如果当前没有显示中的toast,则开始显示
25 | if (!isShowingToast) {
26 | showNextToast();
27 | }
28 | }
29 |
30 | function showNextToast() {
31 | if (toastQueue.length === 0) {
32 | isShowingToast = false;
33 | return;
34 | }
35 |
36 | isShowingToast = true;
37 | const { message, type } = toastQueue.shift();
38 |
39 | const toast = document.getElementById('toast');
40 | const toastMessage = document.getElementById('toastMessage');
41 |
42 | const bgColors = {
43 | 'error': 'bg-red-500',
44 | 'success': 'bg-green-500',
45 | 'info': 'bg-blue-500',
46 | 'warning': 'bg-yellow-500'
47 | };
48 |
49 | const bgColor = bgColors[type] || bgColors.error;
50 | toast.className = `fixed top-4 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 ${bgColor} text-white`;
51 | toastMessage.textContent = message;
52 |
53 | // 显示提示
54 | toast.style.opacity = '1';
55 | toast.style.transform = 'translateX(-50%) translateY(0)';
56 |
57 | // 3秒后自动隐藏
58 | setTimeout(() => {
59 | toast.style.opacity = '0';
60 | toast.style.transform = 'translateX(-50%) translateY(-100%)';
61 |
62 | // 等待动画完成后显示下一个toast
63 | setTimeout(() => {
64 | showNextToast();
65 | }, 300);
66 | }, 3000);
67 | }
68 |
69 | // 添加显示/隐藏 loading 的函数
70 | let loadingTimeoutId = null;
71 |
72 | function showLoading(message = '加载中...') {
73 | // 清除任何现有的超时
74 | if (loadingTimeoutId) {
75 | clearTimeout(loadingTimeoutId);
76 | }
77 |
78 | const loading = document.getElementById('loading');
79 | const messageEl = loading.querySelector('p');
80 | messageEl.textContent = message;
81 | loading.style.display = 'flex';
82 |
83 | // 设置30秒后自动关闭loading,防止无限loading
84 | loadingTimeoutId = setTimeout(() => {
85 | hideLoading();
86 | showToast('操作超时,请稍后重试', 'warning');
87 | }, 30000);
88 | }
89 |
90 | function hideLoading() {
91 | // 清除超时
92 | if (loadingTimeoutId) {
93 | clearTimeout(loadingTimeoutId);
94 | loadingTimeoutId = null;
95 | }
96 |
97 | const loading = document.getElementById('loading');
98 | loading.style.display = 'none';
99 | }
100 |
101 | function updateSiteStatus(isAvailable) {
102 | const statusEl = document.getElementById('siteStatus');
103 | if (isAvailable) {
104 | statusEl.innerHTML = '
● 可用';
105 | } else {
106 | statusEl.innerHTML = '
● 不可用';
107 | }
108 | }
109 |
110 | function closeModal() {
111 | document.getElementById('modal').classList.add('hidden');
112 | // 清除 iframe 内容
113 | document.getElementById('modalContent').innerHTML = '';
114 | }
115 |
116 | // 获取搜索历史的增强版本 - 支持新旧格式
117 | function getSearchHistory() {
118 | try {
119 | const data = localStorage.getItem(SEARCH_HISTORY_KEY);
120 | if (!data) return [];
121 |
122 | const parsed = JSON.parse(data);
123 |
124 | // 检查是否是数组
125 | if (!Array.isArray(parsed)) return [];
126 |
127 | // 支持旧格式(字符串数组)和新格式(对象数组)
128 | return parsed.map(item => {
129 | if (typeof item === 'string') {
130 | return { text: item, timestamp: 0 };
131 | }
132 | return item;
133 | }).filter(item => item && item.text);
134 | } catch (e) {
135 | console.error('获取搜索历史出错:', e);
136 | return [];
137 | }
138 | }
139 |
140 | // 保存搜索历史的增强版本 - 添加时间戳和最大数量限制,现在缓存2个月
141 | function saveSearchHistory(query) {
142 | if (!query || !query.trim()) return;
143 |
144 | // 清理输入,防止XSS
145 | query = query.trim().substring(0, 50).replace(//g, '>');
146 |
147 | let history = getSearchHistory();
148 |
149 | // 获取当前时间
150 | const now = Date.now();
151 |
152 | // 过滤掉超过2个月的记录(约60天,60*24*60*60*1000 = 5184000000毫秒)
153 | history = history.filter(item =>
154 | typeof item === 'object' && item.timestamp && (now - item.timestamp < 5184000000)
155 | );
156 |
157 | // 删除已存在的相同项
158 | history = history.filter(item =>
159 | typeof item === 'object' ? item.text !== query : item !== query
160 | );
161 |
162 | // 新项添加到开头,包含时间戳
163 | history.unshift({
164 | text: query,
165 | timestamp: now
166 | });
167 |
168 | // 限制历史记录数量
169 | if (history.length > MAX_HISTORY_ITEMS) {
170 | history = history.slice(0, MAX_HISTORY_ITEMS);
171 | }
172 |
173 | try {
174 | localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
175 | } catch (e) {
176 | console.error('保存搜索历史失败:', e);
177 | // 如果存储失败(可能是localStorage已满),尝试清理旧数据
178 | try {
179 | localStorage.removeItem(SEARCH_HISTORY_KEY);
180 | localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, 3)));
181 | } catch (e2) {
182 | console.error('再次保存搜索历史失败:', e2);
183 | }
184 | }
185 |
186 | renderSearchHistory();
187 | }
188 |
189 | // 渲染最近搜索历史的增强版本
190 | function renderSearchHistory() {
191 | const historyContainer = document.getElementById('recentSearches');
192 | if (!historyContainer) return;
193 |
194 | const history = getSearchHistory();
195 |
196 | if (history.length === 0) {
197 | historyContainer.innerHTML = '';
198 | return;
199 | }
200 |
201 | // 创建一个包含标题和清除按钮的行
202 | historyContainer.innerHTML = `
203 |
204 |
最近搜索:
205 |
207 | 清除搜索历史
208 |
209 |
210 | `;
211 |
212 | history.forEach(item => {
213 | const tag = document.createElement('button');
214 | tag.className = 'search-tag';
215 | tag.textContent = item.text;
216 |
217 | // 添加时间提示(如果有时间戳)
218 | if (item.timestamp) {
219 | const date = new Date(item.timestamp);
220 | tag.title = `搜索于: ${date.toLocaleString()}`;
221 | }
222 |
223 | tag.onclick = function() {
224 | document.getElementById('searchInput').value = item.text;
225 | search();
226 | };
227 | historyContainer.appendChild(tag);
228 | });
229 | }
230 |
231 | // 增加清除搜索历史功能
232 | function clearSearchHistory() {
233 | // 密码保护校验
234 | if (window.isPasswordProtected && window.isPasswordVerified) {
235 | if (window.isPasswordProtected() && !window.isPasswordVerified()) {
236 | showPasswordModal && showPasswordModal();
237 | return;
238 | }
239 | }
240 | try {
241 | localStorage.removeItem(SEARCH_HISTORY_KEY);
242 | renderSearchHistory();
243 | showToast('搜索历史已清除', 'success');
244 | } catch (e) {
245 | console.error('清除搜索历史失败:', e);
246 | showToast('清除搜索历史失败:', 'error');
247 | }
248 | }
249 |
250 | // 历史面板相关函数
251 | function toggleHistory(e) {
252 | // 密码保护校验
253 | if (window.isPasswordProtected && window.isPasswordVerified) {
254 | if (window.isPasswordProtected() && !window.isPasswordVerified()) {
255 | showPasswordModal && showPasswordModal();
256 | return;
257 | }
258 | }
259 | if (e) e.stopPropagation();
260 |
261 | const panel = document.getElementById('historyPanel');
262 | if (panel) {
263 | panel.classList.toggle('show');
264 |
265 | // 如果打开了历史记录面板,则加载历史数据
266 | if (panel.classList.contains('show')) {
267 | loadViewingHistory();
268 | }
269 |
270 | // 如果设置面板是打开的,则关闭它
271 | const settingsPanel = document.getElementById('settingsPanel');
272 | if (settingsPanel && settingsPanel.classList.contains('show')) {
273 | settingsPanel.classList.remove('show');
274 | }
275 | }
276 | }
277 |
278 | // 格式化时间戳为友好的日期时间格式
279 | function formatTimestamp(timestamp) {
280 | const date = new Date(timestamp);
281 | const now = new Date();
282 | const diff = now - date;
283 |
284 | // 小于1小时,显示"X分钟前"
285 | if (diff < 3600000) {
286 | const minutes = Math.floor(diff / 60000);
287 | return minutes <= 0 ? '刚刚' : `${minutes}分钟前`;
288 | }
289 |
290 | // 小于24小时,显示"X小时前"
291 | if (diff < 86400000) {
292 | const hours = Math.floor(diff / 3600000);
293 | return `${hours}小时前`;
294 | }
295 |
296 | // 小于7天,显示"X天前"
297 | if (diff < 604800000) {
298 | const days = Math.floor(diff / 86400000);
299 | return `${days}天前`;
300 | }
301 |
302 | // 其他情况,显示完整日期
303 | const year = date.getFullYear();
304 | const month = (date.getMonth() + 1).toString().padStart(2, '0');
305 | const day = date.getDate().toString().padStart(2, '0');
306 | const hour = date.getHours().toString().padStart(2, '0');
307 | const minute = date.getMinutes().toString().padStart(2, '0');
308 |
309 | return `${year}-${month}-${day} ${hour}:${minute}`;
310 | }
311 |
312 | // 获取观看历史记录
313 | function getViewingHistory() {
314 | try {
315 | const data = localStorage.getItem('viewingHistory');
316 | return data ? JSON.parse(data) : [];
317 | } catch (e) {
318 | console.error('获取观看历史失败:', e);
319 | return [];
320 | }
321 | }
322 |
323 | // 加载观看历史并渲染
324 | function loadViewingHistory() {
325 | const historyList = document.getElementById('historyList');
326 | if (!historyList) return;
327 |
328 | const history = getViewingHistory();
329 |
330 | if (history.length === 0) {
331 | historyList.innerHTML = `
暂无观看记录
`;
332 | return;
333 | }
334 |
335 | // 渲染历史记录
336 | historyList.innerHTML = history.map(item => {
337 | // 防止XSS
338 | const safeTitle = item.title
339 | .replace(//g, '>')
341 | .replace(/"/g, '"');
342 |
343 | const safeSource = item.sourceName ?
344 | item.sourceName.replace(//g, '>').replace(/"/g, '"') :
345 | '未知来源';
346 |
347 | const episodeText = item.episodeIndex !== undefined ?
348 | `第${item.episodeIndex + 1}集` : '';
349 |
350 | // 格式化进度信息
351 | let progressHtml = '';
352 | if (item.playbackPosition && item.duration && item.playbackPosition > 10 && item.playbackPosition < item.duration * 0.95) {
353 | const percent = Math.round((item.playbackPosition / item.duration) * 100);
354 | const formattedTime = formatPlaybackTime(item.playbackPosition);
355 | const formattedDuration = formatPlaybackTime(item.duration);
356 |
357 | progressHtml = `
358 |
359 |
362 |
${formattedTime} / ${formattedDuration}
363 |
364 | `;
365 | }
366 |
367 | // 为防止XSS,使用encodeURIComponent编码URL
368 | const safeURL = encodeURIComponent(item.url);
369 |
370 | // 构建历史记录项HTML,添加删除按钮,需要放在position:relative的容器中
371 | return `
372 |
373 |
376 |
377 |
378 |
379 |
380 |
381 |
${safeTitle}
382 |
383 | ${episodeText}
384 | ${episodeText ? '· ' : ''}
385 | ${safeSource}
386 |
387 | ${progressHtml}
388 |
${formatTimestamp(item.timestamp)}
389 |
390 |
391 | `;
392 | }).join('');
393 |
394 | // 检查是否存在较多历史记录,添加底部边距确保底部按钮不会挡住内容
395 | if (history.length > 5) {
396 | historyList.classList.add('pb-4');
397 | }
398 | }
399 |
400 | // 格式化播放时间为 mm:ss 格式
401 | function formatPlaybackTime(seconds) {
402 | if (!seconds || isNaN(seconds)) return '00:00';
403 |
404 | const minutes = Math.floor(seconds / 60);
405 | const remainingSeconds = Math.floor(seconds % 60);
406 |
407 | return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
408 | }
409 |
410 | // 删除单个历史记录项
411 | function deleteHistoryItem(encodedUrl) {
412 | try {
413 | // 解码URL
414 | const url = decodeURIComponent(encodedUrl);
415 |
416 | // 获取当前历史记录
417 | const history = getViewingHistory();
418 |
419 | // 过滤掉要删除的项
420 | const newHistory = history.filter(item => item.url !== url);
421 |
422 | // 保存回localStorage
423 | localStorage.setItem('viewingHistory', JSON.stringify(newHistory));
424 |
425 | // 重新加载历史记录显示
426 | loadViewingHistory();
427 |
428 | // 显示成功提示
429 | showToast('已删除该记录', 'success');
430 | } catch (e) {
431 | console.error('删除历史记录项失败:', e);
432 | showToast('删除记录失败', 'error');
433 | }
434 | }
435 |
436 | // 从历史记录播放
437 | function playFromHistory(url, title, episodeIndex, playbackPosition = 0) {
438 | try {
439 | // 尝试从localStorage获取当前视频的集数信息
440 | let episodesList = [];
441 |
442 | // 检查viewingHistory,查找匹配的项以获取其集数数据
443 | const historyRaw = localStorage.getItem('viewingHistory');
444 | if (historyRaw) {
445 | const history = JSON.parse(historyRaw);
446 | // 根据标题查找匹配的历史记录
447 | const historyItem = history.find(item => item.title === title);
448 |
449 | // 如果找到了匹配的历史记录,尝试获取该条目的集数数据
450 | if (historyItem && historyItem.episodes && Array.isArray(historyItem.episodes)) {
451 | episodesList = historyItem.episodes;
452 | console.log(`从历史记录找到视频 ${title} 的集数数据:`, episodesList.length);
453 | }
454 | }
455 |
456 | // 如果在历史记录中没找到,尝试使用上一个会话的集数数据
457 | if (episodesList.length === 0) {
458 | try {
459 | const storedEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');
460 | if (storedEpisodes.length > 0) {
461 | episodesList = storedEpisodes;
462 | console.log(`使用localStorage中的集数数据:`, episodesList.length);
463 | }
464 | } catch (e) {
465 | console.error('解析currentEpisodes失败:', e);
466 | }
467 | }
468 |
469 | // 将剧集列表保存到localStorage,避免过长的URL
470 | if (episodesList.length > 0) {
471 | localStorage.setItem('currentEpisodes', JSON.stringify(episodesList));
472 | console.log(`已将剧集列表保存到localStorage,共 ${episodesList.length} 集`);
473 | }
474 | // 构造带播放进度参数的URL
475 | const positionParam = playbackPosition > 10 ? `&position=${Math.floor(playbackPosition)}` : '';
476 |
477 | if (url.includes('?')) {
478 | // URL已有参数,添加索引和位置参数
479 | let playUrl = url;
480 | if (!url.includes('index=') && episodeIndex > 0) {
481 | playUrl += `&index=${episodeIndex}`;
482 | }
483 | if (playbackPosition > 10) {
484 | playUrl += positionParam;
485 | }
486 | window.open(playUrl, '_blank');
487 | } else {
488 | // 原始URL,构造player页面链接
489 | const playerUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}${positionParam}`;
490 | window.open(playerUrl, '_blank');
491 | }
492 | } catch (e) {
493 | console.error('从历史记录播放失败:', e);
494 | // 回退到原始简单URL
495 | const simpleUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}`;
496 | window.open(simpleUrl, '_blank');
497 | }
498 | }
499 |
500 | // 添加观看历史 - 确保每个视频标题只有一条记录
501 | function addToViewingHistory(videoInfo) {
502 | // 密码保护校验
503 | if (window.isPasswordProtected && window.isPasswordVerified) {
504 | if (window.isPasswordProtected() && !window.isPasswordVerified()) {
505 | showPasswordModal && showPasswordModal();
506 | return;
507 | }
508 | }
509 | try {
510 | const history = getViewingHistory();
511 |
512 | // 检查是否已经存在相同标题的记录(同一视频的不同集数)
513 | const existingIndex = history.findIndex(item => item.title === videoInfo.title);
514 | if (existingIndex !== -1) {
515 | // 存在则更新现有记录的集数和时间戳
516 | const existingItem = history[existingIndex];
517 | existingItem.episodeIndex = videoInfo.episodeIndex;
518 | existingItem.timestamp = Date.now();
519 |
520 | // 确保来源信息保留
521 | if (videoInfo.sourceName && !existingItem.sourceName) {
522 | existingItem.sourceName = videoInfo.sourceName;
523 | }
524 |
525 | // 更新播放进度信息,仅当新进度有效且大于10秒时
526 | if (videoInfo.playbackPosition && videoInfo.playbackPosition > 10) {
527 | existingItem.playbackPosition = videoInfo.playbackPosition;
528 | existingItem.duration = videoInfo.duration || existingItem.duration;
529 | }
530 |
531 | // 更新URL,确保能够跳转到正确的集数
532 | existingItem.url = videoInfo.url;
533 |
534 | // 重要:确保episodes数据与当前视频匹配
535 | // 只有当videoInfo中包含有效的episodes数据时才更新
536 | if (videoInfo.episodes && Array.isArray(videoInfo.episodes) && videoInfo.episodes.length > 0) {
537 | // 如果传入的集数数据与当前保存的不同,则更新
538 | if (!existingItem.episodes ||
539 | !Array.isArray(existingItem.episodes) ||
540 | existingItem.episodes.length !== videoInfo.episodes.length) {
541 | console.log(`更新 "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`);
542 | existingItem.episodes = [...videoInfo.episodes]; // 使用深拷贝
543 | }
544 | }
545 |
546 | // 移到最前面
547 | history.splice(existingIndex, 1);
548 | history.unshift(existingItem);
549 | } else {
550 | // 添加新记录到最前面,确保包含剧集数据
551 | const newItem = {
552 | ...videoInfo,
553 | timestamp: Date.now()
554 | };
555 |
556 | // 确保episodes字段是一个数组
557 | if (videoInfo.episodes && Array.isArray(videoInfo.episodes)) {
558 | newItem.episodes = [...videoInfo.episodes]; // 使用深拷贝
559 | console.log(`保存新视频 "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`);
560 | } else {
561 | // 如果没有提供episodes,初始化为空数组
562 | newItem.episodes = [];
563 | }
564 |
565 | history.unshift(newItem);
566 | }
567 |
568 | // 限制历史记录数量为50条
569 | const maxHistoryItems = 50;
570 | if (history.length > maxHistoryItems) {
571 | history.splice(maxHistoryItems);
572 | }
573 |
574 | // 保存到本地存储
575 | localStorage.setItem('viewingHistory', JSON.stringify(history));
576 | } catch (e) {
577 | console.error('保存观看历史失败:', e);
578 | }
579 | }
580 |
581 | // 清空观看历史
582 | function clearViewingHistory() {
583 | try {
584 | localStorage.removeItem('viewingHistory');
585 | loadViewingHistory(); // 重新加载空的历史记录
586 | showToast('观看历史已清空', 'success');
587 | } catch (e) {
588 | console.error('清除观看历史失败:', e);
589 | showToast('清除观看历史失败', 'error');
590 | }
591 | }
592 |
593 | // 更新toggleSettings函数以处理历史面板互动
594 | const originalToggleSettings = toggleSettings;
595 | toggleSettings = function(e) {
596 | if (e) e.stopPropagation();
597 |
598 | // 原始设置面板切换逻辑
599 | originalToggleSettings(e);
600 |
601 | // 如果历史记录面板是打开的,则关闭它
602 | const historyPanel = document.getElementById('historyPanel');
603 | if (historyPanel && historyPanel.classList.contains('show')) {
604 | historyPanel.classList.remove('show');
605 | }
606 | };
607 |
608 | // 点击外部关闭历史面板
609 | document.addEventListener('DOMContentLoaded', function() {
610 | document.addEventListener('click', function(e) {
611 | const historyPanel = document.getElementById('historyPanel');
612 | const historyButton = document.querySelector('button[onclick="toggleHistory(event)"]');
613 |
614 | if (historyPanel && historyButton &&
615 | !historyPanel.contains(e.target) &&
616 | !historyButton.contains(e.target) &&
617 | historyPanel.classList.contains('show')) {
618 | historyPanel.classList.remove('show');
619 | }
620 | });
621 | });
622 |
--------------------------------------------------------------------------------
/middleware.js:
--------------------------------------------------------------------------------
1 | import { sha256 } from './js/sha256.js'; // 需新建或引入SHA-256实现
2 |
3 | // Vercel Middleware to inject environment variables
4 | export default async function middleware(request) {
5 | // Get the URL from the request
6 | const url = new URL(request.url);
7 |
8 | // Only process HTML pages
9 | const isHtmlPage = url.pathname.endsWith('.html') || url.pathname.endsWith('/');
10 | if (!isHtmlPage) {
11 | return; // Let the request pass through unchanged
12 | }
13 |
14 | // Fetch the original response
15 | const response = await fetch(request);
16 |
17 | // Check if it's an HTML response
18 | const contentType = response.headers.get('content-type') || '';
19 | if (!contentType.includes('text/html')) {
20 | return response; // Return the original response if not HTML
21 | }
22 |
23 | // Get the HTML content
24 | const originalHtml = await response.text();
25 |
26 | // Replace the placeholder with actual environment variable
27 | // If PASSWORD is not set, replace with empty string
28 | const password = process.env.PASSWORD || '';
29 | let passwordHash = '';
30 | if (password) {
31 | passwordHash = await sha256(password);
32 | }
33 | const modifiedHtml = originalHtml.replace(
34 | 'window.__ENV__.PASSWORD = "{{PASSWORD}}";',
35 | `window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash`
36 | );
37 |
38 | // Create a new response with the modified HTML
39 | return new Response(modifiedHtml, {
40 | status: response.status,
41 | statusText: response.statusText,
42 | headers: response.headers
43 | });
44 | }
45 |
46 | export const config = {
47 | matcher: ['/', '/((?!api|_next/static|_vercel|favicon.ico).*)'],
48 | };
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | # netlify.toml
2 |
3 | [build]
4 | # 如果你的项目不需要构建步骤 (纯静态 + functions),可以省略 publish
5 | # publish = "." # 假设你的 HTML/CSS/JS 文件在根目录
6 | functions = "netlify/functions" # 指定 Netlify 函数目录
7 |
8 | # 配置重写规则,将 /proxy/* 的请求路由到 proxy 函数
9 | # 这样前端的 PROXY_URL 仍然可以是 '/proxy/'
10 | [[redirects]]
11 | from = "/proxy/*"
12 | to = "/.netlify/functions/proxy/:splat" # 将路径参数传递给函数
13 | status = 200 # 重要:这是代理,不是重定向
14 |
15 | # (可选)为其他静态文件设置缓存头等
16 | # [[headers]]
17 | # for = "/*"
18 | # [headers.values]
19 | # # Add any global headers here
20 |
--------------------------------------------------------------------------------
/netlify/functions/proxy.mjs:
--------------------------------------------------------------------------------
1 | // /netlify/functions/proxy.mjs - Netlify Function (ES Module)
2 |
3 | import fetch from 'node-fetch';
4 | import { URL } from 'url'; // Use Node.js built-in URL
5 |
6 | // --- Configuration (Read from Environment Variables) ---
7 | const DEBUG_ENABLED = process.env.DEBUG === 'true';
8 | const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // Default 24 hours
9 | const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // Default 5 levels
10 |
11 | // --- User Agent Handling ---
12 | let USER_AGENTS = [
13 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
14 | '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'
15 | ];
16 | try {
17 | const agentsJsonString = process.env.USER_AGENTS_JSON;
18 | if (agentsJsonString) {
19 | const parsedAgents = JSON.parse(agentsJsonString);
20 | if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {
21 | USER_AGENTS = parsedAgents;
22 | console.log(`[Proxy Log Netlify] Loaded ${USER_AGENTS.length} user agents from environment variable.`);
23 | } else {
24 | console.warn("[Proxy Log Netlify] USER_AGENTS_JSON environment variable is not a valid non-empty array, using default.");
25 | }
26 | } else {
27 | console.log("[Proxy Log Netlify] USER_AGENTS_JSON environment variable not set, using default user agents.");
28 | }
29 | } catch (e) {
30 | console.error(`[Proxy Log Netlify] Error parsing USER_AGENTS_JSON environment variable: ${e.message}. Using default user agents.`);
31 | }
32 | const FILTER_DISCONTINUITY = false; // Ad filtering disabled
33 |
34 | // --- Helper Functions (Same as Vercel version, except rewriteUrlToProxy) ---
35 |
36 | function logDebug(message) {
37 | if (DEBUG_ENABLED) {
38 | console.log(`[Proxy Log Netlify] ${message}`);
39 | }
40 | }
41 |
42 | function getTargetUrlFromPath(encodedPath) {
43 | if (!encodedPath) { logDebug("getTargetUrlFromPath received empty path."); return null; }
44 | try {
45 | const decodedUrl = decodeURIComponent(encodedPath);
46 | if (decodedUrl.match(/^https?:\/\/.+/i)) { return decodedUrl; }
47 | else {
48 | logDebug(`Invalid decoded URL format: ${decodedUrl}`);
49 | if (encodedPath.match(/^https?:\/\/.+/i)) { logDebug(`Warning: Path was not encoded but looks like URL: ${encodedPath}`); return encodedPath; }
50 | return null;
51 | }
52 | } catch (e) { logDebug(`Error decoding target URL: ${encodedPath} - ${e.message}`); return null; }
53 | }
54 |
55 | function getBaseUrl(urlStr) {
56 | if (!urlStr) return '';
57 | try {
58 | const parsedUrl = new URL(urlStr);
59 | const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
60 | if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; }
61 | pathSegments.pop(); return `${parsedUrl.origin}/${pathSegments.join('/')}/`;
62 | } catch (e) {
63 | logDebug(`Getting BaseUrl failed for "${urlStr}": ${e.message}`);
64 | const lastSlashIndex = urlStr.lastIndexOf('/');
65 | if (lastSlashIndex > urlStr.indexOf('://') + 2) { return urlStr.substring(0, lastSlashIndex + 1); }
66 | return urlStr + '/';
67 | }
68 | }
69 |
70 | function resolveUrl(baseUrl, relativeUrl) {
71 | if (!relativeUrl) return ''; if (relativeUrl.match(/^https?:\/\/.+/i)) { return relativeUrl; } if (!baseUrl) return relativeUrl;
72 | try { return new URL(relativeUrl, baseUrl).toString(); }
73 | catch (e) {
74 | logDebug(`URL resolution failed: base="${baseUrl}", relative="${relativeUrl}". Error: ${e.message}`);
75 | if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } }
76 | else { return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; }
77 | }
78 | }
79 |
80 | // ** MODIFIED for Netlify redirect **
81 | function rewriteUrlToProxy(targetUrl) {
82 | if (!targetUrl || typeof targetUrl !== 'string') return '';
83 | // Use the path defined in netlify.toml 'from' field
84 | return `/proxy/${encodeURIComponent(targetUrl)}`;
85 | }
86 |
87 | function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; }
88 |
89 | async function fetchContentWithType(targetUrl, requestHeaders) {
90 | const headers = {
91 | 'User-Agent': getRandomUserAgent(),
92 | 'Accept': requestHeaders['accept'] || '*/*',
93 | 'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8',
94 | 'Referer': requestHeaders['referer'] || new URL(targetUrl).origin,
95 | };
96 | Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {});
97 | logDebug(`Fetching target: ${targetUrl} with headers: ${JSON.stringify(headers)}`);
98 | try {
99 | const response = await fetch(targetUrl, { headers, redirect: 'follow' });
100 | if (!response.ok) {
101 | const errorBody = await response.text().catch(() => '');
102 | logDebug(`Fetch failed: ${response.status} ${response.statusText} - ${targetUrl}`);
103 | const err = new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`);
104 | err.status = response.status; throw err;
105 | }
106 | const content = await response.text();
107 | const contentType = response.headers.get('content-type') || '';
108 | logDebug(`Fetch success: ${targetUrl}, Content-Type: ${contentType}, Length: ${content.length}`);
109 | return { content, contentType, responseHeaders: response.headers };
110 | } catch (error) {
111 | logDebug(`Fetch exception for ${targetUrl}: ${error.message}`);
112 | throw new Error(`Failed to fetch target URL ${targetUrl}: ${error.message}`);
113 | }
114 | }
115 |
116 | function isM3u8Content(content, contentType) {
117 | if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; }
118 | return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');
119 | }
120 |
121 | function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing KEY URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); }
122 | function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing MAP URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); }
123 | function processMediaPlaylist(url, content) {
124 | const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`Could not determine base URL for media playlist: ${url}. Cannot process relative paths.`); }
125 | const lines = content.split('\n'); const output = [];
126 | for (let i = 0; i < lines.length; i++) {
127 | const line = lines[i].trim(); if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue;
128 | if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; }
129 | if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; }
130 | if (line.startsWith('#EXTINF')) { output.push(line); continue; }
131 | if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`Rewriting media segment: Original='${line}', Resolved='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; }
132 | output.push(line);
133 | } return output.join('\n');
134 | }
135 | async function processM3u8Content(targetUrl, content, recursionDepth = 0) {
136 | if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`Detected master playlist: ${targetUrl} (Depth: ${recursionDepth})`); return await processMasterPlaylist(targetUrl, content, recursionDepth); }
137 | logDebug(`Detected media playlist: ${targetUrl} (Depth: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content);
138 | }
139 | async function processMasterPlaylist(url, content, recursionDepth) {
140 | if (recursionDepth > MAX_RECURSION) { throw new Error(`Max recursion depth (${MAX_RECURSION}) exceeded for master playlist: ${url}`); }
141 | const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = '';
142 | for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/); const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0; let variantUriLine = ''; for (let j = i + 1; j < lines.length; j++) { const line = lines[j].trim(); if (line && !line.startsWith('#')) { variantUriLine = line; i = j; break; } } if (variantUriLine && currentBandwidth >= highestBandwidth) { highestBandwidth = currentBandwidth; bestVariantUrl = resolveUrl(baseUrl, variantUriLine); } } }
143 | if (!bestVariantUrl) { logDebug(`No BANDWIDTH found, trying first URI in: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) { bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`Fallback: Found first sub-playlist URI: ${bestVariantUrl}`); break; } } }
144 | if (!bestVariantUrl) { logDebug(`No valid sub-playlist URI found in master: ${url}. Processing as media playlist.`); return processMediaPlaylist(url, content); }
145 | logDebug(`Selected sub-playlist (Bandwidth: ${highestBandwidth}): ${bestVariantUrl}`);
146 | const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {});
147 | if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`Fetched sub-playlist ${bestVariantUrl} is not M3U8 (Type: ${variantContentType}). Treating as media playlist.`); return processMediaPlaylist(bestVariantUrl, variantContent); }
148 | return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1);
149 | }
150 |
151 |
152 | // --- Netlify Handler ---
153 | export const handler = async (event, context) => {
154 | console.log('--- Netlify Proxy Request ---');
155 | console.log('Time:', new Date().toISOString());
156 | console.log('Method:', event.httpMethod);
157 | console.log('Path:', event.path);
158 | // Note: event.queryStringParameters contains query params if any
159 | // Note: event.headers contains incoming headers
160 |
161 | // --- CORS Headers (for all responses) ---
162 | const corsHeaders = {
163 | 'Access-Control-Allow-Origin': '*',
164 | 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
165 | 'Access-Control-Allow-Headers': '*', // Allow all headers client might send
166 | };
167 |
168 | // --- Handle OPTIONS Preflight Request ---
169 | if (event.httpMethod === 'OPTIONS') {
170 | logDebug("Handling OPTIONS request");
171 | return {
172 | statusCode: 204,
173 | headers: {
174 | ...corsHeaders,
175 | 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
176 | },
177 | body: '',
178 | };
179 | }
180 |
181 | // --- Extract Target URL ---
182 | // Based on netlify.toml rewrite: from = "/proxy/*" to = "/.netlify/functions/proxy/:splat"
183 | // The :splat part should be available in event.path after the base path
184 | let encodedUrlPath = '';
185 | const proxyPrefix = '/proxy/'; // Match the 'from' path in netlify.toml
186 | if (event.path && event.path.startsWith(proxyPrefix)) {
187 | encodedUrlPath = event.path.substring(proxyPrefix.length);
188 | logDebug(`Extracted encoded path from event.path: ${encodedUrlPath}`);
189 | } else {
190 | logDebug(`Could not extract encoded path from event.path: ${event.path}`);
191 | // Potentially handle direct calls too? Less likely needed.
192 | // const functionPath = '/.netlify/functions/proxy/';
193 | // if (event.path && event.path.startsWith(functionPath)) {
194 | // encodedUrlPath = event.path.substring(functionPath.length);
195 | // }
196 | }
197 |
198 | const targetUrl = getTargetUrlFromPath(encodedUrlPath);
199 | logDebug(`Resolved target URL: ${targetUrl || 'null'}`);
200 |
201 | if (!targetUrl) {
202 | logDebug('Error: Invalid proxy request path.');
203 | return {
204 | statusCode: 400,
205 | headers: { ...corsHeaders, 'Content-Type': 'application/json' },
206 | body: JSON.stringify({ success: false, error: "Invalid proxy request path. Could not extract target URL." }),
207 | };
208 | }
209 |
210 | logDebug(`Processing proxy request for target: ${targetUrl}`);
211 |
212 | try {
213 | // Fetch Original Content (Pass Netlify event headers)
214 | const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, event.headers);
215 |
216 | // --- Process if M3U8 ---
217 | if (isM3u8Content(content, contentType)) {
218 | logDebug(`Processing M3U8 content: ${targetUrl}`);
219 | const processedM3u8 = await processM3u8Content(targetUrl, content);
220 |
221 | logDebug(`Successfully processed M3U8 for ${targetUrl}`);
222 | return {
223 | statusCode: 200,
224 | headers: {
225 | ...corsHeaders, // Include CORS headers
226 | 'Content-Type': 'application/vnd.apple.mpegurl;charset=utf-8',
227 | 'Cache-Control': `public, max-age=${CACHE_TTL}`,
228 | // Note: Do NOT include content-encoding or content-length from original response
229 | // as node-fetch likely decompressed it and length changed.
230 | },
231 | body: processedM3u8, // Netlify expects body as string
232 | };
233 | } else {
234 | // --- Return Original Content (Non-M3U8) ---
235 | logDebug(`Returning non-M3U8 content directly: ${targetUrl}, Type: ${contentType}`);
236 |
237 | // Prepare headers for Netlify response object
238 | const netlifyHeaders = { ...corsHeaders };
239 | responseHeaders.forEach((value, key) => {
240 | const lowerKey = key.toLowerCase();
241 | // Exclude problematic headers and CORS headers (already added)
242 | if (!lowerKey.startsWith('access-control-') &&
243 | lowerKey !== 'content-encoding' &&
244 | lowerKey !== 'content-length') {
245 | netlifyHeaders[key] = value; // Add other original headers
246 | }
247 | });
248 | netlifyHeaders['Cache-Control'] = `public, max-age=${CACHE_TTL}`; // Set our cache policy
249 |
250 | return {
251 | statusCode: 200,
252 | headers: netlifyHeaders,
253 | body: content, // Body as string
254 | // isBase64Encoded: false, // Set true only if returning binary data as base64
255 | };
256 | }
257 |
258 | } catch (error) {
259 | logDebug(`ERROR in proxy processing for ${targetUrl}: ${error.message}`);
260 | console.error(`[Proxy Error Stack Netlify] ${error.stack}`); // Log full stack
261 |
262 | const statusCode = error.status || 500; // Get status from error if available
263 |
264 | return {
265 | statusCode: statusCode,
266 | headers: { ...corsHeaders, 'Content-Type': 'application/json' },
267 | body: JSON.stringify({
268 | success: false,
269 | error: `Proxy processing error: ${error.message}`,
270 | targetUrl: targetUrl
271 | }),
272 | };
273 | }
274 | };
275 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 |
5 | #access_log /var/log/nginx/host.access.log main;
6 |
7 | resolver 114.114.114.114 8.8.8.8 valid=300s;
8 | resolver_timeout 5s;
9 |
10 | # 创建代理路由
11 | location /proxy/ {
12 | # 设置CORS头部
13 | add_header 'Access-Control-Allow-Origin' '*';
14 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
15 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
16 | add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
17 |
18 | # OPTIONS请求处理
19 | if ($request_method = 'OPTIONS') {
20 | add_header 'Access-Control-Max-Age' 1728000;
21 | add_header 'Content-Type' 'text/plain charset=UTF-8';
22 | add_header 'Content-Length' 0;
23 | return 204;
24 | }
25 |
26 | set $target_url '';
27 |
28 | # 执行Lua脚本解析URL
29 | rewrite_by_lua_file /usr/share/nginx/html/proxy.lua;
30 |
31 | proxy_ssl_server_name on;
32 | proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
33 |
34 | # 设置代理头信息
35 | # 不设置Host,让Nginx自动根据目标URL设置
36 | proxy_set_header X-Real-IP $remote_addr;
37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
38 | proxy_set_header X-Forwarded-Proto $scheme;
39 | # 处理可能的重定向
40 | proxy_redirect off;
41 | proxy_buffering off;
42 | # 代理超时设置
43 | proxy_connect_timeout 60s;
44 | proxy_send_timeout 60s;
45 | proxy_read_timeout 60s;
46 |
47 | proxy_pass $target_url;
48 | }
49 |
50 | location / {
51 | root /usr/share/nginx/html;
52 | index index.html index.htm;
53 | }
54 |
55 | #error_page 404 /404.html;
56 |
57 | # redirect server error pages to the static page /50x.html
58 | #
59 | error_page 500 502 503 504 /50x.html;
60 | location = /50x.html {
61 | root /usr/share/nginx/html;
62 | }
63 |
64 | # proxy the PHP scripts to Apache listening on 127.0.0.1:80
65 | #
66 | #location ~ \.php$ {
67 | # proxy_pass http://127.0.0.1;
68 | #}
69 |
70 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
71 | #
72 | #location ~ \.php$ {
73 | # root html;
74 | # fastcgi_pass 127.0.0.1:9000;
75 | # fastcgi_index index.php;
76 | # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
77 | # include fastcgi_params;
78 | #}
79 |
80 | # deny access to .htaccess files, if Apache's document root
81 | # concurs with nginx's one
82 | #
83 | #location ~ /\.ht {
84 | # deny all;
85 | #}
86 | }
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "libretv",
3 | "version": "1.1.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "libretv",
9 | "version": "1.1.0",
10 | "license": "MIT",
11 | "dependencies": {
12 | "node-fetch": "^3.3.2"
13 | }
14 | },
15 | "node_modules/data-uri-to-buffer": {
16 | "version": "4.0.1",
17 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
18 | "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
19 | "license": "MIT",
20 | "engines": {
21 | "node": ">= 12"
22 | }
23 | },
24 | "node_modules/fetch-blob": {
25 | "version": "3.2.0",
26 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
27 | "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
28 | "funding": [
29 | {
30 | "type": "github",
31 | "url": "https://github.com/sponsors/jimmywarting"
32 | },
33 | {
34 | "type": "paypal",
35 | "url": "https://paypal.me/jimmywarting"
36 | }
37 | ],
38 | "license": "MIT",
39 | "dependencies": {
40 | "node-domexception": "^1.0.0",
41 | "web-streams-polyfill": "^3.0.3"
42 | },
43 | "engines": {
44 | "node": "^12.20 || >= 14.13"
45 | }
46 | },
47 | "node_modules/formdata-polyfill": {
48 | "version": "4.0.10",
49 | "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
50 | "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
51 | "license": "MIT",
52 | "dependencies": {
53 | "fetch-blob": "^3.1.2"
54 | },
55 | "engines": {
56 | "node": ">=12.20.0"
57 | }
58 | },
59 | "node_modules/node-domexception": {
60 | "version": "1.0.0",
61 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
62 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
63 | "funding": [
64 | {
65 | "type": "github",
66 | "url": "https://github.com/sponsors/jimmywarting"
67 | },
68 | {
69 | "type": "github",
70 | "url": "https://paypal.me/jimmywarting"
71 | }
72 | ],
73 | "license": "MIT",
74 | "engines": {
75 | "node": ">=10.5.0"
76 | }
77 | },
78 | "node_modules/node-fetch": {
79 | "version": "3.3.2",
80 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
81 | "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
82 | "license": "MIT",
83 | "dependencies": {
84 | "data-uri-to-buffer": "^4.0.0",
85 | "fetch-blob": "^3.1.4",
86 | "formdata-polyfill": "^4.0.10"
87 | },
88 | "engines": {
89 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
90 | },
91 | "funding": {
92 | "type": "opencollective",
93 | "url": "https://opencollective.com/node-fetch"
94 | }
95 | },
96 | "node_modules/web-streams-polyfill": {
97 | "version": "3.3.3",
98 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
99 | "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
100 | "license": "MIT",
101 | "engines": {
102 | "node": ">= 8"
103 | }
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "libretv",
3 | "version": "1.1.0",
4 | "description": "免费在线视频搜索与观看平台",
5 | "private": true,
6 | "type": "module",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "dependencies": {
11 | "node-fetch": "^3.3.2"
12 | },
13 | "author": "bestZwei",
14 | "license": "MIT"
15 | }
--------------------------------------------------------------------------------
/privacy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
隐私政策 - LibreTV
7 |
8 |
9 |
10 |
11 |
12 |
15 |
16 |
17 | 我们尊重并保护您的隐私。LibreTV 不收集任何个人数据,且不会限制访问或使用本网站。
18 |
19 |
20 | 本平台仅用于提供在线视频搜索与播放服务。所有数据均由第三方接口提供,我们不会存储或追踪用户信息。
21 |
22 |
23 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/proxy.lua:
--------------------------------------------------------------------------------
1 | -- 解码URL函数
2 | local function decode_uri(uri)
3 | local decoded = ngx.unescape_uri(uri)
4 | return decoded
5 | end
6 |
7 | -- 直接从请求URI获取完整URL
8 | local request_uri = ngx.var.request_uri
9 | ngx.log(ngx.DEBUG, "完整请求URI: ", request_uri)
10 |
11 | -- 提取/proxy/后面的部分
12 | local _, _, target_path = string.find(request_uri, "^/proxy/(.*)")
13 | ngx.log(ngx.DEBUG, "提取的目标路径: ", target_path or "nil")
14 |
15 | if not target_path or target_path == "" then
16 | ngx.status = 400
17 | ngx.say("错误: 未提供目标URL")
18 | return ngx.exit(400)
19 | end
20 |
21 | -- 解码URL
22 | local target_url = decode_uri(target_path)
23 | ngx.log(ngx.DEBUG, "解码后的目标URL: ", target_url)
24 |
25 | if not target_url or target_url == "" then
26 | ngx.status = 400
27 | ngx.say("错误: 无法解析目标URL")
28 | return ngx.exit(400)
29 | end
30 |
31 | -- 记录日志
32 | ngx.log(ngx.STDERR, "代理请求: ", target_url)
33 |
34 | -- 设置目标URL变量供Nginx使用
35 | ngx.var.target_url = target_url
36 |
37 | -- 继续执行Nginx配置的其余部分
38 | return
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # LibreTV - 免费在线视频搜索与观看平台
2 |
3 |
4 |
5 |
6 |
自由观影,畅享精彩
7 |
8 |
9 | ## 📺 项目简介
10 |
11 | LibreTV 是一个轻量级、免费的在线视频搜索与观看平台,提供来自多个视频源的内容搜索与播放服务。无需注册,即开即用,支持多种设备访问。项目结合了前端技术和后端代理功能,可部署在支持服务端功能的各类网站托管服务上。
12 |
13 | 本项目基于 [bestK/tv](https://github.com/bestK/tv) 进行重构与增强。
14 |
15 |
16 | 点击查看项目截图
17 |
18 |
19 |
20 | ## 🥇 感谢赞助
21 |
22 | - **[YXVM](https://yxvm.com)**
23 | - **[VTEXS](https://vtexs.com)**
24 |
25 | ## 🚀 快速部署
26 |
27 | 选择以下任一平台,点击一键部署按钮,即可快速创建自己的 LibreTV 实例:
28 |
29 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FLibreSpark%2FLibreTV) [](https://app.netlify.com/start/deploy?repository=https://github.com/LibreSpark/LibreTV)
30 |
31 | ## 📋 详细部署指南
32 |
33 | ### Cloudflare Pages
34 |
35 | 1. Fork 或克隆本仓库到您的 GitHub 账户
36 | 2. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/),进入 Pages 服务
37 | 3. 点击"创建项目",连接您的 GitHub 仓库
38 | 4. 使用以下设置:
39 | - 构建命令:留空(无需构建)
40 | - 输出目录:留空(默认为根目录)
41 | 5. 点击"保存并部署"
42 | 6. 可选:在"设置" > "环境变量"中配置密码保护
43 |
44 | ### Vercel
45 |
46 | 1. Fork 或克隆本仓库到您的 GitHub/GitLab 账户
47 | 2. 登录 [Vercel](https://vercel.com/),点击"New Project"
48 | 3. 导入您的仓库,使用默认设置
49 | 4. 点击"Deploy"
50 | 5. 可选:在"Settings" > "Environment Variables"中配置密码保护
51 |
52 | ### Netlify
53 |
54 | 1. Fork 或克隆本仓库到您的 GitHub 账户
55 | 2. 登录 [Netlify](https://app.netlify.com/)
56 | 3. 点击"New site from Git",选择您的仓库
57 | 4. 构建设置保持默认
58 | 5. 点击"Deploy site"
59 | 6. 可选:在"Site settings" > "Build & deploy" > "Environment"中配置密码保护
60 |
61 | ### Docker
62 |
63 | 使用 Docker 运行 LibreTV:
64 |
65 | ```bash
66 | docker run -d \
67 | --name libretv \
68 | -p 8899:80 \
69 | -e PASSWORD=your_password_here \
70 | bestzwei/libretv:latest
71 | ```
72 |
73 | 访问 `http://localhost:8899` 即可使用。
74 |
75 | ### Docker Compose
76 |
77 | `docker-compose.yml` 文件:
78 |
79 | ```yaml
80 | version: '3'
81 | services:
82 | libretv:
83 | image: bestzwei/libretv:latest
84 | container_name: libretv
85 | ports:
86 | - "8899:80"
87 | environment:
88 | - PASSWORD=111111
89 | restart: unless-stopped
90 | ```
91 |
92 | ### 本地开发环境
93 |
94 | 项目包含后端代理功能,需要支持服务器端功能的环境:
95 |
96 | ```bash
97 | # 安装依赖
98 | npm install
99 |
100 | # 启动开发服务器
101 | npm run dev
102 | ```
103 |
104 | > ⚠️ 注意:使用简单静态服务器(如 `python -m http.server` 或 `npx http-server`)时,视频代理功能将不可用,视频无法正常播放。完整功能测试请使用 Node.js 开发服务器。
105 |
106 | ## 🔧 自定义配置
107 |
108 | ### 密码保护
109 |
110 | 要为您的 LibreTV 实例添加密码保护,可以在部署平台上设置环境变量:
111 |
112 | **环境变量名**: `PASSWORD`
113 | **值**: 您想设置的密码
114 |
115 | 各平台设置方法:
116 |
117 | - **Cloudflare Pages**: Dashboard > 您的项目 > 设置 > 环境变量
118 | - **Vercel**: Dashboard > 您的项目 > Settings > Environment Variables
119 | - **Netlify**: Dashboard > 您的项目 > Site settings > Build & deploy > Environment
120 | - **Docker**: 使用 `-e PASSWORD=your_password` 参数
121 |
122 | ### API兼容性
123 |
124 | LibreTV 支持标准的苹果 CMS V10 API 格式。添加自定义 API 时需遵循以下格式:
125 | - 搜索接口: `https://example.com/api.php/provide/vod/?ac=videolist&wd=关键词`
126 | - 详情接口: `https://example.com/api.php/provide/vod/?ac=detail&ids=视频ID`
127 |
128 | **添加 CMS 源**:
129 | 1. 在设置面板中选择"自定义接口"
130 | 2. 接口地址只需填写到域名部分: `https://example.com`(不要包含`/api.php/provide/vod`部分)
131 |
132 | ## ⌨️ 键盘快捷键
133 |
134 | 播放器支持以下键盘快捷键:
135 |
136 | - **空格键**: 播放/暂停
137 | - **左右箭头**: 快退/快进
138 | - **上下箭头**: 音量增加/减小
139 | - **M 键**: 静音/取消静音
140 | - **F 键**: 全屏/退出全屏
141 | - **Esc 键**: 退出全屏
142 |
143 | ## 🛠️ 技术栈
144 |
145 | - HTML5 + CSS3 + JavaScript (ES6+)
146 | - Tailwind CSS (通过 CDN 引入)
147 | - HLS.js 用于 HLS 流处理
148 | - DPlayer 视频播放器核心
149 | - Cloudflare/Vercel/Netlify Serverless Functions
150 | - 服务端 HLS 代理和处理技术
151 | - localStorage 本地存储
152 |
153 | ## 🔄 更新日志
154 |
155 |
156 | 点击查看更新日志
157 |
158 | - **1.1.2** (2025-04-22): 新增豆瓣热门内容显示,设置中可开关
159 | - **1.1.1** (2025-04-19):
160 | - 修复 docker 部署时无法搜索的问题
161 | - 修复播放页面进度保存与恢复的兼容性问题
162 | - **1.1.0** (2025-04-17): 添加服务端代理功能,支持 HLS 流处理和解析,支持环境变量设置访问密码
163 | - **1.0.3** (2025-04-13): 性能优化、UI优化、更新设置功能
164 | - **1.0.2** (2025-04-08): 分离播放页面,优化视频源 API 兼容性
165 | - **1.0.1** (2025-04-07): 添加广告过滤功能,优化播放器性能
166 | - **1.0.0** (2025-04-06): 初始版本发布
167 |
168 |
169 |
170 | ## ⚠️ 免责声明
171 |
172 | LibreTV 仅作为视频搜索工具,不存储、上传或分发任何视频内容。所有视频均来自第三方 API 接口提供的搜索结果。如有侵权内容,请联系相应的内容提供方。
173 |
174 | 本项目开发者不对使用本项目产生的任何后果负责。使用本项目时,您必须遵守当地的法律法规。
175 |
--------------------------------------------------------------------------------
/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 | Disallow: /js/
4 | Disallow: /css/
5 |
6 | Sitemap: https://libretv.is-an.org/sitemap.xml
7 |
--------------------------------------------------------------------------------
/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | https://libretv.is-an.org/
5 | 2025-04-06
6 | weekly
7 | 1.0
8 |
9 |
10 | https://libretv.is-an.org/about.html
11 | 2025-04-06
12 | monthly
13 | 0.8
14 |
15 |
16 | https://libretv.is-an.org/privacy.html
17 | 2025-04-06
18 | monthly
19 | 0.5
20 |
21 |
22 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/proxy/:path*",
5 | "destination": "/api/proxy/:path*"
6 | }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/watch.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
重定向到播放器
7 |
8 |
9 |
如果您没有被自动重定向,请点击这里 前往播放页面。
10 |
11 |
12 |
--------------------------------------------------------------------------------