-
Notifications
You must be signed in to change notification settings - Fork 667
Expand file tree
/
Copy pathserver.js
More file actions
3292 lines (2970 loc) · 167 KB
/
Copy pathserver.js
File metadata and controls
3292 lines (2970 loc) · 167 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Vercel 环境会自动注入环境变量,无需加载 .env 文件
if (!process.env.VERCEL) {
require('dotenv').config();
}
const express = require('express');
const axios = require('axios');
const bodyParser = require('body-parser');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const stream = require('stream');
const { promisify } = require('util');
const pipeline = promisify(stream.pipeline);
const app = express();
const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, 'db.json');
const TEMPLATE_FILE = path.join(__dirname, 'db.template.json');
// 图片缓存目录 (仅本地/Docker 环境)
const IMAGE_CACHE_DIR = path.join(__dirname, 'public/cache/images');
if (!process.env.VERCEL && !fs.existsSync(IMAGE_CACHE_DIR)) {
fs.mkdirSync(IMAGE_CACHE_DIR, { recursive: true });
}
// 访问密码配置(支持多密码)
// 格式:ACCESS_PASSWORD=password1 或 ACCESS_PASSWORD=password1,password2,password3
const ACCESS_PASSWORD_RAW = process.env['ACCESS_PASSWORD'] || '';
const ACCESS_PASSWORDS = ACCESS_PASSWORD_RAW ? ACCESS_PASSWORD_RAW.split(',').map(p => p.trim()).filter(p => p) : [];
// 第一个密码的哈希(兼容旧逻辑)
const PASSWORD_HASH = ACCESS_PASSWORDS.length > 0
? crypto.createHash('sha256').update(ACCESS_PASSWORDS[0]).digest('hex')
: '';
// 生成密码到哈希的映射(用于历史同步)
const PASSWORD_HASH_MAP = {};
ACCESS_PASSWORDS.forEach((pwd, index) => {
const hash = crypto.createHash('sha256').update(pwd).digest('hex');
PASSWORD_HASH_MAP[hash] = {
index: index,
// 第一个密码不启用同步(保持现有设计),其他密码启用同步
syncEnabled: index > 0
};
});
console.log(`[System] Password mode: ${ACCESS_PASSWORDS.length > 1 ? 'Multi-user' : 'Single'} (${ACCESS_PASSWORDS.length} passwords)`);
// 反查:token(=SHA256(独立密码)) → 原始独立密码。仅用于站长后台辨认"是哪个独立密码用户"。
// 只在 ADMIN_TOKEN 鉴权后的后台接口里用到,不外泄。
const HASH_TO_PASSWORD = {};
ACCESS_PASSWORDS.forEach(pwd => { HASH_TO_PASSWORD[crypto.createHash('sha256').update(pwd).digest('hex')] = pwd; });
// 求片/统计后台展示用:把 token 翻成人能认的身份
function userIdentity(token, label) {
if (label && String(label).trim()) return String(label).trim();
if (token && HASH_TO_PASSWORD[token]) return '独立密码: ' + HASH_TO_PASSWORD[token];
if (token && String(token).startsWith('v2board_')) return 'v2board用户#' + String(token).slice(8, 16);
return (token ? String(token).slice(0, 12) : '匿名');
}
// 远程配置URL
const REMOTE_DB_URL = process.env['REMOTE_DB_URL'] || '';
// CORS 代理 URL(用于中转无法直接访问的资源站 API)
const CORS_PROXY_URL = process.env['CORS_PROXY_URL'] || '';
// 📺 直播(IPTV):上游 M3U 源(vbskycn/iptv,每6h更新)。可用 LIVE_M3U_URL 覆盖主源、LIVE_M3U_FALLBACK 覆盖备源;
// 设 LIVE_TV_DISABLED=1 整体关闭(前端隐藏直播区、后端 /api/live/channels 返回 enabled:false)。
const LIVE_M3U_URL = process.env['LIVE_M3U_URL'] || 'https://live.zbds.top/tv/iptv4.m3u';
const LIVE_M3U_FALLBACK = process.env['LIVE_M3U_FALLBACK'] || 'https://gh-proxy.com/raw.githubusercontent.com/vbskycn/iptv/refs/heads/master/tv/iptv4.m3u';
// 第二上游(iptv-org cn):补 CDN 域名源——vbskycn 的 CCTV 多是运营商 IP(封 Cloudflare 放不了),iptv-org 有 cctvplus 等 CDN 源。
const LIVE_M3U_IPTVORG = process.env['LIVE_M3U_IPTVORG'] || 'https://iptv-org.github.io/iptv/countries/cn.m3u';
// 第三上游(iptv-org 中文语言表):countries/cn 多是运营商 IP;languages/zho 反而收录大量【海外 CDN】华语源——
// CCTV-1~17 完整 + CGTN 全家(News/Doc/西/阿/俄/法,多带 ACAO:*) + CCTV-4 America/Europe/Asia + 卫视。实测海外可播的关键补充。
const LIVE_M3U_ZHO = process.env['LIVE_M3U_ZHO'] || 'https://iptv-org.github.io/iptv/languages/zho.m3u';
// 自定义上游(可选):用户有付费 IPTV 的 m3u 可设 LIVE_M3U_EXTRA(逗号分隔多个),并入合并 → 能播什么由它决定。
const LIVE_M3U_EXTRA = (process.env['LIVE_M3U_EXTRA'] || '').split(',').map(s => s.trim()).filter(Boolean);
// 🏀 免费 CCTV5 / CCTV5+:第三方 redirector → 咪咕(migu)源。https + ACAO:*,海外【直连】可播
// (实测返回真 TS 分段、media-seq 实时递增,非 catvod 那种 backup 待机台)。仅这两个体育频道稳定可用——其余 /cctv/N 皆 404。
// 放在合并最前 → 与上游同名 CCTV5/CCTV5+ 归并时其 https 线路(rank0)排第一被优先播,运营商死源退为兜底。源失效用 LIVE_M3U_DISABLE 连内置一起关。
const LIVE_KAFEI_GEN = [
{ name: 'CCTV5', group: '体育', url: 'https://live.666666.zip/cctv/5.m3u8' },
{ name: 'CCTV5+', group: '体育', url: 'https://live.666666.zip/cctv/5p.m3u8' },
];
// 布尔型环境变量:0/false/no/off/空 都算"未开启"——JS 里 "0"/"false" 是 truthy,
// 用户把 LIVE_TV_DISABLED=0 当"不禁用"填时曾把整个直播关掉(线上事故:首页直播频道消失)。
const envFlag = (name) => { const v = String(process.env[name] ?? '').trim().toLowerCase(); return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off'; };
const LIVE_TV_ENABLED = !envFlag('LIVE_TV_DISABLED');
// LIVE_M3U_DISABLE=1:关掉所有内置上游(vbskycn/iptv-org/国际/内置CCTV5源),只用 LIVE_M3U_EXTRA 自定义源。
const LIVE_BUILTIN = !envFlag('LIVE_M3U_DISABLE');
// 后台验证:每次刷新后通过 worker(http源)/直连(https源)实测每个频道首条线路能否播,标 ok 并把能播的排前。
// 不删频道(全列出),只标记+排序。设 LIVE_NO_VALIDATE=1 关闭(纯全列出、不打 worker)。
const LIVE_VALIDATE = !envFlag('LIVE_NO_VALIDATE');
// 环境变量加载状态日志(用于 Vercel 调试)
console.log(`[System] Environment: ${process.env.VERCEL ? 'Vercel Serverless' : 'Local/VPS'}`);
console.log(`[System] TMDB_API_KEY: ${process.env.TMDB_API_KEY ? '✓ Configured' : '✗ Missing'}`);
console.log(`[System] TMDB_PROXY_URL: ${process.env['TMDB_PROXY_URL'] || '(not set)'}`);
console.log(`[System] CORS_PROXY_URL: ${CORS_PROXY_URL || '(not set)'}`);
console.log(`[System] REMOTE_DB_URL: ${REMOTE_DB_URL ? '✓ Configured' : '(not set)'}`);
console.log(`[System] 直播(IPTV): ${LIVE_TV_ENABLED ? '✓ 启用 (' + LIVE_M3U_URL + ')' : '✗ 已禁用 (LIVE_TV_DISABLED)'}`);
// 远程配置缓存
let remoteDbCache = null;
let remoteDbLastFetch = 0;
const REMOTE_DB_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
// 记录需要使用代理的站点(自动学习,带过期时间)
// 格式:{ siteKey: expireTimestamp }
const proxyRequiredSites = new Map();
const PROXY_MEMORY_TTL = 24 * 60 * 60 * 1000; // 24小时后重新尝试直连
const SLOW_THRESHOLD_MS = 1500; // 直连延迟超过此值视为慢速,尝试代理
// IP 地理位置缓存 (避免频繁调用外部 API)
const ipLocationCache = new Map();
const IP_CACHE_TTL = 3600 * 1000; // 缓存1小时
/**
* 获取请求者的真实 IP 地址
* 支持 Cloudflare, Nginx 等反向代理
*/
function getClientIP(req) {
return req.headers['cf-connecting-ip'] || // Cloudflare
req.headers['x-real-ip'] || // Nginx
(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
req.socket?.remoteAddress ||
'';
}
/**
* HTML 转义:用于把不可信数据(如 TMDB 标题/简介)安全地插入服务端渲染的 HTML/属性,
* 防止 XSS。覆盖 & < > " ' 五个字符。
*/
function escapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* 检测是否为私有/内网 IP 地址
* @param {string} ip - IP 地址
* @returns {boolean} - 是否是私有 IP
*/
function isPrivateIP(ip) {
if (!ip) return false;
// IPv4 私有地址
if (/^127\./.test(ip)) return true; // 127.0.0.0/8 (loopback)
if (/^10\./.test(ip)) return true; // 10.0.0.0/8
if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)) return true; // 172.16.0.0/12
if (/^192\.168\./.test(ip)) return true; // 192.168.0.0/16
if (/^169\.254\./.test(ip)) return true; // 169.254.0.0/16 (link-local)
// IPv6 私有/特殊地址
if (ip === '::1') return true; // loopback
if (/^fe80:/i.test(ip)) return true; // link-local
if (/^fc00:/i.test(ip) || /^fd[0-9a-f]{2}:/i.test(ip)) return true; // unique local
return false;
}
/**
* 检测 IP 是否来自中国大陆(需要使用代理)
* 支持从 X-Client-Public-IP 头获取客户端提供的公网 IP
* 私有 IP 默认视为需要代理(假设部署在中国大陆内网环境)
* @param {object} req - Express 请求对象
* @returns {Promise<boolean>} - 是否需要使用代理
*/
async function isChineseIP(req) {
// 1. 优先使用客户端提供的公网 IP (由前端从 api.ip.sb 获取)
const clientProvidedIP = req.headers['x-client-public-ip'];
// 2. 回退到服务端检测的 IP
const detectedIP = getClientIP(req);
// 使用客户端提供的 IP(如果有效且非私有)
let effectiveIP = clientProvidedIP && !isPrivateIP(clientProvidedIP) ? clientProvidedIP : detectedIP;
// 3. 如果有效 IP 仍然是私有的,直接返回 true(视为需要代理)
if (!effectiveIP || isPrivateIP(effectiveIP)) {
console.log(`[IP Detection] Private/LAN IP detected (${detectedIP}), treating as CN (proxy required)`);
return true;
}
// 检查缓存
const cached = ipLocationCache.get(effectiveIP);
if (cached && (Date.now() - cached.time < IP_CACHE_TTL)) {
return cached.isCN;
}
try {
const response = await axios.get(`https://api.ip.sb/geoip/${effectiveIP}`, {
timeout: 3000,
headers: { 'User-Agent': 'DongguaTV/1.0' }
});
const data = response.data;
// 检查是否是中国大陆 (排除港澳台)
let isCN = false;
if (data.country_code === 'CN') {
const excludeRegions = ['Hong Kong', 'Macau', 'Taiwan', '香港', '澳门', '台湾'];
const region = data.region || data.city || '';
if (!excludeRegions.some(r => region.includes(r))) {
isCN = true;
}
}
// 缓存结果
ipLocationCache.set(effectiveIP, { isCN, time: Date.now() });
console.log(`[IP Detection] ${effectiveIP} -> ${isCN ? '中国大陆' : '海外'}${clientProvidedIP ? ' (client-provided)' : ''}`);
return isCN;
} catch (error) {
// API 调用失败,默认不使用代理
console.error(`[IP Detection Error] ${effectiveIP}:`, error.message);
return false;
}
}
/**
* 检测字符串是否主要包含英文字符(用于判断是否需要翻译)
* @param {string} text - 待检测文本
* @returns {boolean} - 是否主要是英文
*/
function isMainlyEnglish(text) {
if (!text) return false;
// 去除空格和标点后检测
const cleaned = text.replace(/[\s\d\-\_\:\.\,\!\?\'\"\(\)\[\]]/g, '');
if (cleaned.length === 0) return false;
// 计算英文字母占比
const englishChars = (cleaned.match(/[a-zA-Z]/g) || []).length;
const ratio = englishChars / cleaned.length;
// 如果英文字符占比超过 70%,认为是英文
return ratio > 0.7;
}
/**
* 通过 TMDB 搜索获取影片的中文名称
* 利用 TMDB 的多语言支持,查询英文标题对应的中文翻译
* 注意:会自动使用 TMDB_PROXY_URL 代理(如果配置)
* @param {string} englishTitle - 英文标题
* @returns {Promise<string[]>} - 找到的中文标题数组
*/
async function fetchChineseTitleFromTMDB(englishTitle) {
const TMDB_API_KEY = process.env.TMDB_API_KEY;
const TMDB_PROXY_URL = process.env['TMDB_PROXY_URL'];
if (!TMDB_API_KEY) return [];
// 构建 TMDB API 基础 URL(支持代理)
// cloudflare-tmdb-proxy.js 需要 /api/3/ 前缀
const TMDB_BASE = TMDB_PROXY_URL
? `${TMDB_PROXY_URL.replace(/\/$/, '')}/api/3` // 代理需要 /api/3 前缀
: 'https://api.themoviedb.org/3';
try {
// 先用英文搜索找到影片 ID
const searchUrl = `${TMDB_BASE}/search/multi?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(englishTitle)}&language=en-US`;
const searchResponse = await axios.get(searchUrl, { timeout: 8000 });
if (!searchResponse.data.results || searchResponse.data.results.length === 0) {
return [];
}
const firstResult = searchResponse.data.results[0];
const mediaType = firstResult.media_type; // movie 或 tv
const id = firstResult.id;
if (!id || (mediaType !== 'movie' && mediaType !== 'tv')) {
return [];
}
// 用中文语言获取详情,TMDB 会返回中文标题
const detailUrl = `${TMDB_BASE}/${mediaType}/${id}?api_key=${TMDB_API_KEY}&language=zh-CN`;
const detailResponse = await axios.get(detailUrl, { timeout: 8000 });
const chineseTitles = [];
const chineseTitle = detailResponse.data.title || detailResponse.data.name;
if (chineseTitle && chineseTitle !== englishTitle) {
chineseTitles.push(chineseTitle);
console.log(`[TMDB Translation] "${englishTitle}" => "${chineseTitle}"`);
}
// 尝试获取更多别名(alternative_titles)- 使用较短超时,失败不影响主流程
try {
const altUrl = `${TMDB_BASE}/${mediaType}/${id}/alternative_titles?api_key=${TMDB_API_KEY}`;
const altResponse = await axios.get(altUrl, { timeout: 5000 });
// 电影用 titles,电视剧用 results
const alternatives = altResponse.data.titles || altResponse.data.results || [];
// 查找中文地区的别名 (CN, TW, HK)
for (const alt of alternatives) {
const country = alt.iso_3166_1;
if (['CN', 'TW', 'HK'].includes(country) && alt.title) {
if (!chineseTitles.includes(alt.title) && alt.title !== englishTitle) {
chineseTitles.push(alt.title);
}
}
}
} catch (e) {
// 别名获取失败不影响主流程
}
return chineseTitles;
} catch (error) {
// 翻译失败不阻塞搜索,静默返回空数组
if (error.code !== 'ECONNABORTED') {
console.error(`[TMDB Translation Error] ${englishTitle}:`, error.message);
}
return [];
}
}
/**
* 智能生成搜索关键词变体
* 用于提高搜索命中率,解决 TMDB 标题与资源站标题不匹配的问题
* 例如:"利刃出鞘3:亡者归来" -> ["利刃出鞘3:亡者归来", "利刃出鞘3", "利刃出鞘"]
* @param {string} keyword - 原始搜索关键词
* @param {string} originalTitle - 可选的原始标题(如英文名)
* @returns {string[]} - 关键词变体数组(已去重)
*/
function generateSearchKeywords(keyword, originalTitle = '') {
const keywords = new Set();
if (!keyword) return [];
// 1. 原始关键词
keywords.add(keyword.trim());
// 2. 如果有原始标题(英文名),也加入
if (originalTitle && originalTitle.trim() && originalTitle !== keyword) {
keywords.add(originalTitle.trim());
}
// 3. 去除常见分隔符后的主标题
// 常见分隔符::、:、-、—、·、|、/
const separators = [':', ':', '–', '—', '-', '·', '|', '/', '~'];
for (const sep of separators) {
if (keyword.includes(sep)) {
const mainTitle = keyword.split(sep)[0].trim();
if (mainTitle && mainTitle.length >= 2) {
keywords.add(mainTitle);
}
}
}
// 4. 去除括号内容:《》、()、()、【】、[]
const bracketPatterns = [
/《[^》]*》/g,
/\([^)]*\)/g,
/([^)]*)/g,
/\[[^\]]*\]/g,
/【[^】]*】/g
];
let cleanedKeyword = keyword;
for (const pattern of bracketPatterns) {
cleanedKeyword = cleanedKeyword.replace(pattern, '').trim();
}
if (cleanedKeyword && cleanedKeyword !== keyword && cleanedKeyword.length >= 2) {
keywords.add(cleanedKeyword);
}
// 5. 对于带数字续集的影片,尝试只保留数字前面的部分
// 例如:"利刃出鞘3" -> "利刃出鞘" (但不移除如 "007" 这样的数字标题)
const numericMatch = keyword.match(/^(.+?)\d+$/);
if (numericMatch && numericMatch[1] && numericMatch[1].length >= 2) {
// 只有当前面有足够长的标题时才添加
const baseTitle = numericMatch[1].trim();
if (baseTitle.length >= 2) {
keywords.add(baseTitle);
}
}
// 6. 去除 "第X季"、"第X部"、"Season X" 等后缀
const seasonPatterns = [
/第[一二三四五六七八九十\d]+季$/,
/第[一二三四五六七八九十\d]+部$/,
/Season\s*\d+$/i,
/S\d+$/i
];
let noSeasonKeyword = keyword;
for (const pattern of seasonPatterns) {
noSeasonKeyword = noSeasonKeyword.replace(pattern, '').trim();
}
if (noSeasonKeyword && noSeasonKeyword !== keyword && noSeasonKeyword.length >= 2) {
keywords.add(noSeasonKeyword);
}
return Array.from(keywords);
}
/**
* 检查站点是否需要使用代理(未过期)
*/
function shouldUseProxy(siteKey) {
if (!proxyRequiredSites.has(siteKey)) return false;
const expireTime = proxyRequiredSites.get(siteKey);
if (Date.now() > expireTime) {
// 已过期,移除记录,下次会重新尝试直连
proxyRequiredSites.delete(siteKey);
console.log(`[Proxy Memory] ${siteKey} 代理记录已过期,将重新尝试直连`);
return false;
}
return true;
}
/**
* 标记站点需要使用代理
*/
function markSiteNeedsProxy(siteKey, reason = '') {
const expireTime = Date.now() + PROXY_MEMORY_TTL;
proxyRequiredSites.set(siteKey, expireTime);
const expireDate = new Date(expireTime).toLocaleString('zh-CN');
console.log(`[Proxy Memory] ${siteKey} 已标记为需要代理${reason ? ` (${reason})` : ''},有效期至 ${expireDate}`);
}
/**
* 带代理回退的请求函数
* 先尝试直接请求,失败或太慢时通过 CORS 代理重试
* @param {string} url - 请求 URL
* @param {object} options - axios 配置
* @param {string} siteKey - 站点标识(用于记忆)
* @returns {Promise<object>} - { data, usedProxy, latency }
*/
async function fetchWithProxyFallback(url, options = {}, siteKey = '') {
const timeout = options.timeout || 8000;
// 如果该站点之前需要代理且未过期,直接使用代理
if (CORS_PROXY_URL && siteKey && shouldUseProxy(siteKey)) {
try {
const startTime = Date.now();
const proxyUrl = `${CORS_PROXY_URL}/?url=${encodeURIComponent(url)}`;
const response = await axios.get(proxyUrl, { ...options, timeout });
const latency = Date.now() - startTime;
return { data: response.data, usedProxy: true, latency };
} catch (proxyError) {
// 代理也失败,移除记忆,下次重新尝试直连
proxyRequiredSites.delete(siteKey);
console.log(`[Proxy Fallback] ${siteKey} 代理失败,已清除记录`);
throw proxyError;
}
}
// 尝试直接请求
const startTime = Date.now();
try {
const response = await axios.get(url, { ...options, timeout });
const directLatency = Date.now() - startTime;
// 检查是否太慢,如果配置了代理,尝试代理看是否更快
if (CORS_PROXY_URL && directLatency > SLOW_THRESHOLD_MS) {
console.log(`[Proxy Fallback] ${siteKey || url} 直连较慢 (${directLatency}ms),尝试代理对比...`);
try {
const proxyStartTime = Date.now();
const proxyUrl = `${CORS_PROXY_URL}/?url=${encodeURIComponent(url)}`;
const proxyResponse = await axios.get(proxyUrl, { ...options, timeout: timeout + 2000 });
const proxyLatency = Date.now() - proxyStartTime;
// 如果代理更快(至少快 30%),使用代理结果并记住
if (proxyLatency < directLatency * 0.7) {
console.log(`[Proxy Fallback] ${siteKey || url} 代理更快 (${proxyLatency}ms vs ${directLatency}ms),使用代理`);
if (siteKey) {
markSiteNeedsProxy(siteKey, `代理更快: ${proxyLatency}ms vs 直连 ${directLatency}ms`);
}
return { data: proxyResponse.data, usedProxy: true, latency: proxyLatency };
} else {
console.log(`[Proxy Fallback] ${siteKey || url} 直连仍更快 (${directLatency}ms vs ${proxyLatency}ms),继续使用直连`);
}
} catch (proxyError) {
// 代理失败,继续使用直连结果
console.log(`[Proxy Fallback] ${siteKey || url} 代理测试失败,继续使用直连`);
}
}
return { data: response.data, usedProxy: false, latency: directLatency };
} catch (directError) {
// 直接请求失败,如果配置了代理,尝试通过代理
if (CORS_PROXY_URL) {
try {
console.log(`[Proxy Fallback] ${siteKey || url} 直连失败,尝试代理...`);
const proxyStartTime = Date.now();
const proxyUrl = `${CORS_PROXY_URL}/?url=${encodeURIComponent(url)}`;
const response = await axios.get(proxyUrl, { ...options, timeout: timeout + 2000 });
const proxyLatency = Date.now() - proxyStartTime;
// 记住该站点需要代理(带过期时间)
if (siteKey) {
markSiteNeedsProxy(siteKey, '直连失败');
}
return { data: response.data, usedProxy: true, latency: proxyLatency };
} catch (proxyError) {
console.error(`[Proxy Fallback] ${siteKey || url} 代理请求也失败:`, proxyError.message);
throw proxyError;
}
}
throw directError;
}
}
// 缓存配置
const CACHE_TYPE = process.env.CACHE_TYPE || 'json'; // json, sqlite, memory, none
const SEARCH_CACHE_JSON = path.join(__dirname, 'cache_search.json');
const DETAIL_CACHE_JSON = path.join(__dirname, 'cache_detail.json');
const INTRO_CACHE_JSON = path.join(__dirname, 'cache_intro.json');
const INTRO_ENV_CACHE_JSON = path.join(__dirname, 'cache_intro_env.json');
const CACHE_DB_FILE = path.join(__dirname, 'cache.db');
console.log(`[System] Cache Type: ${CACHE_TYPE}`);
// 初始化数据库文件 (仅本地/Docker 环境)
if (!process.env.VERCEL && !fs.existsSync(DATA_FILE)) {
if (fs.existsSync(TEMPLATE_FILE)) {
fs.copyFileSync(TEMPLATE_FILE, DATA_FILE);
console.log('[Init] 已从模板创建 db.json');
} else {
const initialData = { sites: [] };
fs.writeFileSync(DATA_FILE, JSON.stringify(initialData, null, 2));
console.log('[Init] 已创建默认 db.json');
}
}
// ========== 缓存抽象层 ==========
class CacheManager {
constructor(type) {
this.type = type;
this.searchCache = {};
this.detailCache = {};
this.introCache = {}; // ⏭️ 片头/片尾标记(json/memory 模式):独立命名空间,不与 VOD 详情缓存串味/共用淘汰
this.introEnvCache = {}; // 🎵 片头/片尾音频响度包络(客户端学习成果的云备份,跨设备/跨线路复用)
this.db = null;
this.init();
}
init() {
if (this.type === 'json') {
if (fs.existsSync(SEARCH_CACHE_JSON)) {
try { this.searchCache = JSON.parse(fs.readFileSync(SEARCH_CACHE_JSON)); } catch (e) { }
}
if (fs.existsSync(DETAIL_CACHE_JSON)) {
try { this.detailCache = JSON.parse(fs.readFileSync(DETAIL_CACHE_JSON)); } catch (e) { }
}
if (fs.existsSync(INTRO_CACHE_JSON)) {
try { this.introCache = JSON.parse(fs.readFileSync(INTRO_CACHE_JSON)); } catch (e) { }
}
if (fs.existsSync(INTRO_ENV_CACHE_JSON)) {
try { this.introEnvCache = JSON.parse(fs.readFileSync(INTRO_ENV_CACHE_JSON)); } catch (e) { }
}
} else if (this.type === 'sqlite') {
try {
const Database = require('better-sqlite3');
this.db = new Database(CACHE_DB_FILE);
// WAL 模式 + 自动 checkpoint:之前 DB 处于 WAL 但无自动 checkpoint,
// WAL 文件会无限增长(已观测到 4MB)占满磁盘。这里显式开启并限制 WAL 大小。
try {
this.db.pragma('journal_mode = WAL');
this.db.pragma('wal_autocheckpoint = 1000'); // 约累计 4MB 自动 checkpoint
this.db.pragma('synchronous = NORMAL');
} catch (e) { console.warn('[Cache] 设置 WAL pragma 失败:', e.message); }
// 创建缓存表
this.db.exec(`
CREATE TABLE IF NOT EXISTS cache (
category TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
expire INTEGER NOT NULL,
PRIMARY KEY (category, key)
)
`);
// 创建用户历史记录表(用于多用户同步)
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_history (
user_token TEXT NOT NULL,
item_id TEXT NOT NULL,
item_data TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_token, item_id)
)
`);
// 创建用户设置表(多用户同步:弹幕开关等个性化偏好,跨设备记住)
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_settings (
user_token TEXT PRIMARY KEY,
settings_data TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// 历史删除墓碑:记录"某条历史在何时被删",跨设备同步删除,防止别的设备/旧会话把已删记录复活
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_history_deleted (
user_token TEXT NOT NULL,
item_id TEXT NOT NULL,
deleted_at INTEGER NOT NULL,
PRIMARY KEY (user_token, item_id)
)
`);
// 求片:用户提交想看但站内没有的剧;站长在站内后台贴磁力/下载链接履行,用户在"我的求片"看链接自取
this.db.exec(`
CREATE TABLE IF NOT EXISTS content_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_token TEXT NOT NULL,
user_label TEXT,
name TEXT NOT NULL,
tmdb_id TEXT,
poster TEXT,
note TEXT,
year TEXT,
aka TEXT,
cast_info TEXT,
status TEXT NOT NULL DEFAULT 'pending',
fulfill_link TEXT,
fulfill_note TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// 求片:为帮站长准确找片新增的可选字段(年份/外文名又名/导演主演)。
// 用幂等 ALTER 给【已存在】的旧表补列(SQLite 列已存在会抛错→吞掉即可),免破坏旧数据。
for (const col of ['year', 'aka', 'cast_info']) {
try { this.db.exec(`ALTER TABLE content_requests ADD COLUMN ${col} TEXT`); } catch (e) { /* 列已存在 */ }
}
// 用户统计/封禁:站长后台据此看每个用户的活跃/封禁。观看数据另从 user_history 现算。
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_stats (
user_token TEXT PRIMARY KEY,
label TEXT,
first_seen INTEGER,
last_login INTEGER,
last_active INTEGER,
banned INTEGER NOT NULL DEFAULT 0,
banned_at INTEGER
)
`);
// 创建索引加速过期查询
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_expire ON cache(expire)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_history_user ON user_history(user_token)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_req_user ON content_requests(user_token)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_req_status ON content_requests(status)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_stats_active ON user_stats(last_active)`);
// 清理过期数据
this.db.prepare('DELETE FROM cache WHERE expire < ?').run(Date.now());
console.log(`[SQLite Cache] Database initialized: ${CACHE_DB_FILE}`);
} catch (e) {
console.error('[SQLite Cache] Init failed, falling back to memory:', e.message);
this.type = 'memory';
}
}
}
_bucket(category) { return category === 'search' ? this.searchCache : category === 'intro' ? this.introCache : category === 'introenv' ? this.introEnvCache : this.detailCache; }
get(category, key) {
if (this.type === 'memory' || this.type === 'json') {
const data = this._bucket(category)[key];
if (data && data.expire > Date.now()) return data.value;
return null;
} else if (this.type === 'sqlite' && this.db) {
try {
const row = this.db.prepare(
'SELECT value FROM cache WHERE category = ? AND key = ? AND expire > ?'
).get(category, key, Date.now());
return row ? JSON.parse(row.value) : null;
} catch (e) {
console.error('[SQLite Cache] Get error:', e.message);
return null;
}
}
return null;
}
set(category, key, value, ttlSeconds = 600) {
const expire = Date.now() + ttlSeconds * 1000;
if (this.type === 'memory') {
this._bucket(category)[key] = { value, expire };
} else if (this.type === 'json') {
this._bucket(category)[key] = { value, expire };
this.saveDisk(category);
} else if (this.type === 'sqlite' && this.db) {
try {
this.db.prepare(`
INSERT OR REPLACE INTO cache (category, key, value, expire)
VALUES (?, ?, ?, ?)
`).run(category, key, JSON.stringify(value), expire);
} catch (e) {
console.error('[SQLite Cache] Set error:', e.message);
}
}
}
saveDisk(only) {
if (this.type !== 'json') return;
// 只写被改动的桶(片头/片尾提交别每次重写整个详情缓存文件)
if (!only || only === 'search') fs.writeFileSync(SEARCH_CACHE_JSON, JSON.stringify(this.searchCache));
if (!only || only === 'intro') fs.writeFileSync(INTRO_CACHE_JSON, JSON.stringify(this.introCache));
if (!only || only === 'introenv') fs.writeFileSync(INTRO_ENV_CACHE_JSON, JSON.stringify(this.introEnvCache));
if (!only || (only !== 'search' && only !== 'intro' && only !== 'introenv')) fs.writeFileSync(DETAIL_CACHE_JSON, JSON.stringify(this.detailCache));
}
// 按 key 前缀列出某类别下的未过期条目(音频包络"跨线路借用"查同剧其它线路用)。上限截断防大扫描。
list(category, prefix, limit = 20) {
const out = [];
if (this.type === 'memory' || this.type === 'json') {
const bucket = this._bucket(category), now = Date.now();
for (const k of Object.keys(bucket)) {
if (k.indexOf(prefix) !== 0) continue;
const data = bucket[k];
if (data && data.expire > now) { out.push({ key: k, value: data.value }); if (out.length >= limit) break; }
}
} else if (this.type === 'sqlite' && this.db) {
try {
// LIKE 通配符注入防护:剧名归一化不会剥掉 % ,必须转义(否则含 % 的标题会匹配到别的剧)
const esc = prefix.replace(/[\\%_]/g, ch => '\\' + ch);
const rows = this.db.prepare(
"SELECT key, value FROM cache WHERE category = ? AND key LIKE ? ESCAPE '\\' AND expire > ? LIMIT ?"
).all(category, esc + '%', Date.now(), limit);
for (const r of rows) { try { out.push({ key: r.key, value: JSON.parse(r.value) }); } catch (e) { } }
} catch (e) {
console.error('[SQLite Cache] List error:', e.message);
}
}
return out;
}
// 定期清理过期缓存 (SQLite)
cleanup() {
if (this.type === 'sqlite' && this.db) {
try {
const result = this.db.prepare('DELETE FROM cache WHERE expire < ?').run(Date.now());
if (result.changes > 0) {
console.log(`[SQLite Cache] Cleaned ${result.changes} expired entries`);
}
} catch (e) {
console.error('[SQLite Cache] Cleanup error:', e.message);
}
}
}
}
const cacheManager = new CacheManager(CACHE_TYPE);
// 定期清理过期缓存 (每小时执行一次)
setInterval(() => {
cacheManager.cleanup();
}, 60 * 60 * 1000);
// ========== 中间件配置 ==========
// 启用 Gzip/Brotli 压缩
const compression = require('compression');
app.use(compression({
level: 6, // 压缩级别 1-9,6 是性能与压缩率的平衡点
threshold: 1024, // 只压缩大于 1KB 的响应
filter: (req, res) => {
// 不压缩 SSE 事件流
if (req.headers['accept'] === 'text/event-stream') {
return false;
}
return compression.filter(req, res);
}
}));
app.use(cors());
app.use(bodyParser.json({ limit: '5mb' })); // 增大限制以支持历史记录同步
// ========== API 速率限制 ==========
const rateLimit = require('express-rate-limit');
// ipKeyGenerator:把 IP 归一化为限流 key(IPv6 归并到子网前缀,避免同一 /64 轮换绕过限流)
const { ipKeyGenerator } = require('express-rate-limit');
const ipKey = (req) => ipKeyGenerator(getClientIP(req) || req.ip || '0.0.0.0');
// 通用 API 限流:每 IP 每分钟最多 600 次请求
// 注意:页面加载时会发送大量图片和 API 请求,需要足够高的限制
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 分钟窗口
max: 600, // 每 IP 最多 600 次(约 10 次/秒)
standardHeaders: true, // 返回 RateLimit-* 标准头
legacyHeaders: false, // 禁用 X-RateLimit-* 旧头
// 用真实客户端 IP 计数(CF-Connecting-IP/X-Real-IP),否则反代后会把所有用户算作同一个 IP
keyGenerator: ipKey,
message: { error: '请求过于频繁,请稍后再试 (Rate limit exceeded)' },
skip: (req) => {
// 跳过静态资源请求
if (!req.path.startsWith('/api/')) return true;
// 配置、认证、站点列表请求不限流(页面加载必需)
if (req.path === '/api/config' || req.path.startsWith('/api/auth/') || req.path === '/api/sites') return true;
// 图片代理请求不限流(前端有大量图片)
if (req.path.startsWith('/api/tmdb-image/')) return true;
// TMDB 代理请求不限流
if (req.path === '/api/tmdb-proxy') return true;
return false;
}
});
// 搜索 API 更严格的限流:每 IP 每分钟最多 120 次搜索
const searchLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
keyGenerator: ipKey,
message: { error: '搜索请求过于频繁,请稍后再试' }
});
// 分享深链预览(/api/preview)更严格的限流:未登录可访问的公开接口,会打 TMDB,需防刷
const previewLimiter = rateLimit({
windowMs: 60 * 1000,
max: 40, // 每 IP 每分钟最多 40 次(真实用户一次开链只调一次,足够宽松)
keyGenerator: ipKey,
message: { error: '预览请求过于频繁,请稍后再试' }
});
// 应用通用限流
app.use(apiLimiter);
// 对搜索 API 应用更严格的限流
app.use('/api/search', searchLimiter);
// 对分享预览 API 应用更严格的限流
app.use('/api/preview', previewLimiter);
// ========== 静态资源配置 ==========
// 静态资源 30天缓存 (libs 目录 - CSS/JS) - 这些文件不会变化
app.use('/libs', express.static('public/libs', {
maxAge: '30d',
immutable: true,
etag: true,
lastModified: true
}));
// 图片缓存目录 - 30天缓存
app.use('/cache', express.static('public/cache', {
maxAge: '30d',
immutable: true,
etag: true
}));
// ========== 自动识别站点 URL ==========
/**
* 从请求自动识别当前站点的 URL
* 优先级:SITE_URL 环境变量 > 请求头自动检测
* @param {object} req - Express 请求对象
* @returns {string} - 站点 URL,如 https://mysite.com(不带尾部斜杠)
*/
function getSiteUrl(req) {
// 1. 优先使用环境变量(用户显式配置的优先级最高)
if (process.env.SITE_URL) {
return process.env.SITE_URL.replace(/\/$/, '');
}
// 2. 从请求头自动检测
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
const host = req.headers['x-forwarded-host'] || req.get('host');
if (host) {
return `${protocol}://${host}`;
}
// 3. 兜底默认值
return 'https://ednovas.video';
}
// 缓存读取的 index.html 原始内容(避免每次请求都读磁盘)
let indexHtmlTemplate = null;
let robotsTxtTemplate = null;
const DEFAULT_SITE_URL = 'https://ednovas.video';
// 🔗 社交平台抓取卡片的爬虫 UA(微信/Twitter/FB/Telegram/Google 等)
function isSocialCrawler(req) {
const ua = (req.headers['user-agent'] || '').toLowerCase();
return /bot|crawl|spider|facebookexternalhit|twitterbot|whatsapp|telegram|slackbot|discordbot|linkedinbot|embedly|pinterest|redditbot|googlebot|bingbot|baiduspider|bytespider|sogou|yisou|360spider|micromessenger|qqbrowser|qq\/|weibo|line-poker|skypeuripreview/i.test(ua);
}
// 🔗 分享深链富预览页:按剧名输出 OG/Twitter 卡片,再 JS 跳转回 SPA。
// 只给社交爬虫返回此页;真实用户照常拿 SPA(SPA 自己解析 ?play= 打开剧)。
async function renderSharePage(req, res, rawName) {
const name = String(rawName || '').slice(0, 100);
const siteUrl = getSiteUrl(req);
let poster = `${siteUrl}/icon.png`;
// 尽力用 TMDB 搜一张海报作为卡片大图(失败/超时则用站点图标,不阻塞)
try {
const TMDB_API_KEY = process.env.TMDB_API_KEY;
if (TMDB_API_KEY && name) {
const TMDB_PROXY_URL = process.env['TMDB_PROXY_URL'];
const serverInChina = process.env['SERVER_IN_CHINA'] === 'true';
const base = (TMDB_PROXY_URL && serverInChina) ? `${TMDB_PROXY_URL.replace(/\/$/, '')}/api/3` : 'https://api.themoviedb.org/3';
const r = await axios.get(`${base}/search/multi`, { params: { api_key: TMDB_API_KEY, language: 'zh-CN', query: name }, timeout: 2500 });
const hit = ((r.data && r.data.results) || []).find(x => x.poster_path || x.backdrop_path);
if (hit) poster = `https://image.tmdb.org/t/p/w500${hit.poster_path || hit.backdrop_path}`;
}
} catch (e) { /* 忽略,用站点图标 */ }
const eName = escapeHtml(name);
const ePoster = escapeHtml(poster);
const desc = escapeHtml(`在 E视界 免费在线观看《${name}》,多线路高清播放。`);
const playUrl = `${siteUrl}/?play=${encodeURIComponent(name)}`;
const spaUrl = `/?play=${encodeURIComponent(name)}&_spa=1`;
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${eName} - 在线观看 | E视界</title>
<meta name="description" content="${desc}">
<link rel="canonical" href="${escapeHtml(playUrl)}">
<meta property="og:type" content="video.other">
<meta property="og:title" content="${eName} - 在线观看">
<meta property="og:description" content="${desc}">
<meta property="og:image" content="${ePoster}">
<meta property="og:url" content="${escapeHtml(playUrl)}">
<meta property="og:site_name" content="E视界">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${eName} - 在线观看">
<meta name="twitter:description" content="${desc}">
<meta name="twitter:image" content="${ePoster}">
<meta http-equiv="refresh" content="0;url=${escapeHtml(spaUrl)}">
<script>location.replace(${JSON.stringify(spaUrl)});</script>
</head>
<body style="background:#141414;color:#fff;font-family:-apple-system,sans-serif;text-align:center;padding:40px;">
<h1>${eName}</h1>
<p>正在进入播放页…若未自动跳转,请 <a href="${escapeHtml(spaUrl)}" style="color:#e50914;">点此进入</a></p>
</body>
</html>`;
res.set('Cache-Control', 'public, max-age=600');
res.type('html').send(html);
}
// ⚠️ 关键:动态注入站点 URL 到 index.html
// 自动将 meta 标签中的 ednovas.video 替换为当前访问的网站地址
app.get(['/', '/index.html'], async (req, res) => {
// 🔗 分享深链:社交爬虫请求 /?play=剧名 时返回富预览卡片;真实用户(无 bot UA 或带 _spa)照常拿 SPA
if (req.query.play && !req.query._spa && isSocialCrawler(req)) {
try { return await renderSharePage(req, res, req.query.play); }
catch (e) { console.error('[SharePage] error:', e.message); /* 失败则继续返回 SPA */ }
}
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
try {
// 懒加载模板
if (!indexHtmlTemplate) {
indexHtmlTemplate = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf-8');
}
const siteUrl = getSiteUrl(req);
// 如果当前就是默认地址,不需要替换
if (siteUrl === DEFAULT_SITE_URL) {
res.type('html').send(indexHtmlTemplate);
return;
}
// 替换所有 hardcoded 的默认 URL
const html = indexHtmlTemplate.replace(
new RegExp(DEFAULT_SITE_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
siteUrl
);
res.type('html').send(html);
} catch (err) {
console.error('[Dynamic HTML] Error:', err.message);
// 回退到静态文件
res.sendFile(path.join(__dirname, 'public/index.html'));
}
});
// 站长后台:独立页面(非首页弹窗)。鉴权在前端输入 ADMIN_TOKEN 后由 /api/admin/* 服务端校验。
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public/admin.html'));
});
// 动态注入站点 URL 到 robots.txt
app.get('/robots.txt', (req, res) => {
try {
if (!robotsTxtTemplate) {
robotsTxtTemplate = fs.readFileSync(path.join(__dirname, 'public/robots.txt'), 'utf-8');
}
const siteUrl = getSiteUrl(req);
if (siteUrl === DEFAULT_SITE_URL) {
res.type('text').send(robotsTxtTemplate);
return;