Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
2045 lines (1935 loc) · 89.6 KB
/
Copy pathmain.py
File metadata and controls
2045 lines (1935 loc) · 89.6 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
"""
Cloudflare IP 优选工具 (TCP筛选 + IP可用性二次筛选 + HTTP检测 + curl带宽测速 + WxPusher通知)
依赖:requests, curl, aiohttp
配置文件:同目录下的 config.json
结果保存到 ProxyIP_A.txt,并自动推送到 GitHub,同时批量更新到 Cloudflare DNS
支持 Windows / Linux
"""
import requests
import socket
import time
import sys
import re
import os
import subprocess
import shutil
import json
import asyncio
import aiohttp
import ipaddress
import warnings
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse
from urllib3.exceptions import InsecureRequestWarning
from requests.adapters import HTTPAdapter
from urllib3.connection import HTTPSConnection
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
warnings.filterwarnings("ignore", message="Unverified HTTPS request")
_original_session_request = requests.Session.request
def _new_session_request(self, method, url, *args, **kwargs):
kwargs.setdefault("verify", False)
return _original_session_request(self, method, url, *args, **kwargs)
requests.Session.request = _new_session_request
_IS_TTY = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
_last_progress_len = 0
def log(level, msg, end="\n"):
global _last_progress_len
line = f"[{time.strftime('%H:%M:%S')}] [{level}] {msg}"
if end == "\n" and level != "进度":
print(line)
elif end == "\n" and level == "进度":
if _IS_TTY:
pad = max(0, _last_progress_len - len(line))
print(f"\r{line}{' ' * pad}")
else:
print(line)
_last_progress_len = 0
elif _IS_TTY:
pad = max(0, _last_progress_len - len(line))
print(f"\r{line}{' ' * pad}", end="", flush=True)
_last_progress_len = len(line)
NODE_PATTERN = re.compile(r"^(\d+\.\d+\.\d+\.\d+):(\d+)#(.+)$")
IP_PORT_PATTERN = re.compile(r"^(\d+\.\d+\.\d+\.\d+):(\d+)#")
CN_TO_CODE = {
"阿富汗": "AF", "奥兰群岛": "AX", "阿尔巴尼亚": "AL", "阿尔及利亚": "DZ",
"美属萨摩亚": "AS", "安道尔": "AD", "安哥拉": "AO", "安圭拉": "AI",
"南极洲": "AQ", "安提瓜和巴布达": "AG", "阿根廷": "AR", "亚美尼亚": "AM",
"阿鲁巴": "AW", "澳大利亚": "AU", "奥地利": "AT", "阿塞拜疆": "AZ",
"巴哈马": "BS", "巴林": "BH", "孟加拉国": "BD", "孟加拉": "BD",
"巴巴多斯": "BB", "白俄罗斯": "BY", "比利时": "BE", "伯利兹": "BZ",
"贝宁": "BJ", "百慕大": "BM", "不丹": "BT", "玻利维亚": "BO",
"波黑": "BA", "波斯尼亚和黑塞哥维那": "BA", "博茨瓦纳": "BW",
"布维岛": "BV", "巴西": "BR", "英属印度洋领地": "IO",
"文莱": "BN", "保加利亚": "BG", "布基纳法索": "BF", "布隆迪": "BI",
"柬埔寨": "KH", "喀麦隆": "CM", "加拿大": "CA", "佛得角": "CV",
"开曼群岛": "KY", "中非": "CF", "乍得": "TD", "智利": "CL",
"中国": "CN", "圣诞岛": "CX", "科科斯(基林)群岛": "CC",
"哥伦比亚": "CO", "科摩罗": "KM", "刚果(布)": "CG", "刚果(布)": "CG",
"刚果(金)": "CD", "刚果(金)": "CD", "库克群岛": "CK",
"哥斯达黎加": "CR", "科特迪瓦": "CI", "克罗地亚": "HR", "古巴": "CU",
"塞浦路斯": "CY", "捷克": "CZ", "丹麦": "DK", "吉布提": "DJ",
"多米尼克": "DM", "多米尼加": "DO", "厄瓜多尔": "EC", "埃及": "EG",
"萨尔瓦多": "SV", "赤道几内亚": "GQ", "厄立特里亚": "ER",
"爱沙尼亚": "EE", "埃塞俄比亚": "ET", "福克兰群岛(马尔维纳斯)": "FK",
"法罗群岛": "FO", "斐济": "FJ", "芬兰": "FI", "法国": "FR",
"法属圭亚那": "GF", "法属波利尼西亚": "PF", "法属南部领地": "TF",
"加蓬": "GA", "冈比亚": "GM", "格鲁吉亚": "GE", "德国": "DE",
"加纳": "GH", "直布罗陀": "GI", "希腊": "GR", "格陵兰": "GL",
"格林纳达": "GD", "瓜德罗普": "GP", "关岛": "GU", "危地马拉": "GT",
"根西岛": "GG", "几内亚": "GN", "几内亚比绍": "GW", "圭亚那": "GY",
"海地": "HT", "赫德岛和麦克唐纳群岛": "HM", "梵蒂冈": "VA",
"洪都拉斯": "HN", "香港": "HK", "匈牙利": "HU", "冰岛": "IS",
"印度": "IN", "印度尼西亚": "ID", "伊朗": "IR", "伊拉克": "IQ",
"爱尔兰": "IE", "马恩岛": "IM", "以色列": "IL", "意大利": "IT",
"牙买加": "JM", "日本": "JP", "泽西岛": "JE", "约旦": "JO",
"哈萨克斯坦": "KZ", "肯尼亚": "KE", "基里巴斯": "KI", "朝鲜": "KP",
"韩国": "KR", "科威特": "KW", "吉尔吉斯斯坦": "KG", "老挝": "LA",
"拉脱维亚": "LV", "黎巴嫩": "LB", "莱索托": "LS", "利比里亚": "LR",
"利比亚": "LY", "列支敦士登": "LI", "立陶宛": "LT", "卢森堡": "LU",
"澳门": "MO", "北马其顿": "MK", "马其顿": "MK", "马达加斯加": "MG",
"马拉维": "MW", "马来西亚": "MY", "马尔代夫": "MV", "马里": "ML",
"马耳他": "MT", "马绍尔群岛": "MH", "马提尼克": "MQ",
"毛里塔尼亚": "MR", "毛里求斯": "MU", "马约特": "YT", "墨西哥": "MX",
"密克罗尼西亚": "FM", "摩尔多瓦": "MD", "摩纳哥": "MC", "蒙古": "MN",
"黑山": "ME", "蒙特塞拉特": "MS", "摩洛哥": "MA", "莫桑比克": "MZ",
"缅甸": "MM", "纳米比亚": "NA", "瑙鲁": "NR", "尼泊尔": "NP",
"荷兰": "NL", "新喀里多尼亚": "NC", "新西兰": "NZ", "尼加拉瓜": "NI",
"尼日尔": "NE", "尼日利亚": "NG", "纽埃": "NU", "诺福克岛": "NF",
"北马里亚纳群岛": "MP", "挪威": "NO", "阿曼": "OM", "巴基斯坦": "PK",
"帕劳": "PW", "巴勒斯坦": "PS", "巴拿马": "PA", "巴布亚新几内亚": "PG",
"巴拉圭": "PY", "秘鲁": "PE", "菲律宾": "PH", "皮特凯恩": "PN",
"波兰": "PL", "葡萄牙": "PT", "波多黎各": "PR", "卡塔尔": "QA",
"留尼汪": "RE", "罗马尼亚": "RO", "俄罗斯": "RU", "卢旺达": "RW",
"圣巴泰勒米": "BL", "圣赫勒拿": "SH", "圣基茨和尼维斯": "KN",
"圣卢西亚": "LC", "圣马丁": "MF", "圣皮埃尔和密克隆": "PM",
"圣文森特和格林纳丁斯": "VC", "萨摩亚": "WS", "圣马力诺": "SM",
"圣多美和普林西比": "ST", "沙特阿拉伯": "SA", "沙特": "SA",
"塞内加尔": "SN", "塞尔维亚": "RS", "塞舌尔": "SC", "塞拉利昂": "SL",
"新加坡": "SG", "圣马丁(荷兰)": "SX", "斯洛伐克": "SK",
"斯洛文尼亚": "SI", "所罗门群岛": "SB", "索马里": "SO", "南非": "ZA",
"南乔治亚和南桑威奇群岛": "GS", "南苏丹": "SS", "西班牙": "ES",
"斯里兰卡": "LK", "苏丹": "SD", "苏里南": "SR", "斯瓦尔巴和扬马延": "SJ",
"斯威士兰": "SZ", "瑞典": "SE", "瑞士": "CH", "叙利亚": "SY",
"台湾": "TW", "塔吉克斯坦": "TJ", "坦桑尼亚": "TZ", "泰国": "TH",
"东帝汶": "TL", "多哥": "TG", "托克劳": "TK", "汤加": "TO",
"特立尼达和多巴哥": "TT", "突尼斯": "TN", "土耳其": "TR",
"土库曼斯坦": "TM", "特克斯和凯科斯群岛": "TC", "图瓦卢": "TV",
"乌干达": "UG", "乌克兰": "UA", "阿联酋": "AE", "英国": "GB",
"美国": "US", "美国本土外小岛屿": "UM", "乌拉圭": "UY",
"乌兹别克斯坦": "UZ", "瓦努阿图": "VU", "委内瑞拉": "VE",
"越南": "VN", "英属维尔京群岛": "VG", "美属维尔京群岛": "VI",
"瓦利斯和富图纳": "WF", "西撒哈拉": "EH", "也门": "YE",
"赞比亚": "ZM", "津巴布韦": "ZW",
}
ALPHA3_TO_ALPHA2 = {
"AFG": "AF", "ALA": "AX", "ALB": "AL", "DZA": "DZ", "ASM": "AS",
"AND": "AD", "AGO": "AO", "AIA": "AI", "ATA": "AQ", "ATG": "AG",
"ARG": "AR", "ARM": "AM", "ABW": "AW", "AUS": "AU", "AUT": "AT",
"AZE": "AZ", "BHS": "BS", "BHR": "BH", "BGD": "BD", "BRB": "BB",
"BLR": "BY", "BEL": "BE", "BLZ": "BZ", "BEN": "BJ", "BMU": "BM",
"BTN": "BT", "BOL": "BO", "BIH": "BA", "BWA": "BW", "BVT": "BV",
"BRA": "BR", "IOT": "IO", "BRN": "BN", "BGR": "BG", "BFA": "BF",
"BDI": "BI", "KHM": "KH", "CMR": "CM", "CAN": "CA", "CPV": "CV",
"CYM": "KY", "CAF": "CF", "TCD": "TD", "CHL": "CL", "CHN": "CN",
"CXR": "CX", "CCK": "CC", "COL": "CO", "COM": "KM", "COG": "CG",
"COD": "CD", "COK": "CK", "CRI": "CR", "CIV": "CI", "HRV": "HR",
"CUB": "CU", "CYP": "CY", "CZE": "CZ", "DNK": "DK", "DJI": "DJ",
"DMA": "DM", "DOM": "DO", "ECU": "EC", "EGY": "EG", "SLV": "SV",
"GNQ": "GQ", "ERI": "ER", "EST": "EE", "ETH": "ET", "FLK": "FK",
"FRO": "FO", "FJI": "FJ", "FIN": "FI", "FRA": "FR", "GUF": "GF",
"PYF": "PF", "ATF": "TF", "GAB": "GA", "GMB": "GM", "GEO": "GE",
"DEU": "DE", "GHA": "GH", "GIB": "GI", "GRC": "GR", "GRL": "GL",
"GRD": "GD", "GLP": "GP", "GUM": "GU", "GTM": "GT", "GGY": "GG",
"GIN": "GN", "GNB": "GW", "GUY": "GY", "HTI": "HT", "HMD": "HM",
"VAT": "VA", "HND": "HN", "HKG": "HK", "HUN": "HU", "ISL": "IS",
"IND": "IN", "IDN": "ID", "IRN": "IR", "IRQ": "IQ", "IRL": "IE",
"IMN": "IM", "ISR": "IL", "ITA": "IT", "JAM": "JM", "JPN": "JP",
"JEY": "JE", "JOR": "JO", "KAZ": "KZ", "KEN": "KE", "KIR": "KI",
"PRK": "KP", "KOR": "KR", "KWT": "KW", "KGZ": "KG", "LAO": "LA",
"LVA": "LV", "LBN": "LB", "LSO": "LS", "LBR": "LR", "LBY": "LY",
"LIE": "LI", "LTU": "LT", "LUX": "LU", "MAC": "MO", "MKD": "MK",
"MDG": "MG", "MWI": "MW", "MYS": "MY", "MDV": "MV", "MLI": "ML",
"MLT": "MT", "MHL": "MH", "MTQ": "MQ", "MRT": "MR", "MUS": "MU",
"MYT": "YT", "MEX": "MX", "FSM": "FM", "MDA": "MD", "MCO": "MC",
"MNG": "MN", "MNE": "ME", "MSR": "MS", "MAR": "MA", "MOZ": "MZ",
"MMR": "MM", "NAM": "NA", "NRU": "NR", "NPL": "NP", "NLD": "NL",
"NCL": "NC", "NZL": "NZ", "NIC": "NI", "NER": "NE", "NGA": "NG",
"NIU": "NU", "NFK": "NF", "MNP": "MP", "NOR": "NO", "OMN": "OM",
"PAK": "PK", "PLW": "PW", "PSE": "PS", "PAN": "PA", "PNG": "PG",
"PRY": "PY", "PER": "PE", "PHL": "PH", "PCN": "PN", "POL": "PL",
"PRT": "PT", "PRI": "PR", "QAT": "QA", "REU": "RE", "ROU": "RO",
"RUS": "RU", "RWA": "RW", "BLM": "BL", "SHN": "SH", "KNA": "KN",
"LCA": "LC", "MAF": "MF", "SPM": "PM", "VCT": "VC", "WSM": "WS",
"SMR": "SM", "STP": "ST", "SAU": "SA", "SEN": "SN", "SRB": "RS",
"SYC": "SC", "SLE": "SL", "SGP": "SG", "SXM": "SX", "SVK": "SK",
"SVN": "SI", "SLB": "SB", "SOM": "SO", "ZAF": "ZA", "SGS": "GS",
"SSD": "SS", "ESP": "ES", "LKA": "LK", "SDN": "SD", "SUR": "SR",
"SJM": "SJ", "SWZ": "SZ", "SWE": "SE", "CHE": "CH", "SYR": "SY",
"TWN": "TW", "TJK": "TJ", "TZA": "TZ", "THA": "TH", "TLS": "TL",
"TGO": "TG", "TKL": "TK", "TON": "TO", "TTO": "TT", "TUN": "TN",
"TUR": "TR", "TKM": "TM", "TCA": "TC", "TUV": "TV", "UGA": "UG",
"UKR": "UA", "ARE": "AE", "GBR": "GB", "USA": "US", "UMI": "UM",
"URY": "UY", "UZB": "UZ", "VUT": "VU", "VEN": "VE", "VNM": "VN",
"VGB": "VG", "VIR": "VI", "WLF": "WF", "ESH": "EH", "YEM": "YE",
"ZMB": "ZM", "ZWE": "ZW",
}
CODE_SET = set(CN_TO_CODE.values())
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"
def load_config():
defaults = {
"USE_GLOBAL_MODE": True,
"GLOBAL_TOP_N": 15,
"PER_COUNTRY_TOP_N": 1,
"BANDWIDTH_CANDIDATES": 300,
"TCP_PROBES": 1,
"MIN_SUCCESS_RATE": 1.0,
"TCP_LATENCY_WEIGHT": 0.0,
"TIMEOUT": 3.0,
"SOCKET_DEFAULT_TIMEOUT": 3,
"PROGRESS_PRINT_INTERVAL": 1,
"FILTER_COUNTRIES_ENABLED": False,
"ALLOWED_COUNTRIES": ["HK"],
"PRE_FILTER_BLOCKED_ENABLED": True,
"PRE_FILTER_BLOCKED_COUNTRIES": ["CN"],
"PRE_FILTER_PORT_ENABLED": True,
"PRE_FILTER_PORTS": [443],
"ENABLE_TG": True,
"TG_BOT_TOKEN": "",
"TG_CHAT_ID": "",
"TG_API_URL": "https://api.telegram.org",
"NOTIFY_TIMEOUT": 3,
"NOTIFY_CONNECT_TIMEOUT": 3,
"CF_ENABLED": True,
"CF_API_TOKEN": "your_CF_API_TOKEN",
"CF_ZONE_ID": "your_CF_ZONE_ID",
"CF_DNS_RECORD_NAME": "your_CF_DNS_RECORD_NAME",
"CF_TTL": 60,
"CF_PROXIED": False,
"CF_DNS_CONNECT_TIMEOUT": 3,
"CF_DNS_READ_TIMEOUT": 3,
"DNS_RECORD_TYPE": "TXT",
"ADDITIONAL_SOURCES": [
{"url": "https://zip.cm.edu.kg/all.txt", "enabled": True},
{"url": "https://countrymerge.pages.dev/all.txt", "enabled": True}
],
"FETCH_MAX_RETRIES": 3,
"FETCH_RETRY_DELAY": 3,
"FETCH_TIMEOUT": 3,
"FETCH_CONNECT_TIMEOUT": 3,
"IP_CALIBRATION_ENABLED": False,
"TOKEN_FAILURE_THRESHOLD": 3,
"IP_CALIBRATION_MIN_INTERVAL": 0.1,
"IP_CALIBRATION_REQUEST_TIMEOUT": 3,
"IP_CALIBRATION_TOKEN_FILE": "valid_tokens.txt",
"IP_CALIBRATION_CACHE_FILE": "ipinfo_cache.txt",
"OUTPUT_FILE": "ProxyIP_A.txt",
"ENABLE_LOGGING": False,
"LOG_FILE": "cfnb.log",
"FORCE_DIRECT": False,
"USER_AGENT": DEFAULT_UA,
"TEST_AVAILABILITY": True,
"PROBE_HOST": "cloudflare-ech.com",
"PROBE_PATH": "/cdn-cgi/trace",
"AVAILABILITY_TIMEOUT": 3,
"AVAILABILITY_CONNECT_TIMEOUT": 3,
"AVAILABILITY_RETRY_MAX": 3,
"AVAILABILITY_RETRY_DELAY": 3,
"AVAILABILITY_INNER_RETRY_ENABLED": True,
"AVAILABILITY_INNER_RETRY_MAX": 3,
"AVAILABILITY_INNER_RETRY_DELAY": 3,
"AVAILABILITY_TEST_ROUNDS": 3,
"HTTP_LATENCY_ENABLED": False,
"HTTP_LATENCY_WEIGHT": 3.0,
"JITTER_WEIGHT": 3.0,
"OUTPUT_FILTER_COUNTRIES_ENABLED": False,
"OUTPUT_ALLOWED_COUNTRIES": ["HK"],
"FILTER_IPV6_AVAILABILITY": True,
"FILTER_BLOCKED_COUNTRIES_ENABLED": True,
"BLOCKED_COUNTRIES": [
"BD", "BI", "BY", "CD", "CF", "CN", "CU", "DE", "ET", "HK",
"IR", "KP", "LY", "MO", "NG", "NL", "PK", "RU", "SD", "SO",
"SY", "TH", "TW", "UA", "VE", "VN", "YE", "ZW"
],
"DNS_IP_RISK_FILTER_ENABLED": False,
"DNS_IP_RISK_MAX_LEVEL": "高风险",
"IP_RISK_TIMEOUT": 3,
"DNS_UPDATE_TARGET_COUNT": 15,
"BANDWIDTH_SIZE_MB": 1.0,
"BANDWIDTH_TIMEOUT": 3,
"BANDWIDTH_RETRY_MAX": 3,
"BANDWIDTH_RETRY_DELAY": 3,
"BANDWIDTH_URL_TEMPLATE": "{scheme}://speed.cloudflare.com:{port}/__down?bytes={bytes}",
"BANDWIDTH_PROCESS_BUFFER": 3,
"BANDWIDTH_CONNECT_TIMEOUT": 3,
"SPEED_WEIGHT": 3.0,
"IP_CALIBRATION_CONCURRENCY": 300,
"MAX_WORKERS": 300,
"AVAILABILITY_WORKERS": 300,
"FALLBACK_WORKERS": 300,
"BANDWIDTH_WORKERS": 3,
"IP_RISK_WORKERS": 300,
"DNS_UPDATE_MAX_RETRIES": 3,
"DNS_UPDATE_RETRY_DELAY": 3,
"GITHUB_SYNC_MAX_RETRIES": 3,
"GITHUB_SYNC_RETRY_DELAY": 3,
"GIT_SYNC_PROCESS_TIMEOUT": 180,
"AD_HEADER_ENABLED": False,
"AD_HEADER_LINES": [],
"AD_FOOTER_ENABLED": False,
"AD_FOOTER_LINES": [],
"AD_PERLINE_ENABLED": False,
"AD_PERLINE_TEXT": "",
"IP_TXT_SHOW_BANDWIDTH": False,
"IP_TXT_SHOW_HTTP_LATENCY": False,
"IP_TXT_SHOW_HTTP_JITTER": False,
"IP_TXT_SHOW_LATENCY": False,
}
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
except FileNotFoundError:
log("警告", f"未找到配置文件 {CONFIG_FILE},使用内置默认配置")
return defaults
except json.JSONDecodeError as e:
log("错误", f"配置文件格式不正确 - {e}")
sys.exit(1)
missing = [k for k in defaults if k not in config]
if missing:
for k in missing:
config[k] = defaults[k]
log("警告", f"配置缺失 {len(missing)} 项:{', '.join(missing)}")
return config
cfg = load_config()
USE_GLOBAL_MODE = cfg["USE_GLOBAL_MODE"]
GLOBAL_TOP_N = cfg["GLOBAL_TOP_N"]
PER_COUNTRY_TOP_N = cfg["PER_COUNTRY_TOP_N"]
BANDWIDTH_CANDIDATES = cfg["BANDWIDTH_CANDIDATES"]
TCP_PROBES = cfg["TCP_PROBES"]
MIN_SUCCESS_RATE = cfg["MIN_SUCCESS_RATE"]
TCP_LATENCY_WEIGHT = cfg["TCP_LATENCY_WEIGHT"]
TIMEOUT = cfg["TIMEOUT"]
SOCKET_DEFAULT_TIMEOUT = cfg["SOCKET_DEFAULT_TIMEOUT"]
PROGRESS_PRINT_INTERVAL = cfg["PROGRESS_PRINT_INTERVAL"]
FILTER_COUNTRIES_ENABLED = cfg["FILTER_COUNTRIES_ENABLED"]
ALLOWED_COUNTRIES = cfg["ALLOWED_COUNTRIES"]
PRE_FILTER_BLOCKED_ENABLED = cfg["PRE_FILTER_BLOCKED_ENABLED"]
PRE_FILTER_BLOCKED_COUNTRIES = [c.upper() for c in cfg["PRE_FILTER_BLOCKED_COUNTRIES"]]
PRE_FILTER_PORT_ENABLED = cfg["PRE_FILTER_PORT_ENABLED"]
PRE_FILTER_PORTS = [str(p) for p in cfg["PRE_FILTER_PORTS"]]
ENABLE_TG = cfg.get("ENABLE_TG", True)
TG_BOT_TOKEN = cfg.get("TG_BOT_TOKEN", "")
TG_CHAT_ID = cfg.get("TG_CHAT_ID", "")
TG_API_URL = cfg.get("TG_API_URL", "https://api.telegram.org").rstrip("/")
NOTIFY_TIMEOUT = cfg.get("NOTIFY_TIMEOUT", 3)
NOTIFY_CONNECT_TIMEOUT = cfg.get("NOTIFY_CONNECT_TIMEOUT", 3)
CF_ENABLED = cfg["CF_ENABLED"]
CF_API_TOKEN = cfg["CF_API_TOKEN"]
CF_ZONE_ID = cfg["CF_ZONE_ID"]
CF_DNS_RECORD_NAME = cfg["CF_DNS_RECORD_NAME"]
CF_TTL = cfg["CF_TTL"]
CF_PROXIED = cfg["CF_PROXIED"]
CF_DNS_CONNECT_TIMEOUT = cfg["CF_DNS_CONNECT_TIMEOUT"]
CF_DNS_READ_TIMEOUT = cfg["CF_DNS_READ_TIMEOUT"]
DNS_RECORD_TYPE = cfg["DNS_RECORD_TYPE"]
ADDITIONAL_SOURCES = cfg["ADDITIONAL_SOURCES"]
FETCH_MAX_RETRIES = cfg["FETCH_MAX_RETRIES"]
FETCH_RETRY_DELAY = cfg["FETCH_RETRY_DELAY"]
FETCH_TIMEOUT = cfg["FETCH_TIMEOUT"]
FETCH_CONNECT_TIMEOUT = cfg["FETCH_CONNECT_TIMEOUT"]
IP_CALIBRATION_ENABLED = cfg["IP_CALIBRATION_ENABLED"]
TOKEN_FAILURE_THRESHOLD = cfg["TOKEN_FAILURE_THRESHOLD"]
IP_CALIBRATION_MIN_INTERVAL = cfg["IP_CALIBRATION_MIN_INTERVAL"]
IP_CALIBRATION_REQUEST_TIMEOUT = cfg["IP_CALIBRATION_REQUEST_TIMEOUT"]
IP_CALIBRATION_TOKEN_FILE = cfg["IP_CALIBRATION_TOKEN_FILE"]
IP_CALIBRATION_CACHE_FILE = cfg["IP_CALIBRATION_CACHE_FILE"]
OUTPUT_FILE = cfg["OUTPUT_FILE"]
ENABLE_LOGGING = cfg["ENABLE_LOGGING"]
LOG_FILE = cfg["LOG_FILE"]
FORCE_DIRECT = cfg["FORCE_DIRECT"]
USER_AGENT = cfg["USER_AGENT"] or DEFAULT_UA
TEST_AVAILABILITY = cfg["TEST_AVAILABILITY"]
PROBE_HOST = cfg["PROBE_HOST"]
PROBE_PATH = cfg["PROBE_PATH"]
AVAILABILITY_TIMEOUT = cfg["AVAILABILITY_TIMEOUT"]
AVAILABILITY_CONNECT_TIMEOUT = cfg["AVAILABILITY_CONNECT_TIMEOUT"]
AVAILABILITY_RETRY_MAX = cfg["AVAILABILITY_RETRY_MAX"]
AVAILABILITY_RETRY_DELAY = cfg["AVAILABILITY_RETRY_DELAY"]
AVAILABILITY_INNER_RETRY_ENABLED = cfg["AVAILABILITY_INNER_RETRY_ENABLED"]
AVAILABILITY_INNER_RETRY_MAX = cfg["AVAILABILITY_INNER_RETRY_MAX"]
AVAILABILITY_INNER_RETRY_DELAY = cfg["AVAILABILITY_INNER_RETRY_DELAY"]
AVAILABILITY_TEST_ROUNDS = cfg["AVAILABILITY_TEST_ROUNDS"]
HTTP_LATENCY_ENABLED = cfg["HTTP_LATENCY_ENABLED"]
HTTP_LATENCY_WEIGHT = cfg["HTTP_LATENCY_WEIGHT"]
JITTER_WEIGHT = cfg["JITTER_WEIGHT"]
OUTPUT_FILTER_COUNTRIES_ENABLED = cfg["OUTPUT_FILTER_COUNTRIES_ENABLED"]
OUTPUT_ALLOWED_COUNTRIES = cfg["OUTPUT_ALLOWED_COUNTRIES"]
FILTER_IPV6_AVAILABILITY = cfg["FILTER_IPV6_AVAILABILITY"]
FILTER_BLOCKED_COUNTRIES_ENABLED = cfg["FILTER_BLOCKED_COUNTRIES_ENABLED"]
BLOCKED_COUNTRIES = cfg["BLOCKED_COUNTRIES"]
DNS_IP_RISK_FILTER_ENABLED = cfg["DNS_IP_RISK_FILTER_ENABLED"]
DNS_IP_RISK_MAX_LEVEL = cfg["DNS_IP_RISK_MAX_LEVEL"]
IP_RISK_TIMEOUT = cfg["IP_RISK_TIMEOUT"]
DNS_UPDATE_TARGET_COUNT = cfg["DNS_UPDATE_TARGET_COUNT"]
BANDWIDTH_SIZE_MB = cfg["BANDWIDTH_SIZE_MB"]
BANDWIDTH_TIMEOUT = cfg["BANDWIDTH_TIMEOUT"]
BANDWIDTH_RETRY_MAX = cfg["BANDWIDTH_RETRY_MAX"]
BANDWIDTH_RETRY_DELAY = cfg["BANDWIDTH_RETRY_DELAY"]
BANDWIDTH_URL_TEMPLATE = cfg["BANDWIDTH_URL_TEMPLATE"]
BANDWIDTH_PROCESS_BUFFER = cfg["BANDWIDTH_PROCESS_BUFFER"]
BANDWIDTH_CONNECT_TIMEOUT = cfg["BANDWIDTH_CONNECT_TIMEOUT"]
SPEED_WEIGHT = cfg["SPEED_WEIGHT"]
IP_CALIBRATION_CONCURRENCY = cfg["IP_CALIBRATION_CONCURRENCY"]
MAX_WORKERS = cfg["MAX_WORKERS"]
AVAILABILITY_WORKERS = cfg["AVAILABILITY_WORKERS"]
FALLBACK_WORKERS = cfg["FALLBACK_WORKERS"]
BANDWIDTH_WORKERS = cfg["BANDWIDTH_WORKERS"]
IP_RISK_WORKERS = cfg["IP_RISK_WORKERS"]
DNS_UPDATE_MAX_RETRIES = cfg["DNS_UPDATE_MAX_RETRIES"]
DNS_UPDATE_RETRY_DELAY = cfg["DNS_UPDATE_RETRY_DELAY"]
GITHUB_SYNC_MAX_RETRIES = cfg["GITHUB_SYNC_MAX_RETRIES"]
GITHUB_SYNC_RETRY_DELAY = cfg["GITHUB_SYNC_RETRY_DELAY"]
GIT_SYNC_PROCESS_TIMEOUT = cfg["GIT_SYNC_PROCESS_TIMEOUT"]
AD_HEADER_ENABLED = cfg["AD_HEADER_ENABLED"]
AD_HEADER_LINES = cfg["AD_HEADER_LINES"]
AD_FOOTER_ENABLED = cfg["AD_FOOTER_ENABLED"]
AD_FOOTER_LINES = cfg["AD_FOOTER_LINES"]
AD_PERLINE_ENABLED = cfg["AD_PERLINE_ENABLED"]
AD_PERLINE_TEXT = cfg["AD_PERLINE_TEXT"]
IP_TXT_SHOW_BANDWIDTH = cfg["IP_TXT_SHOW_BANDWIDTH"]
IP_TXT_SHOW_HTTP_LATENCY = cfg["IP_TXT_SHOW_HTTP_LATENCY"]
IP_TXT_SHOW_HTTP_JITTER = cfg["IP_TXT_SHOW_HTTP_JITTER"]
IP_TXT_SHOW_LATENCY = cfg["IP_TXT_SHOW_LATENCY"]
if FORCE_DIRECT:
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
os.environ.pop(key, None)
os.environ["NO_PROXY"] = "*"
socket.setdefaulttimeout(SOCKET_DEFAULT_TIMEOUT)
class _PinnedHTTPSConnection(HTTPSConnection):
pinned_ip = None
pinned_port = 443
def _new_conn(self):
from urllib3.util.connection import create_connection
timeout = self.timeout
if hasattr(timeout, "connect_timeout"):
timeout = timeout.connect_timeout
return create_connection(
(self.pinned_ip, self.pinned_port),
timeout,
getattr(self, "source_address", None),
getattr(self, "socket_options", None),
)
class PinnedAdapter(HTTPAdapter):
def __init__(self, pinned_ip, pinned_port=443, **kwargs):
self.pinned_ip = pinned_ip
self.pinned_port = pinned_port
super().__init__(**kwargs)
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
from urllib3.poolmanager import PoolManager
from urllib3.connectionpool import HTTPSConnectionPool
tag = f"{self.pinned_ip}_{self.pinned_port}".replace(".", "_").replace(":", "_")
conn_cls = type(
f"PinnedConn_{tag}",
(_PinnedHTTPSConnection,),
{"pinned_ip": self.pinned_ip, "pinned_port": self.pinned_port},
)
pool_cls = type(
f"PinnedPool_{tag}",
(HTTPSConnectionPool,),
{"ConnectionCls": conn_cls},
)
class _Manager(PoolManager):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.pool_classes_by_scheme = dict(self.pool_classes_by_scheme)
self.pool_classes_by_scheme["https"] = pool_cls
self.poolmanager = _Manager(
num_pools=connections,
maxsize=maxsize,
block=block,
**pool_kwargs,
)
def _parse_trace_text(text):
out = {}
for line in text.splitlines():
line = line.strip()
if "=" in line:
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
def _local_probe(ip, port, timeout):
s = requests.Session()
s.trust_env = False
adapter = PinnedAdapter(ip, port)
s.mount(f"https://{PROBE_HOST}", adapter)
try:
start = time.time()
r = s.get(
f"https://{PROBE_HOST}{PROBE_PATH}",
timeout=(AVAILABILITY_CONNECT_TIMEOUT, timeout),
allow_redirects=False,
headers={"User-Agent": USER_AGENT},
)
elapsed_ms = (time.time() - start) * 1000
trace = _parse_trace_text(r.text) if r.status_code == 200 else {}
return {
"status": r.status_code,
"headers": r.headers,
"trace": trace,
"ua": r.request.headers.get("User-Agent", ""),
"error": None,
"elapsed_ms": elapsed_ms,
}
except requests.exceptions.RequestException as e:
return {"status": None, "headers": {}, "trace": {}, "ua": "", "error": str(e), "elapsed_ms": None}
finally:
s.close()
_LOCK_FILE_HANDLE = None
_LOCK_FILE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".run.lock")
def acquire_single_instance():
global _LOCK_FILE_HANDLE
f = None
try:
f = open(_LOCK_FILE_PATH, "a+")
f.seek(0)
if sys.platform == "win32":
import msvcrt
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
if f is not None:
try:
f.close()
except Exception:
pass
return False
_LOCK_FILE_HANDLE = f
return True
def release_single_instance():
global _LOCK_FILE_HANDLE
if _LOCK_FILE_HANDLE is None:
return
try:
if sys.platform == "win32":
import msvcrt
_LOCK_FILE_HANDLE.seek(0)
msvcrt.locking(_LOCK_FILE_HANDLE.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(_LOCK_FILE_HANDLE.fileno(), fcntl.LOCK_UN)
except Exception:
pass
try:
_LOCK_FILE_HANDLE.close()
except Exception:
pass
_LOCK_FILE_HANDLE = None
try:
os.remove(_LOCK_FILE_PATH)
except Exception:
pass
# ====================================================
def send_tg_notification(content, summary):
if not ENABLE_TG or not TG_BOT_TOKEN or not TG_CHAT_ID:
return False
try:
tg_url = f"{TG_API_URL}/bot{TG_BOT_TOKEN}/sendMessage"
tg_text = f"*{summary}*\n\n{content}"
payload = {
"chat_id": TG_CHAT_ID,
"text": tg_text,
"parse_mode": "Markdown"
}
headers = {"Content-Type": "application/json; charset=utf-8", "User-Agent": USER_AGENT}
resp = requests.post(
tg_url,
json=payload,
headers=headers,
timeout=(NOTIFY_CONNECT_TIMEOUT, NOTIFY_TIMEOUT)
)
if resp.status_code == 200:
print("Telegram 通知已发送")
return True
else:
print(f"Telegram 通知发送失败: {resp.status_code}")
return False
except Exception as e:
print(f"Telegram 通知异常: {e}")
return False
# 这一行极其重要:做个别名映射,这样底下原本调用微信发送的代码就全都不用改了!
send_wxpusher_notification = send_tg_notification
RISK_LEVEL_ORDER = {
"极度纯净": 0,
"纯净": 1,
"轻微风险": 2,
"高风险": 3,
"极度危险": 4,
}
def get_ip_risk_level(ip):
url = f"https://api.090227.xyz/api/ipapi?ip={ip}"
try:
resp = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=IP_RISK_TIMEOUT)
resp.raise_for_status()
data = resp.json()
except Exception:
return "未知"
company_score = data.get("company", {}).get("abuser_score")
asn_score = data.get("asn", {}).get("abuser_score")
security_flags = {
"is_crawler": data.get("is_crawler", False),
"is_proxy": data.get("is_proxy", False),
"is_vpn": data.get("is_vpn", False),
"is_tor": data.get("is_tor", False),
"is_abuser": data.get("is_abuser", False),
"is_bogon": data.get("is_bogon", False),
}
def extract_score(score_str):
if not score_str:
return 0.0
match = re.match(r"([\d.]+)\s*\(([^)]+)\)", str(score_str).strip())
if match:
return float(match.group(1))
try:
return float(score_str)
except (ValueError, TypeError):
return 0.0
company = extract_score(company_score)
asn = extract_score(asn_score)
base_score = ((company + asn) / 2) * 5
risk_count = sum(1 for key in ["is_crawler", "is_proxy", "is_vpn", "is_tor", "is_abuser"]
if security_flags.get(key, False))
final_score = base_score + risk_count * 0.15
if security_flags.get("is_bogon", False):
final_score += 1.0
percentage = final_score * 100
if percentage >= 100:
return "极度危险"
elif percentage >= 20:
return "高风险"
elif percentage >= 5:
return "轻微风险"
elif percentage >= 0.25:
return "纯净"
else:
return "极度纯净"
def extract_country_code(label):
label = label.strip()
if not label:
return None
tokens = re.split(r'[\s,;|/]+', label)
for token in tokens:
token_cleaned = re.sub(r'^[\d\s\-_.|#]+', '', token.strip())
m3 = re.match(r'^([A-Z]{3})(?![A-Za-z])', token_cleaned)
if m3 and m3.group(1) in ALPHA3_TO_ALPHA2:
return ALPHA3_TO_ALPHA2[m3.group(1)]
m2 = re.match(r'^([A-Z]{2})(?![A-Za-z])', token_cleaned)
if m2 and m2.group(1) in CODE_SET:
return m2.group(1)
for token in tokens:
token_cleaned = re.sub(r'^[\d\s\-_.|#]+', '', token)
cn_matches = re.findall(r'[\u4e00-\u9fff()()]+', token_cleaned)
for cn in cn_matches:
code = CN_TO_CODE.get(cn)
if code:
return code
emoji_chars = [c for c in label if '\U0001F1E6' <= c <= '\U0001F1FF']
if len(emoji_chars) >= 2 and len(emoji_chars) % 2 == 0:
first = ord(emoji_chars[0]) - 0x1F1E6
second = ord(emoji_chars[1]) - 0x1F1E6
if 0 <= first <= 25 and 0 <= second <= 25:
return chr(first + ord('A')) + chr(second + ord('A'))
return None
def _parse_json_nodes(data):
nodes = []
if isinstance(data, list):
for item in data:
nodes.extend(_parse_json_nodes(item))
elif isinstance(data, dict):
for key in ('nodes', 'data', 'result', 'list'):
if key in data and isinstance(data[key], list):
nodes.extend(_parse_json_nodes(data[key]))
break
ip = data.get('ip') or data.get('host')
port = data.get('port')
code = data.get('country') or data.get('cc')
if ip and port and code:
code_str = str(code).strip()
normalized = extract_country_code(code_str) or code_str.upper()
nodes.append(f"{ip}:{port}#{normalized}")
elif isinstance(data, str):
nodes.extend(_parse_text_nodes(data))
return nodes
def _query_country(ip, port):
r = _local_probe(ip, int(port), AVAILABILITY_TIMEOUT)
if r["status"] == 200:
loc = r["trace"].get("loc", "")
if loc and len(loc) == 2:
return loc.upper()
return None
def _resolve_countries_batch(ipports):
results = {}
total = len(ipports)
completed = 0
last_print = time.time()
def worker(ipport):
ip, port = ipport.rsplit(':', 1)
return ipport, _query_country(ip, port)
with ThreadPoolExecutor(max_workers=FALLBACK_WORKERS) as executor:
futures = {executor.submit(worker, ipp): ipp for ipp in ipports}
for future in as_completed(futures):
try:
ipport, code = future.result()
results[ipport] = code
except Exception:
results[futures[future]] = None
completed += 1
now = time.time()
is_last = (completed == total)
if is_last or now - last_print >= PROGRESS_PRINT_INTERVAL:
log("进度", f"反代验证查询 {completed}/{total} ({(completed/total)*100:.1f}%)", end="\n" if is_last else "")
last_print = now
return results
def _parse_text_nodes(text):
nodes = []
pending = []
tokens = text.split()
for token in tokens:
pure_match = re.match(r'^(\d+\.\d+\.\d+\.\d+:\d+)$', token)
if pure_match:
pending.append(pure_match.group(1))
continue
if '#' not in token:
continue
try:
ipport, label = token.split('#', 1)
except ValueError:
continue
ipport = ipport.strip()
label = label.strip()
if ipport.startswith('['):
continue
if not re.match(r'^\d+\.\d+\.\d+\.\d+:\d+$', ipport):
continue
code = extract_country_code(label)
if code:
nodes.append(f"{ipport}#{code}")
else:
pending.append(ipport)
if pending:
log("信息", f"{len(pending)} 个节点缺少国家,反代验证查询中")
resolved = _resolve_countries_batch(pending)
for ipport, code in resolved.items():
if code:
nodes.append(f"{ipport}#{code}")
return nodes
def parse_adaptive(text):
text = text.strip()
if not text:
return []
if text.startswith('{') or text.startswith('['):
try:
data = json.loads(text)
return _parse_json_nodes(data)
except (json.JSONDecodeError, Exception):
pass
return _parse_text_nodes(text)
def fetch_additional_source(url):
if not url:
return []
for attempt in range(1, FETCH_MAX_RETRIES + 1):
try:
log("信息", f"拉取数据源 第 {attempt}/{FETCH_MAX_RETRIES} 次({url})")
headers = {"Accept-Encoding": "gzip, deflate, br", "User-Agent": USER_AGENT}
resp = requests.get(url, timeout=(FETCH_CONNECT_TIMEOUT, FETCH_TIMEOUT), headers=headers)
resp.raise_for_status()
nodes = parse_adaptive(resp.text)
log("信息", f"数据源解析完成({len(nodes)} 节点,{url})")
return nodes
except Exception as e:
log("错误", f"数据源请求失败({url}): {e}")
if attempt < FETCH_MAX_RETRIES:
log("警告", f"等待 {FETCH_RETRY_DELAY}s 重试")
time.sleep(FETCH_RETRY_DELAY)
else:
log("错误", f"已尝试 {FETCH_MAX_RETRIES} 次,放弃 {url}")
return []
class IpInfoAsync:
def __init__(self, token_list, concurrency, min_interval, trust_env, failure_threshold):
self.token_list = token_list
self.n_tokens = len(token_list)
self.current_token_index = 0
self.token_lock = asyncio.Lock()
self.semaphore = asyncio.Semaphore(concurrency)
self.min_interval = min_interval
self.session = None
self.trust_env = trust_env
self.failure_threshold = max(2, failure_threshold)
self.token_failures = [0] * self.n_tokens
self.cooldown_until = [0.0] * self.n_tokens
self.last_request_time = [0.0] * self.n_tokens
self.rate_locks = [asyncio.Lock() for _ in range(self.n_tokens)]
async def __aenter__(self):
self.session = aiohttp.ClientSession(
trust_env=self.trust_env,
connector=aiohttp.TCPConnector(ssl=False),
headers={"User-Agent": USER_AGENT},
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.session:
await self.session.close()
async def _acquire_token(self):
async with self.token_lock:
now = asyncio.get_event_loop().time()
n = self.n_tokens
for _ in range(n):
self.current_token_index = (self.current_token_index + 1) % n
idx = self.current_token_index
if (self.token_failures[idx] < self.failure_threshold
and self.cooldown_until[idx] <= now):
return idx
idx = min(range(n), key=lambda i: self.token_failures[i])
self.current_token_index = idx
return idx
async def _rate_limit(self, idx):
async with self.rate_locks[idx]:
now = asyncio.get_event_loop().time()
wait = self.last_request_time[idx] + self.min_interval - now
if wait > 0:
await asyncio.sleep(wait)
self.last_request_time[idx] = asyncio.get_event_loop().time()
async def get_ip_details(self, ip_address):
max_attempts = max(3, min(self.n_tokens, 10))
for _ in range(max_attempts):
idx = await self._acquire_token()
token = self.token_list[idx]
url = (f"https://ipinfo.io/{ip_address}/json?token={token}"
if ip_address else
f"https://ipinfo.io/json?token={token}")
await self._rate_limit(idx)
async with self.semaphore:
try:
async with self.session.get(
url, timeout=aiohttp.ClientTimeout(total=IP_CALIBRATION_REQUEST_TIMEOUT)
) as resp:
if resp.status == 429:
self.cooldown_until[idx] = asyncio.get_event_loop().time() + 1.0
continue
if resp.status in (401, 403):
self.token_failures[idx] += 1
continue
resp.raise_for_status()
data = await resp.json()
self.token_failures[idx] = 0
country = data.get("country", "Unknown")
if country == "Unknown":
continue
return {
"CountryCode": country,
"Region": data.get("region", "Unknown"),
"City": data.get("city", "Unknown"),
"ASN": data.get("org", "").split(" ")[0] if data.get("org", "").startswith("AS") else "Unknown",
"ISP": data.get("org", "").split(" ", 1)[-1] if " " in data.get("org", "") else data.get("org", "Unknown"),
}
except (asyncio.TimeoutError, aiohttp.ClientError):
self.cooldown_until[idx] = asyncio.get_event_loop().time() + 0.3
continue
return None
def load_tokens(filepath):
if not os.path.exists(filepath):
return []
with open(filepath, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def load_ipinfo_cache(cache_file):
if not os.path.exists(cache_file):
return {}
cache = {}
with open(cache_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or '#' not in line:
continue
ipport, tag = line.split('#', 1)
cache[ipport.strip()] = tag.strip()
return cache
def save_ipinfo_cache(cache_file, new_records):
with open(cache_file, "a", encoding="utf-8") as f:
for ipport, tag in new_records:
f.write(f"{ipport}#{tag}\n")
def sort_cache_file(cache_file):
if not os.path.exists(cache_file):
return
with open(cache_file, "r", encoding="utf-8") as f:
lines = f.readlines()
parsed = []
for line in lines:
line = line.strip()
if not line or '#' not in line:
continue
ipport, tag = line.split('#', 1)
ip_str = ipport.split(':')[0]
try:
ip_obj = ipaddress.ip_address(ip_str)
except ValueError:
continue
parsed.append((ip_obj, line))
parsed.sort(key=lambda x: x[0])
with open(cache_file, "w", encoding="utf-8") as f:
for _, line in parsed:
f.write(line + "\n")
async def validate_tokens(token_list, concurrency, min_interval, trust_env):
valid = []
sem = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession(
trust_env=trust_env,
connector=aiohttp.TCPConnector(ssl=False),
headers={"User-Agent": USER_AGENT},
) as session:
async def check(token):
url = f"https://ipinfo.io/json?token={token}"
try:
async with sem:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=IP_CALIBRATION_REQUEST_TIMEOUT)) as resp:
if resp.status != 200:
return token, False
data = await resp.json()
return token, bool(data.get("country"))
except Exception:
return token, False
tasks = [asyncio.ensure_future(check(t)) for t in token_list]
total = len(tasks)
completed = 0
last_print = time.time()
for coro in asyncio.as_completed(tasks):
token, ok = await coro
if ok:
valid.append(token)
completed += 1
now = time.time()
is_last = (completed == total)
if is_last or now - last_print >= PROGRESS_PRINT_INTERVAL:
log("进度", f"Token 校验 {completed}/{total}", end="\n" if is_last else "")
last_print = now
return valid
async def query_new_ips(new_ips, token_list, concurrency, min_interval, trust_env,
ipport_map=None, cache_file=None):
result = {}
if not new_ips or not token_list:
return result
log("信息", f"地区校准查询 {len(new_ips)} 个新 IP")
async with IpInfoAsync(token_list, concurrency, min_interval, trust_env, TOKEN_FAILURE_THRESHOLD) as handler:
tasks = []
for ip in new_ips:
task = asyncio.ensure_future(handler.get_ip_details(ip))
task.my_ip = ip
tasks.append(task)
total = len(tasks)
completed = 0
failed = 0