mirror of
https://github.com/ermaozi/get_subscribe.git
synced 2026-09-29 19:51:41 +00:00
124 lines
5.3 KiB
Python
124 lines
5.3 KiB
Python
"""按 TCP 握手结果分类订阅;UDP/QUIC 和无法解析的格式不作失效判断。"""
|
||
import base64
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from datetime import datetime, timezone
|
||
import ipaddress
|
||
import json
|
||
from pathlib import Path
|
||
import socket
|
||
import time
|
||
from urllib.parse import parse_qs, urlsplit
|
||
|
||
import yaml
|
||
|
||
TIMEOUT = 3
|
||
WORKERS = 24
|
||
STATUSES = ('reachable', 'unreachable', 'untested')
|
||
UDP_TYPES = {'hysteria', 'hysteria2', 'hy2', 'tuic', 'wireguard', 'mieru'}
|
||
|
||
|
||
def _decode(value):
|
||
return base64.urlsafe_b64decode(value + '=' * (-len(value) % 4)).decode('utf-8')
|
||
|
||
|
||
def endpoint(node):
|
||
"""返回 TCP 主机和端口;不支持或使用 UDP 的节点返回 None。"""
|
||
try:
|
||
if isinstance(node, dict):
|
||
if node.get('type') in UDP_TYPES or node.get('network') in {'quic', 'kcp'}:
|
||
return None
|
||
host, port = node.get('server'), node.get('port')
|
||
else:
|
||
scheme, body = node.split('://', 1)
|
||
if scheme in UDP_TYPES:
|
||
return None
|
||
if scheme == 'vmess':
|
||
data = json.loads(_decode(body.split('#', 1)[0]))
|
||
if not isinstance(data, dict) or data.get('net') in {'quic', 'kcp'}:
|
||
return None
|
||
host, port = data.get('add'), data.get('port')
|
||
elif scheme == 'ssr':
|
||
host, port, *_ = _decode(body.split('#', 1)[0]).split('/?', 1)[0].rsplit(':', 5)
|
||
else:
|
||
if scheme == 'ss' and '@' not in body:
|
||
node = 'ss://' + _decode(body.split('#', 1)[0])
|
||
parsed = urlsplit(node)
|
||
if any(v in {'quic', 'kcp'} for v in parse_qs(parsed.query).get('type', [])):
|
||
return None
|
||
host, port = parsed.hostname, parsed.port
|
||
port = int(port)
|
||
if not isinstance(host, str) or not host or not 1 <= port <= 65535:
|
||
return None
|
||
return host.strip('[]').lower().rstrip('.'), port
|
||
except (ValueError, TypeError, KeyError, UnicodeError):
|
||
return None
|
||
|
||
|
||
def tcp_status(address):
|
||
# ponytail: 只测 TCP 握手;需要验证认证和代理出口时再引入代理内核。
|
||
host, port = address
|
||
try:
|
||
resolved = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||
except OSError:
|
||
return 'unreachable'
|
||
public = []
|
||
for family, socktype, proto, _, sockaddr in resolved:
|
||
# 来源不可信,不探测宿主机内网、回环或云实例元数据地址。
|
||
if ipaddress.ip_address(sockaddr[0]).is_global:
|
||
public.append((family, socktype, proto, sockaddr))
|
||
if not public:
|
||
return 'untested'
|
||
deadline = time.monotonic() + TIMEOUT
|
||
for family, socktype, proto, sockaddr in public:
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
break
|
||
try:
|
||
with socket.socket(family, socktype, proto) as connection:
|
||
connection.settimeout(remaining)
|
||
connection.connect(sockaddr)
|
||
return 'reachable'
|
||
except OSError:
|
||
continue
|
||
return 'unreachable'
|
||
|
||
|
||
def classify(directory='subscribe'):
|
||
directory = Path(directory)
|
||
config = yaml.safe_load((directory / 'clash.yml').read_text(encoding='utf-8'))
|
||
uris = (directory / 'v2ray.txt').read_text(encoding='utf-8').splitlines()
|
||
proxies = config['proxies']
|
||
proxy_addresses = [endpoint(proxy) for proxy in proxies]
|
||
uri_addresses = [endpoint(uri) for uri in uris]
|
||
addresses = list(dict.fromkeys(a for a in proxy_addresses + uri_addresses if a is not None))
|
||
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
||
results = dict(zip(addresses, pool.map(tcp_status, addresses)))
|
||
summary = {'checked_at': datetime.now(timezone.utc).isoformat(), 'method': 'TCP connect',
|
||
'timeout_seconds': TIMEOUT, 'unique_endpoints': len(addresses), 'counts': {}}
|
||
all_names = {proxy['name'] for proxy in proxies}
|
||
for status in STATUSES:
|
||
selected = [p for p, address in zip(proxies, proxy_addresses) if results.get(address, 'untested') == status]
|
||
selected_uris = [uri for uri, address in zip(uris, uri_addresses) if results.get(address, 'untested') == status]
|
||
removed = all_names - {proxy['name'] for proxy in selected}
|
||
filtered = {**config, 'proxies': selected}
|
||
groups = []
|
||
for group in config.get('proxy-groups', []):
|
||
group = dict(group)
|
||
if 'proxies' in group:
|
||
group['proxies'] = [name for name in group['proxies'] if name not in removed] or ['REJECT']
|
||
groups.append(group)
|
||
if 'proxy-groups' in config:
|
||
filtered['proxy-groups'] = groups
|
||
target = directory / status
|
||
target.mkdir(exist_ok=True)
|
||
(target / 'clash.yml').write_text(yaml.safe_dump(filtered, allow_unicode=True, sort_keys=False), encoding='utf-8')
|
||
(target / 'v2ray.txt').write_text('\n'.join(selected_uris) + ('\n' if selected_uris else ''), encoding='utf-8')
|
||
summary['counts'][status] = {'clash': len(selected), 'v2ray': len(selected_uris)}
|
||
(directory / 'health.json').write_text(json.dumps(summary, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||
print(json.dumps(summary, ensure_ascii=False))
|
||
return summary
|
||
|
||
|
||
if __name__ == '__main__':
|
||
classify()
|