Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
3741 lines (3305 loc) · 155 KB
/
Copy pathmain.py
File metadata and controls
3741 lines (3305 loc) · 155 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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NodeCollection Pro v2.13.0 - 订阅源采集 + 多格式转换一体化工具
架构:
config.yaml (TG频道) + airports.yaml (机场列表) + merge.yaml (上游订阅白名单)
→ 并发爬取频道 + 探测机场公开订阅
→ 校验分类去重
→ 原始 YAML 输出 (向后兼容)
→ subconverter API 多格式转换 (Clash/V2Ray/Surge/SingBox)
→ 上游订阅融合: 白名单拉取 → 解析 → 安检 → [ext:来源] 标注 → 独立输出 output/merged/
→ GitHub Actions 自动提交
新增模块:
- load_airports(): 加载机场列表
- probe_airport(): 探测机场公开订阅链接
- call_subconverter(): 调用 subconverter API 转换格式
- generate_multi_format(): 生成多格式订阅文件
- load_upstreams(): 加载上游订阅白名单 (merge.yaml)
- fetch_upstream() / fetch_all_upstreams(): 上游拉取 (重试 + 失败隔离)
- parse_upstream_text(): 解析上游订阅 (Base64 / 明文链接 / Clash YAML)
- generate_merged_format(): 融合节点独立输出到 output/merged/
- extract_host_port(): 从分享链接 URI 提取 host/port (协议无关)
- measure_uri_latencies() / measure_proxy_latencies(): 并发 TCP 测速 (P1 v1.5.0)
P17 (v2.9.0) 主订阅源健康治理 + 综合订阅补充修复:
- _LenientYAMLLoader: 忽略未知 YAML tag (如 !<str>), 修复主订阅补充读取失败
(线上主订阅 latest.yaml 含 !<str> tag → safe_load 抛错 → _load_main_sub_proxies
返回 [] → 综合订阅主订阅补充丢失 main_supplement=0)
- classify_subscription 空壳判断: clash 需 proxies 非空, v2 需至少一条有效协议行
- SUB_URL_BLACKLIST: 已知失效/低质/违规主订阅源域名黑名单
- _purge_stale_sub_urls: 合并前对历史累积 URL 重新验证, 剔除失效/空壳/黑名单源,
防止 sub/latest.yaml 只增不减地累积失效源
"""
VERSION = '2.14.1'
import re
import os
import sys
import time
import json
import socket
import base64
import shutil
import tempfile
import threading
import ipaddress
import datetime
import http.server
from urllib.parse import urlparse, quote, unquote, urlencode
from concurrent.futures import ThreadPoolExecutor, as_completed
import yaml
import requests
from loguru import logger
from tqdm import tqdm
from retry import retry
# ============================================================
# 配置常量
# ============================================================
CONFIG_PATH = './config.yaml'
AIRPORTS_PATH = './airports.yaml'
MERGE_PATH = './merge.yaml'
SUB_DIR = 'sub'
OUTPUT_DIR = 'output'
MERGED_DIR = 'merged'
MAX_THREADS = 32
CHANNEL_THREADS = 8
AIRPORT_THREADS = 8
REQUEST_TIMEOUT = 10
CHECK_ALL_TIMEOUT = 150 # P22.3: 订阅校验阶段硬超时 (秒) — 个别 URL DNS 解析/连接挂起不受
# requests timeout 控制 (getaddrinfo 是 OS 级), 曾导致单 URL 挂 11.5 分钟;
# 正常 1431 URL 全量校验约 35s, 150s 为 4 倍护栏
CHANNEL_TIMEOUT = 15
AIRPORT_TIMEOUT = 8
RETRY_TIMES = 2
USER_AGENT = 'ClashforWindows/0.18.1'
PROTOCOL_PREFIXES = ('ss://', 'ssr://', 'vmess://', 'trojan://')
# P17 (v2.9.0): 已知失效/低质/违规主订阅源域名黑名单 (主订阅源健康治理)
# 来源: P15/P16 线上验收确认的失效源 (HTTP 200 但空壳/不可达) + 违规词源
SUB_URL_BLACKLIST = (
'ziyoufly.com', 'youzilite.help', 'xswl-fff.com', 'mtvpn.net',
'820010.xyz', 'knjc.cfd', 'xueshanlink.com', 'apit.yun7g.top',
'lovebabyforever.workers.dev', 'iuiu.lovebabyforever',
)
# 上游订阅融合配置 (v1.4.0)
UPSTREAM_THREADS = 8
UPSTREAM_TIMEOUT = 60
UPSTREAM_RETRIES = 3
UPSTREAM_RETRY_DELAY = 5
# 上游节点允许的协议前缀 (比自采管道宽: 兼容 vless / hysteria2 / tuic 等新协议)
UPSTREAM_PROTOCOL_PREFIXES = (
'ss://', 'ssr://', 'vmess://', 'vless://', 'trojan://',
'hysteria://', 'hysteria2://', 'hy2://', 'tuic://',
)
# Clash YAML 代理类型白名单 (解析上游 Clash 订阅时过滤未知类型)
CLASH_PROXY_TYPES = {
'ss', 'ssr', 'vmess', 'vless', 'trojan', 'hysteria', 'hysteria2',
'tuic', 'snell', 'socks5', 'http', 'mixed',
}
# 节点延迟测速配置 (P1 v1.5.0, P3 v1.7.0 优化, P11 v2.5.0 质量优化)
LATENCY_TIMEOUT = 4 # 单节点 TCP connect 超时 (秒, P3: 5→4)
LATENCY_THREADS = 48 # 并发测速线程数 (P3: 32→48)
LATENCY_SAMPLE_RATIO = 1.0 # 抽测比例 (1.0 = 全量测速), 可在 merge.yaml 覆盖
LATENCY_FAIL_THRESHOLD = 3 # 连续 N 个周期不可达则剔除 (P3: 2→3, 与冷却期配合)
LATENCY_MAX_THRESHOLD = 2000 # P11: 延迟阈值 (ms), 超过此值的节点排到末尾, 总量截断时优先剔除 (P11.5: 800→2000, 保留更多可用节点)
# P22 (v2.14.0): 测速抖动检测 (C) — 对排序后候选池二次复测, 过滤"假快"节点
JITTER_RECHECK_TOP = 180 # 复测候选数 (覆盖 MERGED_MAX_NODES=150 且留余量, 复测剔除后仍充足)
JITTER_RECHECK_TIMEOUT = 3 # 复测单节点超时 (秒, P22.1: 2→3 减少慢速节点误杀; 仍比初次 4s 短)
JITTER_HISTORY_KEEP = 14 # D: 质量历史保留轮数 (趋势图数据源)
MERGED_MAX_NODES = 150 # P11: 融合输出每格式总量上限 (200→150, 提升整体质量)
# 冷却期跳过测速配置 (P3 v1.7.0, T3.1)
# 连续失败 COOLDOWN_ENTRY_THRESHOLD 次后进入冷却期, 跳过测速以减少耗时;
# 每 COOLDOWN_RETRY_INTERVAL 个周期强制重试一次, 最多重试 COOLDOWN_MAX_RETRIES 次;
# 重试成功则退出冷却期, 重试全部失败则剔除.
COOLDOWN_ENTRY_THRESHOLD = 2 # 连续失败 N 次进入冷却期
COOLDOWN_RETRY_INTERVAL = 3 # 冷却期内每 N 个周期强制重试一次
COOLDOWN_MAX_RETRIES = 3 # 冷却期内最多重试 N 次, 超过则剔除
# P11.1 (v2.5.1): 违规词过滤 - 在源头屏蔽包含不良内容的节点
# 不依赖 subconverter 的 exclude_remarks (可能因正则/编码问题失效), 直接在 Python 层过滤
ILLEGAL_KEYWORDS = [
# 色情/成人相关
'高清无码', '高清無碼', 'AVToday', '成人影片', '色情', '情色', '黃色', '黄色',
'萝莉', '幼女', '强奸', '亂倫', '乱伦', '自慰', '性愛', '性爱', '做爱',
'炮友', '约炮', '一夜情', '外围', '援交', '主播福利', '福利姬', '裸聊',
'偷拍', '偷窥', '迷奸', '下药', '成人', '色图', '黄图', '淫',
# 广告/推广相关 (常见于免费节点命名)
'到期时间', '剩余流量', '官方网站', '产品介绍', '平台官网', '官网地址',
'推广链接', '广告投放', 'Expire', 'Traffic', 'Website',
]
def contains_illegal_keyword(name):
"""检查节点名称是否包含违规词 (不区分大小写)"""
if not name:
return False
name_lower = str(name).lower()
for keyword in ILLEGAL_KEYWORDS:
if keyword.lower() in name_lower:
return True
return False
# 协议兼容性矩阵 (P3 v1.7.0, T3.3)
# 各输出格式支持的代理协议集合, 用于 README 标注和可选的预过滤
# subconverter 本身会自动跳过不支持的协议, 此处主要用于文档说明
PROTOCOL_COMPATIBILITY = {
'clash': ('ss', 'ssr', 'vmess', 'vless', 'trojan'),
'v2ray': ('ss', 'vmess', 'vless', 'trojan'),
'surge': ('ss', 'vmess', 'trojan'),
'mixed': ('ss', 'ssr', 'vmess', 'vless', 'trojan', 'hysteria', 'hysteria2', 'tuic'),
'singbox': ('ss', 'ssr', 'vmess', 'vless', 'trojan', 'hysteria', 'hysteria2', 'tuic'),
}
# 协议中文名称映射 (用于 README 显示)
PROTOCOL_NAMES = {
'ss': 'Shadowsocks',
'ssr': 'ShadowsocksR',
'vmess': 'VMess',
'vless': 'VLESS',
'trojan': 'Trojan',
'hysteria': 'Hysteria',
'hysteria2': 'Hysteria2',
'tuic': 'TUIC',
}
# ============================================================
# P5 (v1.9.0) 节点地区识别与无效过滤
# ============================================================
# 告警通知 Webhook (T5.6 预留接口, 暂不启用)
# 配置后将在运行失败/节点数异常时发送通知
# 支持: 钉钉机器人 / 飞书机器人 / Server酱 / 通用 Webhook
# 格式: https://oapi.dingtalk.com/robot/send?access_token=xxx
# https://open.feishu.cn/open-apis/bot/v2/hook/xxx
# https://sctapi.ftqq.com/xxx.send
ALERT_WEBHOOK_URL = os.environ.get('ALERT_WEBHOOK_URL', '')
ALERT_ENABLED = bool(ALERT_WEBHOOK_URL)
# 地区识别规则 (轻量方案: 基于节点名/服务器名的正则匹配)
# 格式: (地区中文名, 地区代码, 正则模式列表)
REGION_PATTERNS = [
('香港', 'HK', [r'香港|hong\s*kong|hk|hkg|🇭🇰']),
('台湾', 'TW', [r'台湾|taiwan|tw|tpe|🇹🇼']),
('日本', 'JP', [r'日本|japan|jp|tyo|nrt|🇯🇵']),
('新加坡', 'SG', [r'新加坡|singapore|sg|sin|🇸🇬']),
('美国', 'US', [r'美国|usa|united\s*states|america|nyc|lax|sfo|us|🇺🇸']),
('韩国', 'KR', [r'韩国|korea|kr|sel|icn|🇰🇷']),
('英国', 'UK', [r'英国|united\s*kingdom|britain|london|lhr|uk|🇬🇧']),
('德国', 'DE', [r'德国|germany|de|fra|🇩🇪']),
('法国', 'FR', [r'法国|france|fr|cdg|🇫🇷']),
('俄罗斯', 'RU', [r'俄罗斯|russia|ru|mow|🇷🇺']),
('加拿大', 'CA', [r'加拿大|canada|ca|yyz|🇨🇦']),
('澳大利亚', 'AU', [r'澳大利亚|australia|au|syd|🇦🇺']),
('荷兰', 'NL', [r'荷兰|netherlands|nl|ams|🇳🇱']),
('印度', 'IN', [r'印度|india|in|del|🇮🇳']),
('巴西', 'BR', [r'巴西|brazil|br|gru|🇧🇷']),
('波兰', 'PL', [r'波兰|poland|pl|waw|🇵🇱']),
('爱尔兰', 'IE', [r'爱尔兰|ireland|ie|dub|🇮🇪']),
('伊朗', 'IR', [r'伊朗|iran|ir|🇮🇷']),
]
# 已包含地区标识的正则 (用于判断是否需要添加地区前缀)
REGION_ALREADY_MARKED = re.compile(
r'🇭🇰|🇹🇼|🇯🇵|🇸🇬|🇺🇸|🇰🇷|🇬🇧|🇩🇪|🇫🇷|🇷🇺|🇨🇦|🇦🇺|🇳🇱|🇮🇳|🇧🇷|🇵🇱|🇮🇪|🇮🇷|'
r'香港|台湾|日本|新加坡|美国|韩国|英国|德国|法国|俄罗斯|加拿大|澳大利亚|荷兰|印度|巴西|波兰|爱尔兰|伊朗|'
r'\b(HK|TW|JP|SG|US|KR|UK|DE|FR|RU|CA|AU|NL|IN|BR|PL|IE|IR)\b',
re.IGNORECASE
)
def detect_region(text):
"""
P5 (v1.9.0, T5.3): 基于文本 (节点名/服务器名) 识别地区。
返回: (地区中文名, 地区代码) 或 (None, None)
轻量方案: 仅基于正则匹配, 不依赖 IP 地理库。
T13.1 (v2.6.1): 增加类型保护, 兼容 subconverter 输出 name 为数字的情况。
"""
if not text:
return None, None
text = str(text)
text_lower = text.lower()
for region_name, region_code, patterns in REGION_PATTERNS:
for pattern in patterns:
if re.search(pattern, text_lower):
return region_name, region_code
return None, None
# ============================================================
# P12 (v2.6.0) 保留地区 + 序号重命名
# ============================================================
# 地区代码 → 旗帜 emoji 映射 (用于输出兜底阶段的美观重命名)
REGION_EMOJI_MAP = {
'HK': '🇭🇰', 'TW': '🇹🇼', 'JP': '🇯🇵', 'SG': '🇸🇬', 'US': '🇺🇸',
'KR': '🇰🇷', 'UK': '🇬🇧', 'DE': '🇩🇪', 'FR': '🇫🇷', 'RU': '🇷🇺',
'CA': '🇨🇦', 'AU': '🇦🇺', 'NL': '🇳🇱', 'IN': '🇮🇳', 'BR': '🇧🇷',
'PL': '🇵🇱', 'IE': '🇮🇪', 'IR': '🇮🇷',
}
def _rename_with_region(name, index):
"""
P12 (v2.6.0): 保留地区 + 序号重命名 (subconverter 转换前使用)。
从原始节点名提取地区代码, 生成 'US 01' / 'JP 02' 格式。
既规避违规词, 又让 subconverter 的地区 filter 和 emoji 规则能识别地区。
"""
region_name, region_code = detect_region(name or '')
if region_code:
return f'{region_code} {index:02d}'
return f'{index:02d}'
def _rename_with_region_emoji(name, index):
"""
P12 (v2.6.0): 保留地区 emoji + 序号重命名 (输出兜底阶段使用)。
从节点名提取地区, 生成 '🇺🇸 01' / '🇯🇵 02' 格式。
美观且与 merged_config.ini 的地区 filter (含 emoji) 兼容。
"""
region_name, region_code = detect_region(name or '')
if region_code:
emoji = REGION_EMOJI_MAP.get(region_code, '')
return f'{emoji} {index:02d}' if emoji else f'{region_code} {index:02d}'
return f'{index:02d}'
def is_invalid_node_host(host):
"""
P5 (v1.9.0, T5.1): 检查 host 是否为无效地址 (本地/保留/回环/链路本地)。
域名节点返回 False (由客户端解析), IP 节点检查是否为私有/保留地址。
返回: True = 无效应剔除, False = 有效
"""
if not host:
return True
try:
ip = ipaddress.ip_address(host)
return (ip.is_private or ip.is_loopback or ip.is_reserved
or ip.is_link_local or ip.is_multicast or ip.is_unspecified)
except ValueError:
return False # 域名节点放行
# 历史版本保留配置 (P2 v1.6.0, T2.2)
HISTORY_KEEP_COUNT = 5 # 每格式保留最近 N 版日期文件, 超出自动清理最旧版本
# 上游监控与降级配置 (P2 v1.6.0, T2.3)
UPSTREAM_DEGRADE_THRESHOLD = 3 # 连续 N 次拉取失败标记为 degraded (已降级)
# 上游拉取镜像回退: 主 URL 连续失败时按序尝试镜像前缀 (仅对 raw.githubusercontent.com 生效)
# 解决本地/国内网络直连 raw.githubusercontent.com 被阻断 (10054) 或限流 (HTTP 429) 的问题
RAW_GITHUB_HOST = 'raw.githubusercontent.com'
UPSTREAM_MIRROR_PREFIXES = (
'https://ghfast.top/',
'https://gh-proxy.com/',
'https://ghproxy.net/',
)
# subconverter 配置
SUBCONVERTER_URL = os.environ.get('SUBCONVERTER_URL', 'http://127.0.0.1:25500')
SUBCONVERTER_TIMEOUT = 60
SUBCONVERTER_RETRIES = 3
SUBCONVERTER_RETRY_DELAY = 5
SUBCONVERTER_EXTERNAL_CONFIG = 'subconverter/external_config.ini'
SUBCONVERTER_MERGED_CONFIG = 'subconverter/merged_config.ini' # T1.3: 融合订阅独立配置 (含韩英分组)
# GitHub 仓库信息 (用于生成 README 中的订阅链接)
GITHUB_OWNER = os.environ.get('GITHUB_OWNER', 'huiwin')
GITHUB_REPO = os.environ.get('GITHUB_REPO', 'NodeCollection')
GITHUB_BRANCH = os.environ.get('GITHUB_BRANCH', 'main')
# Raw 链接基础路径
RAW_BASE = f'https://raw.githubusercontent.com/{GITHUB_OWNER}/{GITHUB_REPO}/{GITHUB_BRANCH}'
# 加速代理前缀配置: (显示名, 前缀模板)
# {url} 会被替换为原始 raw 链接
PROXY_PREFIXES = [
('原生', '{url}'),
('kkgithub', 'https://raw.kkgithub.com/{GITHUB_OWNER}/{GITHUB_REPO}/{GITHUB_BRANCH}{path}'),
('ghproxy.net', 'https://ghproxy.net/{url}'),
('gh-proxy.com', 'https://gh-proxy.com/{url}'),
('ghfast.top', 'https://ghfast.top/{url}'),
('jsdelivr', 'https://fastly.jsdelivr.net/gh/{GITHUB_OWNER}/{GITHUB_REPO}@{GITHUB_BRANCH}{path}'),
]
# 输出格式: (target参数, 输出子目录, 文件扩展名)
# T2.1 (v1.6.0): 新增 singbox 格式 (Sing-box / SagerNet / Hiddify 客户端)
OUTPUT_FORMATS = [
('clash', 'clash', 'yaml'),
('v2ray', 'v2ray', 'txt'),
('surge&ver=4', 'surge', 'conf'),
('mixed', 'mixed', 'txt'),
('singbox', 'singbox', 'json'),
]
URL_REGEX = re.compile(
r'https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]'
)
# 机场页面中常见的订阅/节点关键词
SUB_KEYWORDS = re.compile(
r'(subscribe|subscription|sub|api/v1/client|clash|v2ray|trojan|ssr|free|'
r'node|节点|订阅|免费|试用|trial)',
re.IGNORECASE
)
# ============================================================
# 初始化目录
# ============================================================
def pre_check():
"""创建 sub/YYYY/M/ 和 output/ 目录结构,返回当日输出路径。"""
today = datetime.datetime.today()
path_year = os.path.join(SUB_DIR, str(today.year))
path_mon = os.path.join(path_year, str(today.month))
path_yaml = os.path.join(path_mon, f'{today.month}-{today.day}.yaml')
for directory in (SUB_DIR, path_year, path_mon, OUTPUT_DIR):
if not os.path.exists(directory):
os.makedirs(directory)
logger.info('初始化目录完成')
return path_yaml
# ============================================================
# YAML 读写
# ============================================================
def yaml_check(path_yaml):
"""读取已有订阅 YAML,不存在则返回空结构。"""
if os.path.isfile(path_yaml):
with open(path_yaml, encoding='UTF-8') as f:
dict_url = yaml.load(f, Loader=yaml.FullLoader)
else:
dict_url = {
'机场订阅': [],
'clash订阅': [],
'v2订阅': [],
'开心玩耍': [],
}
logger.info('读取已有文件成功')
return dict_url
def yaml_save(path_yaml, dict_url):
"""将订阅数据写入 YAML 文件。"""
with open(path_yaml, 'w', encoding='utf-8') as f:
yaml.dump(dict_url, f, allow_unicode=True)
logger.info(f'写入原始 YAML: {path_yaml}')
# ============================================================
# 配置读取
# ============================================================
def get_config():
"""读取 config.yaml 中的 Telegram 频道列表。"""
with open(CONFIG_PATH, encoding='UTF-8') as f:
data = yaml.load(f, Loader=yaml.FullLoader)
list_tg = data.get('tgchannel', [])
new_list = []
for url in list_tg:
channel_name = url.split('/')[-1].strip()
if channel_name:
new_list.append(f'https://t.me/s/{channel_name}')
logger.info(f'读取 TG 频道配置: {len(new_list)} 个')
return new_list
def load_fixed_subscriptions():
"""
P19 (v2.11.0): 读取 config.yaml 中的固定订阅源列表。
固定订阅源与 TG 频道/机场一样, 走 sub_check 校验 (可达+有节点+非黑名单),
有效才保留; 失效自动剔除, 不污染输出。
"""
try:
with open(CONFIG_PATH, encoding='UTF-8') as f:
data = yaml.load(f, Loader=yaml.FullLoader)
fixed = data.get('fixed_subscriptions', []) or []
fixed = [u.strip() for u in fixed if u and u.strip().startswith(('http://', 'https://'))]
logger.info(f'读取固定订阅源: {len(fixed)} 个')
return fixed
except Exception as e:
logger.warning(f'读取固定订阅源失败: {e}')
return []
def load_airports():
"""
读取 airports.yaml 机场列表。
返回: list[dict] 每项含 domain, clash(可选), note(可选)
"""
if not os.path.isfile(AIRPORTS_PATH):
logger.info('未找到 airports.yaml,跳过机场探测')
return []
with open(AIRPORTS_PATH, encoding='UTF-8') as f:
data = yaml.load(f, Loader=yaml.FullLoader)
airports = data.get('airports', []) if data else []
logger.info(f'读取机场列表: {len(airports)} 个')
return airports
# ============================================================
# 安全工具
# ============================================================
def is_safe_url(url):
"""URL 安全校验,阻止内网地址(SSRF 防护)。"""
try:
parsed = urlparse(url)
except Exception:
return False
if parsed.scheme not in ('http', 'https'):
return False
host = parsed.hostname
if not host:
return False
try:
ip = ipaddress.ip_address(host)
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
return False
except ValueError:
pass
return True
# ============================================================
# 频道爬取
# ============================================================
def get_channel_http(session, channel_url):
"""请求 Telegram 频道公开页面,正则提取所有 URL。"""
try:
resp = session.get(channel_url, timeout=CHANNEL_TIMEOUT)
url_list = URL_REGEX.findall(resp.text)
logger.info(f'{channel_url}\t获取成功 ({len(url_list)} 个 URL)')
return url_list
except requests.Timeout:
logger.warning(f'{channel_url}\t请求超时')
return []
except requests.ConnectionError:
logger.warning(f'{channel_url}\t连接失败')
return []
except Exception as e:
logger.warning(f'{channel_url}\t获取失败: {type(e).__name__}: {e}')
return []
def crawl_all_channels(session, channel_urls):
"""并发爬取所有 Telegram 频道。"""
all_urls = []
with ThreadPoolExecutor(max_workers=CHANNEL_THREADS) as executor:
futures = {
executor.submit(get_channel_http, session, url): url
for url in channel_urls
}
for future in as_completed(futures):
result = future.result()
if result:
all_urls.extend(result)
safe_urls = [u for u in all_urls if is_safe_url(u)]
unique_urls = list(set(safe_urls))
logger.info(
f'频道爬取完成: 原始 {len(all_urls)}, 安全 {len(safe_urls)}, 去重 {len(unique_urls)}'
)
return unique_urls
# ============================================================
# 机场探测 (Phase 1: 简单版 - 探测公开页面中的订阅链接)
# ============================================================
def probe_airport(session, airport):
"""
探测单个机场域名的公开订阅链接。
策略:
1. 访问 https://{domain}/ 页面
2. 正则提取页面中所有 URL
3. 过滤出含订阅关键词的链接
4. 返回候选订阅 URL 列表
注意: 此为 Phase 1 简单实现,不包含自动注册。
Phase 2 将增加 v2board API 自动注册获取试用订阅。
"""
domain = airport.get('domain', '').strip()
if not domain:
return []
# 标准化域名为完整 URL
if not domain.startswith('http'):
domain = f'https://{domain}'
if not is_safe_url(domain):
return []
candidate_urls = []
try:
resp = session.get(domain, timeout=AIRPORT_TIMEOUT, allow_redirects=True)
page_urls = URL_REGEX.findall(resp.text)
for url in page_urls:
if not is_safe_url(url):
continue
# 检查 URL 或路径是否包含订阅关键词
if SUB_KEYWORDS.search(url):
candidate_urls.append(url)
# 也检查页面文本中的 base64 编码内容 (可能包含节点链接)
# 这里保持简单,不做 base64 解码
except requests.Timeout:
logger.debug(f'[airport] {domain}\t超时')
except requests.ConnectionError:
logger.debug(f'[airport] {domain}\t连接失败')
except Exception as e:
logger.debug(f'[airport] {domain}\t{type(e).__name__}: {e}')
if candidate_urls:
logger.info(f'[airport] {domain}\t发现 {len(candidate_urls)} 个候选订阅')
return candidate_urls
def probe_all_airports(session, airports):
"""并发探测所有机场域名。"""
if not airports:
return []
all_candidates = []
logger.info(f'开始探测 {len(airports)} 个机场域名 ---')
with ThreadPoolExecutor(max_workers=AIRPORT_THREADS) as executor:
futures = {
executor.submit(probe_airport, session, airport): airport
for airport in airports
}
for future in as_completed(futures):
result = future.result()
if result:
all_candidates.extend(result)
unique_candidates = list(set(all_candidates))
logger.info(
f'机场探测完成: 原始 {len(all_candidates)}, 去重 {len(unique_candidates)}'
)
return unique_candidates
# ============================================================
# 上游订阅融合 (v1.4.0 External Sources)
# 白名单拉取 → 解析 → 安检 → [ext:来源] 标注 → 独立输出 output/merged/
# ============================================================
def load_upstreams():
"""
读取 merge.yaml 上游订阅白名单。
返回: list[dict] 每项含 name, url, enabled(可选), max_nodes(可选)
配置缺失或为空时返回空列表,不影响主流程。
"""
if not os.path.isfile(MERGE_PATH):
logger.info('未找到 merge.yaml,跳过上游订阅融合')
return []
try:
with open(MERGE_PATH, encoding='UTF-8') as f:
data = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
logger.warning(f'merge.yaml 读取失败: {e},跳过上游订阅融合')
return []
upstreams = [
u for u in (data.get('upstreams') or [])
if isinstance(u, dict) and u.get('url') and u.get('enabled', True)
]
logger.info(f'读取上游订阅白名单: {len(upstreams)} 个启用')
return upstreams
def _upstream_fetch_candidates(url):
"""
构造上游拉取的候选 URL 列表: [主 URL] + 镜像 URL (仅 raw.githubusercontent.com)。
主 URL 享有完整重试次数;镜像各尝试 1 次,作为网络阻断时的回退通道。
"""
candidates = [url]
try:
if urlparse(url).hostname == RAW_GITHUB_HOST:
candidates += [m + url for m in UPSTREAM_MIRROR_PREFIXES]
except Exception:
pass
return candidates
def _fetch_upstream_once(session, name, url):
"""单次拉取一个 URL。成功返回订阅文本,失败返回 None (日志由调用方统一输出)。"""
resp = session.get(
url, headers={'User-Agent': USER_AGENT}, timeout=UPSTREAM_TIMEOUT
)
if resp.status_code == 200 and resp.text.strip():
logger.info(f'[ext:{name}] 拉取成功 ({len(resp.text)} bytes, via {urlparse(url).hostname})')
return resp.text
logger.warning(f'[ext:{name}] 拉取失败: HTTP {resp.status_code} (via {urlparse(url).hostname})')
return None
def fetch_upstream(session, upstream):
"""
拉取单个上游订阅内容。
主 URL: 60s 超时 + 3 次重试 (间隔 5s);全部失败后依次尝试镜像 (各 1 次)。
任何成功即返回,彻底失败返回 None,不阻断主流程 (失败隔离)。
"""
name = upstream.get('name', 'unknown')
url = upstream['url']
if not is_safe_url(url):
logger.warning(f'[ext:{name}] URL 未通过安全校验 (SSRF 防护),已跳过')
return None
candidates = _upstream_fetch_candidates(url)
# 1. 主 URL: 完整重试
for attempt in range(1, UPSTREAM_RETRIES + 1):
try:
text = _fetch_upstream_once(session, name, candidates[0])
if text:
return text
except requests.Timeout:
logger.warning(
f'[ext:{name}] 第 {attempt}/{UPSTREAM_RETRIES} 次请求超时 '
f'({UPSTREAM_TIMEOUT}s)'
)
except Exception as e:
logger.warning(
f'[ext:{name}] 第 {attempt}/{UPSTREAM_RETRIES} 次异常: '
f'{type(e).__name__}: {e}'
)
if attempt < UPSTREAM_RETRIES:
time.sleep(UPSTREAM_RETRY_DELAY)
# 2. 镜像回退: 各尝试 1 次
for mirror_url in candidates[1:]:
try:
text = _fetch_upstream_once(session, name, mirror_url)
if text:
return text
except Exception as e:
logger.warning(
f'[ext:{name}] 镜像拉取异常: {urlparse(mirror_url).hostname}: '
f'{type(e).__name__}'
)
logger.error(f'[ext:{name}] 主 URL + {len(candidates) - 1} 个镜像均拉取失败,已跳过')
return None
def fetch_all_upstreams(session, upstreams):
"""并发拉取所有上游订阅。返回 {name: 订阅文本},仅含成功的上游。"""
results = {}
if not upstreams:
return results
with ThreadPoolExecutor(max_workers=UPSTREAM_THREADS) as executor:
futures = {executor.submit(fetch_upstream, session, u): u for u in upstreams}
for future in as_completed(futures):
upstream = futures[future]
text = future.result()
if text:
results[upstream['name']] = text
logger.info(f'上游拉取完成: 成功 {len(results)}/{len(upstreams)}')
return results
def rename_uri_node(uri, prefix):
"""
给分享链接的节点显示名加来源前缀,返回修改后的 URI。
vmess 的名称在 Base64 JSON 的 ps 字段中;其余协议名称在 #fragment 中。
解析失败时原样返回,不中断流程。
"""
try:
scheme, body = uri.split('://', 1)
scheme = scheme.lower()
if scheme == 'vmess':
padded = body + '=' * (-len(body) % 4)
info = json.loads(base64.b64decode(padded).decode('utf-8'))
info['ps'] = f"{prefix} {info.get('ps', '')}".strip()
payload = base64.b64encode(
json.dumps(info, ensure_ascii=False).encode('utf-8')
).decode('utf-8')
return f'vmess://{payload}'
if '#' in body:
body, fragment = body.rsplit('#', 1)
else:
fragment = ''
name = unquote(fragment).strip() or 'node'
return f"{scheme}://{body}#{quote(f'{prefix} {name}'.strip(), safe='[]: ')}".replace(' ', '%20')
except Exception:
return uri
def filter_clash_proxies(proxies, prefix):
"""
过滤上游 Clash YAML 中的代理节点:
1. 类型白名单 (CLASH_PROXY_TYPES)
2. server 为内网/保留地址时剔除 (SSRF 防护)
3. 节点名加 [ext:来源] 前缀
"""
valid = []
for proxy in proxies:
if not isinstance(proxy, dict):
continue
ptype = str(proxy.get('type', '')).lower()
server = str(proxy.get('server', '')).strip()
if ptype not in CLASH_PROXY_TYPES or not server:
continue
try:
ip = ipaddress.ip_address(server)
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
continue
except ValueError:
pass # 域名节点放行,由客户端解析
proxy['name'] = f"{prefix} {proxy.get('name', 'node')}".strip()
valid.append(proxy)
return valid
def parse_upstream_text(text, upstream):
"""
解析上游订阅文本为统一节点集合。
自动识别: Clash YAML (proxies) / 整体 Base64 / 明文分享链接列表。
P5 (v1.9.0): 集成无效地址过滤 (T5.1) + 地区识别增强 (T5.3)。
返回: (uris: list[str], clash_proxies: list[dict])
"""
name = upstream.get('name', 'unknown')
max_nodes = int(upstream.get('max_nodes', 0) or 0)
prefix = f'[ext:{name}]'
uris, clash_proxies = [], []
stripped = text.strip()
if 'proxies:' in stripped[:2000]:
# Clash YAML 订阅
try:
# P17 (v2.9.0): 容错加载, 兼容上游含 !<str> tag 的 Clash 配置
data = yaml.load(stripped, Loader=_LenientYAMLLoader)
proxies = (data or {}).get('proxies') or []
clash_proxies = filter_clash_proxies(proxies, prefix)
# T5.3: Clash 代理地区识别增强 (为无地区标识节点添加地区前缀)
clash_proxies = _enhance_clash_proxies_region(clash_proxies)
logger.info(
f'[ext:{name}] Clash YAML 解析: {len(proxies)} 个代理, '
f'过滤后 {len(clash_proxies)}'
)
except Exception as e:
logger.warning(f'[ext:{name}] Clash YAML 解析失败: {e}')
else:
lines = stripped.splitlines()
# 尝试整体 Base64 解码 (v2ray 标准订阅格式)
try:
padded = stripped + '=' * (-len(stripped) % 4)
decoded = base64.b64decode(padded).decode('utf-8', errors='ignore')
if any(l.strip().startswith(UPSTREAM_PROTOCOL_PREFIXES)
for l in decoded.splitlines()):
lines = decoded.splitlines()
except Exception:
pass
# P19.1 (v2.11.1): 逐行 Base64 解码回退 (兼容每行独立 base64 编码的订阅,
# 如 getNode v2ray.txt: 每行 dm1lc3M6Ly9... = base64("vmess://..."))
if not any(l.strip().startswith(UPSTREAM_PROTOCOL_PREFIXES) for l in lines):
_dec_lines = []
for _l in lines:
_s = _l.strip()
if not _s:
continue
try:
_pad = '=' * (-len(_s) % 4)
_dec = base64.b64decode(_s + _pad).decode('utf-8', errors='ignore').strip()
if _dec.startswith(UPSTREAM_PROTOCOL_PREFIXES):
_dec_lines.append(_dec)
except Exception:
pass
if _dec_lines:
logger.info(f'[ext:{name}] 逐行 Base64 解码: 识别 {len(_dec_lines)} 个节点')
lines = _dec_lines
# 明文分享链接列表 (含 Base64 解码结果)
raw_uris = [
l.strip() for l in lines
if l.strip().startswith(UPSTREAM_PROTOCOL_PREFIXES)
]
# T5.1: URI 节点无效地址过滤 (剔除 127.0.0.x/10.x/192.168.x 等本地保留地址)
valid_uris = []
invalid_count = 0
for u in raw_uris:
host, port = extract_host_port(u)
if is_invalid_node_host(host):
invalid_count += 1
continue
valid_uris.append(u)
if invalid_count:
logger.info(f'[ext:{name}] T5.1 无效地址过滤: 剔除 {invalid_count} 个本地/保留地址节点')
# 重命名 + T5.3 地区识别增强
renamed_uris = [rename_uri_node(u, prefix) for u in valid_uris]
uris = _enhance_uris_region(renamed_uris)
logger.info(f'[ext:{name}] 分享链接解析: {len(uris)} 个节点 '
f'(原始 {len(raw_uris)}, 无效过滤 {invalid_count})')
# T1.4: 截断逻辑已移至 generate_merged_format (测速排序后按延迟截断)
# 此处返回全部解析节点, 由上层统一做「单源 max_nodes + 总量 MERGED_MAX_NODES」两层截断
return uris, clash_proxies
def _get_uri_name(uri):
"""从 URI 中提取节点显示名 (fragment 部分), 失败返回空字符串。"""
try:
if '#' in uri:
_, fragment = uri.rsplit('#', 1)
return unquote(fragment).strip()
except Exception:
pass
return ''
def _set_uri_name(uri, new_name):
"""设置 URI 的节点显示名 (fragment 部分), 返回修改后的 URI。"""
try:
if '#' in uri:
base, _ = uri.rsplit('#', 1)
else:
base = uri
encoded_name = quote(new_name, safe='[]: ')
return f'{base}#{encoded_name}'
except Exception:
return uri
def _enhance_uris_region(uris):
"""
P5 (v1.9.0, T5.3): 为 URI 节点添加地区前缀 (轻量方案)。
仅对未包含地区标识的节点, 基于服务器名识别地区并添加前缀。
返回增强后的 URI 列表。
"""
enhanced = []
added_count = 0
for uri in uris:
name = _get_uri_name(uri)
# 已包含地区标识则跳过
if name and REGION_ALREADY_MARKED.search(name):
enhanced.append(uri)
continue
# 基于服务器名识别地区
host, _ = extract_host_port(uri)
region_name, region_code = detect_region(host or '')
if region_name and name:
new_name = f'[{region_code}] {name}'
enhanced.append(_set_uri_name(uri, new_name))
added_count += 1
else:
enhanced.append(uri)
if added_count:
logger.debug(f'[T5.3] URI 地区识别: 为 {added_count} 个节点添加地区前缀')
return enhanced
def _enhance_clash_proxies_region(proxies):
"""
P5 (v1.9.0, T5.3): 为 Clash 代理节点添加地区前缀 (轻量方案)。
仅对未包含地区标识的节点, 基于 server 识别地区并添加前缀。
返回增强后的 proxy 列表。
"""
enhanced = []
added_count = 0
for proxy in proxies:
name = str(proxy.get('name', ''))
# 已包含地区标识则跳过
if name and REGION_ALREADY_MARKED.search(name):
enhanced.append(proxy)
continue
# 基于 server 识别地区
server = str(proxy.get('server', ''))
region_name, region_code = detect_region(server)
if region_name and name:
proxy['name'] = f'[{region_code}] {name}'
added_count += 1
enhanced.append(proxy)
if added_count:
logger.debug(f'[T5.3] Clash 代理地区识别: 为 {added_count} 个节点添加地区前缀')
return enhanced
# ============================================================
# 节点延迟测速 (P1 v1.5.0)
# 对融合节点做 TCP connect 计时,记录每节点延迟 (ms)。
# 协议无关: vmess/ss/ssr/vless/trojan/hysteria2/tuic 均通过 TCP 握手测速。
# ============================================================
def _strip_ipv6_brackets(host):
"""去掉 IPv6 地址的方括号 (如 [::1] → ::1),非 IPv6 原样返回。"""
if host and host.startswith('[') and host.endswith(']'):
return host[1:-1]
return host
def extract_host_port(uri):
"""
从分享链接 URI 中提取 host 和 port。
支持: vmess (Base64 JSON) / ss / ssr / vless / trojan / hysteria2 / tuic
返回: (host, port) 或 (None, None)
"""
try:
scheme, body = uri.split('://', 1)
scheme = scheme.lower()
# --- vmess: Base64 JSON, 含 add/port 字段 ---
if scheme == 'vmess':
padded = body + '=' * (-len(body) % 4)
info = json.loads(base64.b64decode(padded).decode('utf-8'))
host = str(info.get('add', '')).strip()
port = int(info.get('port', 0) or 0)
if host and port > 0:
return _strip_ipv6_brackets(host), port
return None, None
# --- ssr: base64(host:port:protocol:method:obfs:base64pass/?params) ---
if scheme == 'ssr':
padded = body + '=' * (-len(body) % 4)
decoded = base64.b64decode(padded).decode('utf-8', errors='ignore')
main = decoded.split('/?')[0]
parts = main.split(':')
if len(parts) >= 2:
host = parts[0]
port = int(parts[1])
if host and port > 0:
return _strip_ipv6_brackets(host), port
return None, None
# --- ss (Shadowsocks): SIP002 或 legacy ---
if scheme == 'ss':
# 去掉 fragment
body_no_frag = body.split('#', 1)[0]
if '@' in body_no_frag:
# SIP002: ss://base64(method:password)@host:port[#name]
_, hostport = body_no_frag.rsplit('@', 1)
hostport = hostport.split('?')[0]
if ':' in hostport:
host, port_str = hostport.rsplit(':', 1)