diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000..3ffde9e6d0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "manager"]
+ path = manager
+ url = https://github.com/wzdnzd/proxy-manager.git
diff --git a/clash/clash-darwin-amd b/clash/clash-darwin-amd
index 7a6122f093..d48f579cc5 100644
Binary files a/clash/clash-darwin-amd and b/clash/clash-darwin-amd differ
diff --git a/clash/clash-darwin-arm b/clash/clash-darwin-arm
index d7e9f7a1b5..b64be7f2f0 100644
Binary files a/clash/clash-darwin-arm and b/clash/clash-darwin-arm differ
diff --git a/clash/clash-linux-amd b/clash/clash-linux-amd
index 4e4e41bcd3..81322e8721 100644
Binary files a/clash/clash-linux-amd and b/clash/clash-linux-amd differ
diff --git a/clash/clash-linux-arm b/clash/clash-linux-arm
index d6a6c9ad43..96065b0239 100644
Binary files a/clash/clash-linux-arm and b/clash/clash-linux-arm differ
diff --git a/clash/clash-windows-amd.exe b/clash/clash-windows-amd.exe
index a21601b154..0cb4d7c2ce 100644
Binary files a/clash/clash-windows-amd.exe and b/clash/clash-windows-amd.exe differ
diff --git a/manager b/manager
new file mode 160000
index 0000000000..e3d93e51e7
--- /dev/null
+++ b/manager
@@ -0,0 +1 @@
+Subproject commit e3d93e51e762d269546e299490ff114031272e0b
diff --git a/subconverter/subconverter-darwin-amd b/subconverter/subconverter-darwin-amd
index b74b3a0091..e360517aad 100644
Binary files a/subconverter/subconverter-darwin-amd and b/subconverter/subconverter-darwin-amd differ
diff --git a/subconverter/subconverter-darwin-arm b/subconverter/subconverter-darwin-arm
index b74b3a0091..e360517aad 100644
Binary files a/subconverter/subconverter-darwin-arm and b/subconverter/subconverter-darwin-arm differ
diff --git a/subconverter/subconverter-linux-amd b/subconverter/subconverter-linux-amd
index 03dbba7f03..7749222c08 100644
Binary files a/subconverter/subconverter-linux-amd and b/subconverter/subconverter-linux-amd differ
diff --git a/subconverter/subconverter-linux-arm b/subconverter/subconverter-linux-arm
index fd48ed5683..9390376a51 100644
Binary files a/subconverter/subconverter-linux-arm and b/subconverter/subconverter-linux-arm differ
diff --git a/subconverter/subconverter-windows-amd.exe b/subconverter/subconverter-windows-amd.exe
index f2f9d6a3c3..b7dc348272 100644
Binary files a/subconverter/subconverter-windows-amd.exe and b/subconverter/subconverter-windows-amd.exe differ
diff --git a/subscribe/airport.py b/subscribe/airport.py
index b2b6d39b92..ea123925d5 100644
--- a/subscribe/airport.py
+++ b/subscribe/airport.py
@@ -533,14 +533,15 @@ def parse(
with open(self.sub, "r", encoding="UTF8") as f:
text = f.read()
else:
- headers = {"User-Agent": "Clash.Meta; Mihomo"}
+ client = f"{utils.USER_AGENT}; Clash.Meta; Mihomo; Shadowrocket;"
+ headers = {"User-Agent": client}
trace = os.environ.get("TRACE_ENABLE", "false").lower() in ["true", "1"]
text = utils.http_get(
url=self.sub,
headers=headers,
retry=retry,
- timeout=30,
+ timeout=120,
trace=trace,
interval=1,
max_size=15 * 1024 * 1024,
@@ -699,7 +700,7 @@ def parse(
@staticmethod
def check_protocol(link: str) -> bool:
return re.match(
- r"^(vmess|trojan|ss|ssr|vless|hysteria|hysteria2|tuic|snell|anytls)://[a-zA-Z0-9:.?+=@%_\-/]{10,}",
+ r"^(vmess|trojan|ss|ssr|vless|hysteria|hysteria2|tuic|snell|anytls|socks5|https?)://[a-zA-Z0-9:.?+=@%_\-/]{10,}",
utils.trim(link).replace("\r", ""),
flags=re.I,
)
@@ -754,7 +755,7 @@ def clean_text(document: str) -> str:
not is_b64encode
and not is_json
and not is_yaml
- and all(AirPort.check_protocol(x) for x in text.split("\n") if x)
+ and all(AirPort.check_protocol(x) for x in text.split("\n") if x and not x.startswith("#"))
):
text = base64.b64encode(text.encode(encoding="UTF8")).decode(encoding="UTF8")
@@ -766,7 +767,7 @@ def clean_text(document: str) -> str:
if os.path.exists(v2ray_file):
os.remove(v2ray_file)
- logger.error(f"save file fialed, artifact: {artifact}")
+ logger.error(f"save file failed, artifact: {artifact}")
traceback.print_exc()
generate_conf = os.path.join(PATH, "subconverter", "generate.ini")
@@ -777,6 +778,7 @@ def clean_text(document: str) -> str:
f"{artifact}.yaml",
"clash",
True,
+ True,
ignore,
)
if not success:
diff --git a/subscribe/clash.py b/subscribe/clash.py
index 193559f585..2733b89b88 100644
--- a/subscribe/clash.py
+++ b/subscribe/clash.py
@@ -192,7 +192,7 @@ def proxies_exists(proxy: dict, hosts: dict) -> bool:
"xchacha20-ietf-poly1305",
]
-# reference: https://github.com/MetaCubeX/sing-shadowsocks2/blob/dev/shadowaead_2022/method.go#L73-L86
+# reference: https://github.com/SagerNet/sing-shadowsocks2/blob/dev/shadowaead_2022/method.go#L72-L86
MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN = {
"2022-blake3-aes-128-gcm": 16,
"2022-blake3-aes-256-gcm": 32,
@@ -247,6 +247,31 @@ def proxies_exists(proxy: dict, hosts: dict) -> bool:
SPECIAL_PROTOCOLS = set(["vless", "tuic", "hysteria", "hysteria2", "anytls"])
+VLESS_MLKEM_X25519_PLUS_PREFIX = "mlkem768x25519plus"
+VLESS_MLKEM_X25519_PLUS_MODES = ("native", "xorpub", "random")
+VLESS_MLKEM_X25519_PLUS_RTTS = ("1rtt", "0rtt")
+VLESS_MLKEM_X25519_PLUS_PADDING_LIMIT = 20
+VLESS_MLKEM_X25519_PLUS_KEY_SIZES = (32, 1184)
+
+# mihomo ParseRange uses strconv.Atoi (64-bit int on our binaries)
+# see: https://github.com/MetaCubeX/mihomo/blob/Alpha/transport/xhttp/config.go
+XHTTP_RANGE_MAX = 2**63 - 1
+XHTTP_RANGE_FIELDS = (
+ "sc-max-each-post-bytes",
+ "sc-min-posts-interval-ms",
+ "x-padding-bytes",
+ "uplink-chunk-size",
+ "session-length",
+)
+XHTTP_RANGE_POSITIVE_MAX = set(["sc-max-each-post-bytes", "sc-min-posts-interval-ms"])
+XHTTP_REUSE_RANGE_FIELDS = (
+ "max-concurrency",
+ "max-connections",
+ "c-max-reuse-times",
+ "h-max-request-times",
+ "h-max-reusable-secs",
+)
+
# xtls-rprx-direct and xtls-rprx-origin are deprecated and no longer supported
# XTLS_FLOWS = set(["xtls-rprx-direct", "xtls-rprx-origin", "xtls-rprx-vision"])
@@ -290,10 +315,207 @@ def check_ports(port: str, ranges: str, protocol: str) -> bool:
return True
+def verify_vless_encryption(encryption: str) -> bool:
+ if not encryption or encryption == "none":
+ return True
+
+ parts = encryption.split(".")
+ if (
+ len(parts) < 4
+ or parts[0] != VLESS_MLKEM_X25519_PLUS_PREFIX
+ or parts[1] not in VLESS_MLKEM_X25519_PLUS_MODES
+ or parts[2] not in VLESS_MLKEM_X25519_PLUS_RTTS
+ ):
+ return False
+
+ for key in parts[3:]:
+ if len(key) < VLESS_MLKEM_X25519_PLUS_PADDING_LIMIT:
+ continue
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", key):
+ return False
+
+ try:
+ content = key + "=" * (-len(key) % 4)
+ decoded = base64.urlsafe_b64decode(content)
+ except:
+ return False
+
+ if len(decoded) not in VLESS_MLKEM_X25519_PLUS_KEY_SIZES:
+ return False
+
+ return True
+
+
+def verify_ss_2022_password(cipher: str, password: str) -> bool:
+ # password is ":"-separated standard base64 PSKs; chacha20 rejects EIH (pskList > 1)
+ # see: https://github.com/SagerNet/sing-shadowsocks2/blob/dev/shadowaead_2022/method.go#L72-L86
+ password = utils.trim(password)
+ if not password:
+ return False
+
+ words = password.split(":")
+ if cipher == "2022-blake3-chacha20-poly1305" and len(words) > 1:
+ return False
+
+ key_len = MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN.get(cipher)
+ if not key_len:
+ return False
+
+ for word in words:
+ # Go encoding/base64.StdEncoding: standard alphabet and padding required
+ if not word or not re.fullmatch(r"[A-Za-z0-9+/]+=*$", word) or len(word) % 4 != 0:
+ return False
+ try:
+ text = base64.b64decode(word, validate=True)
+ except:
+ return False
+ if len(text) != key_len:
+ return False
+
+ return True
+
+
+def verify_reality_public_key(public_key: str) -> bool:
+ # mihomo uses base64.RawURLEncoding (URL-safe, no padding) and requires 32 bytes
+ # see: https://github.com/MetaCubeX/mihomo/blob/Alpha/adapter/outbound/reality.go
+ public_key = utils.trim(public_key)
+ if not public_key or not re.fullmatch(r"[A-Za-z0-9_-]+", public_key):
+ return False
+
+ try:
+ decoded = base64.urlsafe_b64decode(public_key + "=" * (-len(public_key) % 4))
+ except:
+ return False
+
+ if len(decoded) != 32:
+ return False
+
+ # reject non-canonical encodings that Go RawURLEncoding.DecodeString rejects
+ canonical = base64.urlsafe_b64encode(decoded).decode("utf-8").rstrip("=")
+ return canonical == public_key
+
+
+def parse_xhttp_range_bound(text: str):
+ # one ParseRange token: decimal int or scientific/float that is a whole number
+ text = utils.trim(text)
+ if not text:
+ return None
+
+ if re.fullmatch(r"[0-9]+", text):
+ try:
+ value = int(text)
+ except:
+ return None
+ else:
+ try:
+ number = float(text)
+ except:
+ return None
+ if number != number or number < 0 or number == float("inf"):
+ return None
+ value = int(number)
+ if value != number:
+ return None
+
+ if value < 0 or value > XHTTP_RANGE_MAX:
+ return None
+ return value
+
+
+def normalize_xhttp_range(value):
+ # canonical "123" or "min-max" for mihomo ParseRange; None if invalid
+ if isinstance(value, bool) or value is None:
+ return None
+ if isinstance(value, int):
+ if value < 0 or value > XHTTP_RANGE_MAX:
+ return None
+ return str(value)
+ if isinstance(value, float):
+ if value != value or value < 0 or value == float("inf"):
+ return None
+ number = int(value)
+ if number != value or number > XHTTP_RANGE_MAX:
+ return None
+ return str(number)
+
+ text = utils.trim(str(value))
+ if not text:
+ return ""
+
+ bound = parse_xhttp_range_bound(text)
+ if bound is not None:
+ return str(bound)
+
+ # original value is already a range like "16-32" or "1e5-2e5", not a clamp
+ if text.count("-") != 1:
+ return None
+ left, right = text.split("-", 1)
+ min_val, max_val = parse_xhttp_range_bound(left), parse_xhttp_range_bound(right)
+ if min_val is None or max_val is None or max_val < min_val:
+ return None
+ if min_val == max_val:
+ return str(min_val)
+ return f"{min_val}-{max_val}"
+
+
+def apply_xhttp_range_field(container: dict, key: str, min_positive: bool = False, max_positive: bool = False) -> bool:
+ if key not in container:
+ return True
+
+ value = container[key]
+ if value is None or (isinstance(value, str) and not utils.trim(value)):
+ container.pop(key, None)
+ return True
+
+ normalized = normalize_xhttp_range(value)
+ if normalized is None:
+ return False
+ if not normalized:
+ container.pop(key, None)
+ return True
+
+ parts = normalized.split("-")
+ min_val, max_val = int(parts[0]), int(parts[-1])
+ if min_positive and min_val <= 0:
+ return False
+ if max_positive and max_val <= 0:
+ return False
+
+ container[key] = min_val if len(parts) == 1 else normalized
+ return True
+
+
+def verify_xhttp_reuse_settings(settings: dict) -> bool:
+ if type(settings) != dict:
+ return False
+
+ for key in XHTTP_REUSE_RANGE_FIELDS:
+ if not apply_xhttp_range_field(settings, key):
+ return False
+
+ if "h-keep-alive-period" not in settings:
+ return True
+
+ value = settings["h-keep-alive-period"]
+ if value is None or (isinstance(value, str) and not utils.trim(value)):
+ settings.pop("h-keep-alive-period", None)
+ return True
+
+ normalized = normalize_xhttp_range(value)
+ if not normalized or "-" in normalized:
+ return False
+
+ settings["h-keep-alive-period"] = int(normalized)
+ return True
+
+
def verify(item: dict, mihomo: bool = True) -> bool:
if not item or type(item) != dict or "type" not in item:
return False
+ # remove dialer-proxy because target proxy maybe not exists
+ item.pop("dialer-proxy", None)
+
try:
# name must be string
name = str(item.get("name", "")).strip()
@@ -337,17 +559,8 @@ def verify(item: dict, mihomo: bool = True) -> bool:
return False
if item["cipher"] in MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN:
- # will throw bad key length error
- # see: https://github.com/MetaCubeX/sing-shadowsocks2/blob/dev/shadowaead_2022/method.go#L59-L108
- password = str(item.get(authentication, ""))
- words = password.split(":")
- for word in words:
- try:
- text = base64.b64decode(word)
- if len(text) != MIHOMO_SS_SUPPORTED_CIPHERS_SALT_LEN.get(item["cipher"]):
- return False
- except:
- return False
+ if not verify_ss_2022_password(item["cipher"], str(item.get(authentication, ""))):
+ return False
plugin = item.get("plugin", "")
@@ -521,10 +734,16 @@ def verify(item: dict, mihomo: bool = True) -> bool:
elif item["type"] == "vless":
authentication = "uuid"
+
+ # see: https://github.com/MetaCubeX/mihomo/blob/Alpha/transport/vless/encryption/factory.go#L12
+ encryption = utils.trim(item.get("encryption", ""))
+ if not verify_vless_encryption(encryption):
+ return False
+
network = utils.trim(item.get("network", "tcp"))
# mihomo: https://wiki.metacubex.one/config/proxies/vless/#network
- network_opts = ["ws", "tcp", "grpc", "http", "h2"] if mihomo else ["ws", "tcp", "grpc"]
+ network_opts = ["ws", "tcp", "grpc", "http", "h2", "xhttp"] if mihomo else ["ws", "tcp", "grpc"]
if network not in network_opts:
return False
@@ -564,11 +783,11 @@ def verify(item: dict, mihomo: bool = True) -> bool:
return False
content = utils.trim(reality_opts["public-key"])
- content += "=" * (4 - len(content) % 4)
- public_key = base64.urlsafe_b64decode(content)
- if len(public_key) != 32:
+ if not verify_reality_public_key(content):
return False
+ reality_opts["public-key"] = content
+
short_id = reality_opts["short-id"]
if type(short_id) != str:
if utils.is_number(short_id):
@@ -576,10 +795,56 @@ def verify(item: dict, mihomo: bool = True) -> bool:
else:
return False
- if len(short_id) != 8 or not is_hex(short_id) or re.match(r"\d+e\d+", short_id, flags=re.I):
- return False
+ if short_id:
+ try:
+ sib = bytes.fromhex(short_id)
+ if len(sib) > 8:
+ return False
+ except ValueError:
+ return False
reality_opts["short-id"] = QuotedStr(short_id)
+ if "xhttp-opts" in item:
+ if network != "xhttp":
+ return False
+
+ xhttp_opts = item.get("xhttp-opts", {})
+ if not xhttp_opts or type(xhttp_opts) != dict:
+ return False
+ if "path" in xhttp_opts and type(xhttp_opts["path"]) != str:
+ return False
+ if "host" in xhttp_opts and type(xhttp_opts["host"]) != str:
+ return False
+
+ if "mode" in xhttp_opts:
+ xhttp_mode = utils.trim(xhttp_opts.get("mode", ""))
+ if xhttp_mode and xhttp_mode not in ["stream-one", "stream-up", "packet-up"]:
+ return False
+ if "headers" in xhttp_opts and type(xhttp_opts["headers"]) != dict:
+ return False
+
+ # ParseRange fields must be decimal ints or min-max, not 1E+05 / 100000.0
+ # see: https://github.com/MetaCubeX/mihomo/blob/Alpha/transport/xhttp/config.go
+ for key in XHTTP_RANGE_FIELDS:
+ min_positive = key == "session-length"
+ max_positive = key in XHTTP_RANGE_POSITIVE_MAX
+ if not apply_xhttp_range_field(
+ xhttp_opts, key, min_positive=min_positive, max_positive=max_positive
+ ):
+ return False
+
+ if "reuse-settings" in xhttp_opts and not verify_xhttp_reuse_settings(
+ xhttp_opts.get("reuse-settings")
+ ):
+ return False
+ if "download-settings" in xhttp_opts:
+ download_settings = xhttp_opts.get("download-settings")
+ if type(download_settings) != dict:
+ return False
+ if "reuse-settings" in download_settings and not verify_xhttp_reuse_settings(
+ download_settings.get("reuse-settings")
+ ):
+ return False
elif item["type"] == "tuic":
# mihomo: https://wiki.metacubex.one/config/proxies/tuic
token = wrap(item.get("token", ""))
diff --git a/subscribe/crawl.py b/subscribe/crawl.py
index 0c8cdc2bed..e999cedc6b 100644
--- a/subscribe/crawl.py
+++ b/subscribe/crawl.py
@@ -41,6 +41,56 @@
SEPARATOR = "-"
+_SUSPICIOUS_URL_RE = re.compile(
+ r"(?i)("
+ r"(?:^|//)(?:[^/]*\.)?(?:speedtest|librespeed|fast\.com|cachefly\.net|thinkbroadband\.com|speed\.cloudflare\.com)"
+ r"|/__down(?:\?|$)"
+ r"|\.(?:zip|iso|exe|mp4|mkv|avi|tar|tgz|gz|7z|rar|bin|img|dmg|apk|msi|pdf)(?:\?|$)"
+ r")"
+)
+
+_REJECT_CONTENT_TYPE_PREFIXES = (
+ "video/",
+ "audio/",
+ "image/",
+ "font/",
+ "application/zip",
+ "application/x-zip",
+ "application/gzip",
+ "application/x-gzip",
+ "application/x-tar",
+ "application/x-rar",
+ "application/pdf",
+ "application/x-msdownload",
+ "application/vnd.",
+)
+
+_REJECT_DISPOSITION_EXT = (
+ ".zip",
+ ".iso",
+ ".exe",
+ ".mp4",
+ ".mkv",
+ ".avi",
+ ".tar",
+ ".tgz",
+ ".gz",
+ ".7z",
+ ".rar",
+ ".bin",
+ ".img",
+ ".dmg",
+ ".apk",
+ ".msi",
+ ".pdf",
+ ".torrent",
+)
+
+_VALIDATE_MAX_BYTES = 15 * 1024 * 1024
+_VALIDATE_UNIDENTIFIED_BYTES = 64 * 1024
+_VALIDATE_CHUNK_SIZE = 8 * 1024
+_VALIDATE_SNIFF_DEADLINE = 8
+
@dataclass
class ValidateResult(object):
@@ -1271,15 +1321,33 @@ def check_status(
if not url or retry <= 0:
return False, connectable
+ # 垃圾 URL 提前拦截
+ if _is_suspicious_url(url):
+ logger.debug(f"[Validate] skip suspicious url: {utils.mask(url)}")
+ return False, True
+
try:
- headers = {"User-Agent": "clash.meta"}
+ headers = {"User-Agent": f"{utils.USER_AGENT}; Clash.Meta; Mihomo; Shadowrocket;"}
request = urllib.request.Request(url=url, headers=headers)
response = urllib.request.urlopen(request, timeout=10, context=utils.CTX)
if response.getcode() != 200:
return False, connectable
- # in order to avoid the request to the speed test site causing constant data downloads, limit the maximum read to 15MB
- content = str(response.read(15 * 1024 * 1024), encoding="utf8")
+ # 响应头检查 - 明显不是订阅
+ if _should_reject_response(response):
+ response.close()
+ logger.debug(f"[Validate] reject by header: {utils.mask(url)}")
+ return False, True
+
+ # 按需分块读取 + 嗅探,先确认像不像订阅,再决定读多少
+ content = _read_subscription_body(response)
+ if content is None:
+ return False, True
+
+ try:
+ content = content.decode("utf-8")
+ except UnicodeDecodeError:
+ return False, True
# response text is too short, ignore
if len(content) < 32:
@@ -1310,7 +1378,7 @@ def check_status(
return is_expired(header=subscription, remain=remain, spare_time=spare_time, tolerance=tolerance)
except urllib.error.HTTPError as e:
try:
- message = str(e.read(), encoding="utf8")
+ message = str(e.read(4096), encoding="utf8")
except:
message = ""
@@ -1326,7 +1394,8 @@ def check_status(
)
return False, expired
- except Exception as e:
+ except (socket.timeout, TimeoutError, ssl.SSLError, ConnectionError, OSError):
+ # 网络类错误继续重试
return check_status(
url=url,
retry=retry - 1,
@@ -1335,9 +1404,15 @@ def check_status(
tolerance=tolerance,
connectable=connectable,
)
+ except Exception as e:
+ # 其他非网络错误不再重试
+ logger.debug(f"[Validate] unexpected error for {utils.mask(url)}: {e}")
+ return False, connectable
def _parse_yaml_proxies(content: str) -> list | None:
+ if not content or not re.search(r"^proxies\s*:", content, flags=re.M):
+ return None
try:
return yaml.load(content, Loader=yaml.SafeLoader).get("proxies", [])
@@ -1385,6 +1460,151 @@ def is_expired(header: str, remain: float = 0, spare_time: float = 0, tolerance:
return True, False
+def _is_suspicious_url(url: str) -> bool:
+ """URL 前置垃圾拦截"""
+ if not url:
+ return False
+
+ return bool(_SUSPICIOUS_URL_RE.search(url))
+
+
+def _should_reject_response(response) -> bool:
+ """响应头检查 - 明显不是订阅"""
+ ctype = (response.getheader("Content-Type") or "").split(";")[0].strip().lower()
+ if ctype:
+ for prefix in _REJECT_CONTENT_TYPE_PREFIXES:
+ if ctype.startswith(prefix):
+ return True
+
+ disposition = response.getheader("Content-Disposition") or ""
+ if disposition:
+ m = re.search(r"filename\*?=(?:UTF-8''|\"|')?([^\";]+)", disposition, re.I)
+ if m:
+ name = urllib.parse.unquote(m.group(1).strip().strip("\"'")).lower()
+ if name.endswith(_REJECT_DISPOSITION_EXT):
+ return True
+
+ return False
+
+
+def _read_subscription_body(response) -> bytes | None:
+ """分块读取 + 嗅探 + 动态上限,大聚合订阅不受影响"""
+ start = time.monotonic()
+ buf = bytearray()
+ limit = _VALIDATE_UNIDENTIFIED_BYTES
+ verdict = "maybe"
+
+ while len(buf) < limit:
+ # 嗅探阶段设置时限
+ if verdict != "yes" and (time.monotonic() - start) > _VALIDATE_SNIFF_DEADLINE:
+ try:
+ response.close()
+ except Exception:
+ pass
+ return None
+
+ to_read = min(_VALIDATE_CHUNK_SIZE, limit - len(buf))
+ try:
+ chunk = response.read(to_read)
+ except (socket.timeout, TimeoutError, OSError):
+ try:
+ response.close()
+ except Exception:
+ pass
+ return None
+
+ if not chunk:
+ break
+
+ buf.extend(chunk)
+
+ if verdict != "yes":
+ verdict = _looks_like_subscription(bytes(buf))
+ if verdict == "no":
+ try:
+ response.close()
+ except Exception:
+ pass
+ return None
+ if verdict == "yes":
+ limit = _VALIDATE_MAX_BYTES
+
+ # 读到 64KB 且 verdict 仍是 "maybe",说明是大量未识别内容
+ if verdict != "yes" and len(buf) >= _VALIDATE_UNIDENTIFIED_BYTES:
+ try:
+ extra = response.read(1)
+ except Exception:
+ extra = b""
+ if extra:
+ try:
+ response.close()
+ except Exception:
+ pass
+ return None
+
+ return bytes(buf)
+
+
+def _looks_like_subscription(data: bytes) -> str:
+ """内容嗅探 - 返回 'yes' / 'no' / 'maybe'"""
+ if not data:
+ return "maybe"
+
+ sample = data[:8192]
+ if b"\x00" in sample:
+ return "no"
+
+ try:
+ text = data.decode("utf-8")
+ except UnicodeDecodeError:
+ return "no"
+
+ sample_text = text.lstrip(" \t\r\n")
+ if not sample_text:
+ return "maybe"
+
+ head = sample_text[:64].lower()
+ if head.startswith(("= 32 and re.match(r"^[A-Za-z0-9+/=]+$", compact):
+ return "yes"
+
+ # 纯注释开头,机场订阅常见前言
+ lines = [ln.strip() for ln in sample_text.splitlines() if ln.strip()]
+ if lines and all(ln.startswith("#") or ln.startswith("//") for ln in lines):
+ return "maybe"
+
+ return "maybe"
+
+
def is_available(url: str, retry: int = 2, remain: float = 0, spare_time: float = 0) -> bool:
available, _ = check_status(url=url, retry=retry, remain=remain, spare_time=spare_time)
return available
diff --git a/subscribe/iplibrary.py b/subscribe/iplibrary.py
new file mode 100644
index 0000000000..832f1c7b0c
--- /dev/null
+++ b/subscribe/iplibrary.py
@@ -0,0 +1,520 @@
+# -*- coding: utf-8 -*-
+
+# @Author : wzdnzd
+# @Time : 2026-09-07
+
+import html
+import ipaddress
+import json
+import random
+import re
+import urllib.parse
+from dataclasses import dataclass, field
+from typing import Optional
+
+import utils
+from logger import logger
+
+# HTTPS only. Fetched via CONNECT + TLS through the HTTP inbound.
+EGRESS_IPV4_URLS = (
+ "https://ipinfo.io/ip",
+ "https://api-ipv4.ip.sb/ip",
+ "https://ipv4.ip.sb/ip",
+ "https://ipv4.icanhazip.com",
+ "https://icanhazip.com",
+ "https://api4.ipify.org",
+ "https://api.ipify.org",
+ "https://v4.ident.me",
+ "https://ident.me",
+ "https://iplark.com/ipapi/public/ip",
+ "https://ipv4.wtfismyip.com/text",
+ "https://ifconfig.me/ip",
+ "https://ifconfig.co/ip",
+ "https://ifconfig.io/ip",
+ "https://ipecho.net/plain",
+ "https://wgetip.com",
+ "https://myexternalip.com/raw",
+ "https://ip.tyk.nu",
+ "https://eth0.me",
+ "https://ipapi.co/ip",
+ "https://ipv4.nsupdate.info/myip",
+ "https://myip.ipip.net",
+ "https://v4.ip.zxinc.org/getip",
+ "https://www.cloudflare.com/cdn-cgi/trace",
+ "https://1.1.1.1/cdn-cgi/trace",
+ "https://httpbin.org/ip",
+ "https://jsonip.com",
+ "https://ipv4.seeip.org",
+ "https://l2.io/ip",
+)
+_EGRESS_IPV4_TRIES = 8
+_IPV4_RE = re.compile(r"(?:\d{1,3}\.){3}\d{1,3}")
+ECHO_HEADERS = {
+ "Accept": "text/plain, application/json, */*",
+ "Accept-Language": "en-US,en;q=0.8,zh-CN;q=0.6",
+ "Cache-Control": "no-cache",
+ "Origin": None,
+ "Referer": None,
+}
+
+
+def _valid_public_ipv4(text: str) -> str:
+ try:
+ addr = ipaddress.ip_address(utils.trim(text))
+ except ValueError:
+ return ""
+ if addr.version != 4 or not addr.is_global:
+ return ""
+ return str(addr)
+
+
+def _parse_egress_ipv4(content) -> str:
+ if content is None:
+ return ""
+ if isinstance(content, dict):
+ chunks = []
+ for key in ("ip", "origin", "query", "address", "IPv4", "ipv4"):
+ value = content.get(key)
+ if value is not None:
+ chunks.append(str(value))
+ content = "\n".join(chunks) if chunks else json.dumps(content, ensure_ascii=False)
+ elif not isinstance(content, str):
+ content = str(content)
+
+ text = utils.trim(content)
+ if not text:
+ return ""
+ if text[0] in "{[":
+ try:
+ parsed = json.loads(text)
+ except Exception:
+ parsed = None
+ if isinstance(parsed, dict):
+ ip = _parse_egress_ipv4(parsed)
+ if ip:
+ return ip
+
+ # Cloudflare trace lists h=1.1.1.1 before ip=; prefer the ip field
+ for match in re.finditer(r"(?im)^ip=([0-9.]+)\s*$", text):
+ ip = _valid_public_ipv4(match.group(1))
+ if ip:
+ return ip
+
+ for match in _IPV4_RE.finditer(text):
+ ip = _valid_public_ipv4(match.group(0))
+ if ip:
+ return ip
+ return ""
+
+
+def resolve_egress_ipv4(port: int, request, max_retries: int = 2, timeout: int = 15) -> str:
+ urls = list(EGRESS_IPV4_URLS)
+ random.shuffle(urls)
+ tries = min(len(urls), max(_EGRESS_IPV4_TRIES, max_retries + 1))
+ for url in urls[:tries]:
+ success, content = request(
+ port=port,
+ url=url,
+ max_retries=1,
+ timeout=timeout,
+ headers=ECHO_HEADERS,
+ deserialize=False,
+ quiet=True,
+ )
+ ip = _parse_egress_ipv4(content) if success else ""
+ if ip:
+ return ip
+
+ return ""
+
+
+@dataclass
+class IPClassifyResult:
+ country_code: str = ""
+ company_type: str = ""
+ asn_type: str = ""
+ score: Optional[int] = None
+ raw: dict = field(default_factory=dict)
+
+
+class IPLibrary:
+ name: str = ""
+ needs_egress_ip: bool = False
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ raise NotImplementedError
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ raise NotImplementedError
+
+ def _resolve_egress_ip(self, port: int, request, max_retries: int = 2, timeout: int = 15, ip: str = "") -> str:
+ text = _valid_public_ipv4(ip) if ip else ""
+ if text:
+ return text
+ return resolve_egress_ipv4(port, request, max_retries=max_retries, timeout=timeout)
+
+ def _get(
+ self,
+ request,
+ port: int,
+ url: str,
+ max_retries: int,
+ timeout: int,
+ headers: dict = None,
+ deserialize: bool = True,
+ ):
+ success, response = request(
+ port=port,
+ url=url,
+ max_retries=max_retries,
+ timeout=timeout,
+ headers=headers,
+ deserialize=deserialize,
+ )
+ if not success:
+ return None
+ return response
+
+ @staticmethod
+ def _parse_trust_score(value, *, invert: bool = False) -> Optional[int]:
+ if value is None:
+ return None
+
+ try:
+ n = int(value)
+ except (TypeError, ValueError):
+ try:
+ n = int(float(value))
+ except (TypeError, ValueError):
+ return None
+
+ if invert:
+ n = 100 - n
+
+ return max(0, min(100, n))
+
+ @staticmethod
+ def _nested(data, *keys) -> dict:
+ current = data
+ for key in keys:
+ if not isinstance(current, dict):
+ return {}
+ current = current.get(key)
+ return current if isinstance(current, dict) else {}
+
+
+class IPNetCoffeeLibrary(IPLibrary):
+ name = "ipnetcoffee"
+ needs_egress_ip = True
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ ip = self._resolve_egress_ip(port, request, max_retries=max_retries, timeout=timeout, ip=ip)
+ if not ip:
+ return {}
+
+ url = f"https://ip.net.coffee/api/ip/lookup/{urllib.parse.quote(ip, safe='')}"
+ response = self._get(request, port, url, max_retries, timeout)
+ return response if isinstance(response, dict) else {}
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ if data.get("isResidential") is True and utils.trim(data.get("company_type", "")) != "business":
+ company_type, asn_type = "isp", "isp"
+ else:
+ company_type, asn_type = "hosting", "hosting"
+
+ return IPClassifyResult(
+ country_code=utils.trim(data.get("countryCode", "")).upper(),
+ company_type=company_type,
+ asn_type=asn_type,
+ score=self._parse_trust_score(data.get("trust_score")),
+ raw=data,
+ )
+
+
+class MeowVPSLibrary(IPLibrary):
+ name = "meowvps"
+ needs_egress_ip = True
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ ip = self._resolve_egress_ip(port, request, max_retries=max_retries, timeout=timeout, ip=ip)
+ if not ip:
+ return {}
+
+ url = f"https://meowvps.com/api/ip-aggregator/{urllib.parse.quote(ip, safe='')}"
+ headers = {
+ "Accept": "*/*",
+ "Accept-Language": "zh-CN,zh;q=0.8",
+ "Origin": "https://meowvps.com",
+ "Referer": "https://meowvps.com/tools/ip-check/",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/152.0.0.0 Safari/537.36"
+ ),
+ }
+ response = self._get(request, port, url, max_retries, timeout, headers=headers)
+ if not isinstance(response, dict):
+ return {}
+ if response.get("success") is False:
+ logger.warning(f"MeowVPS lookup failed, ip: {ip}")
+ return {}
+
+ return response
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ core = self._nested(data, "core_data")
+ digital = self._nested(data, "api4", "digital")
+ traits = self._nested(data, "minfraud", "traits")
+ scores = self._nested(data, "risk_assessment", "ipdata", "scores")
+
+ digital_type = "" if digital.get("type") is None else str(digital.get("type")).strip().lower()
+ user_type = str(traits.get("user_type") or "").strip().lower()
+
+ if digital_type == "edu" or user_type in {"college", "education", "edu"}:
+ company_type, asn_type = "edu", "edu"
+ elif self._is_residential(digital_type, user_type, data):
+ company_type, asn_type = "isp", "isp"
+ else:
+ company_type, asn_type = "hosting", "hosting"
+
+ country_code = utils.trim(core.get("country_code") or digital.get("country_code") or "").upper()
+ return IPClassifyResult(
+ country_code=country_code,
+ company_type=company_type,
+ asn_type=asn_type,
+ score=self._parse_trust_score(scores.get("trust_score")),
+ raw=data,
+ )
+
+ @classmethod
+ def _is_residential(cls, digital_type: str, user_type: str, data: dict) -> bool:
+ if digital_type in {"hosting", "edu"}:
+ return False
+ if user_type in {"hosting", "content_delivery_network", "college"}:
+ return False
+ if user_type in {"residential", "traveler", "cellular"}:
+ return True
+ if digital_type:
+ return False
+
+ ipapi = cls._nested(data, "risk_assessment", "ipapi")
+ threat = cls._nested(data, "risk_assessment", "ipdata", "threat")
+ if ipapi.get("hosting") is True or threat.get("is_datacenter") is True:
+ return False
+
+ return True
+
+
+class IPPureLibrary(IPLibrary):
+ name = "ippure"
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ response = self._get(request, port, "https://my.ippure.com/v1/info", max_retries, timeout)
+ return response if isinstance(response, dict) else {}
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ if data.get("isResidential", False):
+ company_type, asn_type = "isp", "isp"
+ else:
+ company_type, asn_type = "hosting", "hosting"
+
+ return IPClassifyResult(
+ country_code=utils.trim(data.get("countryCode", "")).upper(),
+ company_type=company_type,
+ asn_type=asn_type,
+ score=self._parse_trust_score(data.get("fraudScore"), invert=True),
+ raw=data,
+ )
+
+
+class IP2LocationLibrary(IPLibrary):
+ name = "ip2location"
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ headers = {"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
+ response = self._get(
+ request,
+ port,
+ "https://www.ip2location.com/demo",
+ max_retries,
+ timeout,
+ headers=headers,
+ deserialize=False,
+ )
+ if not isinstance(response, str):
+ return {}
+
+ data = self._extract_data(response)
+ if not data:
+ logger.warning("Failed to extract JSON payload from ip2location demo HTML")
+ return {}
+ return data
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ usage_type = utils.trim(data.get("usage_type", "")).lower()
+ as_info = data.get("as_info", {})
+ if not isinstance(as_info, dict):
+ as_info = {}
+ as_usage_type = utils.trim(as_info.get("as_usage_type", "")).lower()
+
+ check = lambda usage: usage.startswith("isp") or usage == "mob"
+ if check(usage_type) and check(as_usage_type):
+ company_type, asn_type = "isp", "isp"
+ else:
+ company_type, asn_type = "hosting", "hosting"
+
+ return IPClassifyResult(
+ country_code=utils.trim(data.get("country_code", "")).upper(),
+ company_type=company_type,
+ asn_type=asn_type,
+ score=self._parse_trust_score(data.get("fraud_score"), invert=True),
+ raw=data,
+ )
+
+ @staticmethod
+ def _extract_data(content: str) -> dict:
+ if not content or not isinstance(content, str):
+ return {}
+
+ pattern = r']*class=["\'][^"\']*\blanguage-json\b[^"\']*["\'][^>]*>(.*?)\s*'
+ groups = re.findall(pattern, content, flags=re.I | re.S)
+ if not groups:
+ return {}
+
+ for group in groups:
+ payload = utils.trim(group)
+ if not payload:
+ continue
+
+ payload = re.sub(r"<[^>]+>", "", payload, flags=re.I | re.S)
+ payload = html.unescape(payload)
+
+ try:
+ data = json.loads(payload)
+ except Exception:
+ continue
+
+ if isinstance(data, dict) and data:
+ return data
+
+ return {}
+
+
+class IPLarkLibrary(IPLibrary):
+ name = "iplark"
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ response = self._get(request, port, "https://iplark.com/ipapi/public/ipinfo", max_retries, timeout)
+ return response if isinstance(response, dict) else {}
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ node_type = utils.trim(data.get("type", "")).lower()
+ if node_type == "isp":
+ company_type, asn_type = "isp", "isp"
+ elif node_type == "business":
+ company_type, asn_type = "business", "business"
+ else:
+ company_type, asn_type = "hosting", "hosting"
+
+ return IPClassifyResult(
+ country_code=utils.trim(data.get("country_code", "")).upper(),
+ company_type=company_type,
+ asn_type=asn_type,
+ raw=data,
+ )
+
+
+class IPInfoLibrary(IPLibrary):
+ name = "ipinfo"
+ needs_egress_ip = True
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ ip = self._resolve_egress_ip(port, request, max_retries=max_retries, timeout=timeout, ip=ip)
+ if not ip:
+ return {}
+
+ url = f"https://ipinfo.io/widget/demo/{ip}"
+ response = self._get(request, port, url, max_retries, timeout)
+ if not isinstance(response, dict):
+ return {}
+
+ data = response.get("data", response)
+ return data if isinstance(data, dict) else {}
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ company = data.get("company", {}) if isinstance(data.get("company"), dict) else {}
+ asn = data.get("asn", {}) if isinstance(data.get("asn"), dict) else {}
+ return IPClassifyResult(
+ country_code=utils.trim(data.get("country", "")).upper(),
+ company_type=utils.trim(company.get("type", "")).lower(),
+ asn_type=utils.trim(asn.get("type", "")).lower(),
+ raw=data,
+ )
+
+
+class IPApiLibrary(IPLibrary):
+ name = "ipapi"
+
+ def __init__(self, api_key: str = ""):
+ self.api_key = utils.trim(api_key)
+
+ def fetch(self, port: int, request, max_retries: int = 2, timeout: int = 12, ip: str = "") -> dict:
+ url = "https://api.ipapi.is"
+ if self.api_key:
+ url += f"?key={self.api_key}"
+
+ response = self._get(request, port, url, max_retries, timeout)
+ return response if isinstance(response, dict) else {}
+
+ def classify(self, data: dict) -> IPClassifyResult:
+ data = data if isinstance(data, dict) else {}
+ location = data.get("location", {}) if isinstance(data.get("location"), dict) else {}
+ company = data.get("company", {}) if isinstance(data.get("company"), dict) else {}
+ asn = data.get("asn", {}) if isinstance(data.get("asn"), dict) else {}
+ return IPClassifyResult(
+ country_code=utils.trim(location.get("country_code", "")).upper(),
+ company_type=utils.trim(company.get("type", "")).lower(),
+ asn_type=utils.trim(asn.get("type", "")).lower(),
+ raw=data,
+ )
+
+
+PROVIDERS_ORDER = ["ipnetcoffee", "meowvps", "ippure", "ip2location", "ipinfo"]
+
+LIBRARIES = {
+ "ipnetcoffee": IPNetCoffeeLibrary,
+ "meowvps": MeowVPSLibrary,
+ "ippure": IPPureLibrary,
+ "ip2location": IP2LocationLibrary,
+ "iplark": IPLarkLibrary,
+ "ipinfo": IPInfoLibrary,
+ "ipapi": IPApiLibrary,
+}
+
+
+def get_providers(preferred: str) -> list[str]:
+ library = utils.trim(preferred).lower()
+ if library not in PROVIDERS_ORDER:
+ if library != "":
+ logger.warning(f"IP library {library} is not be supported")
+
+ library = "ipnetcoffee"
+
+ return [library] + [item for item in PROVIDERS_ORDER if item != library]
+
+
+def create_library(name: str, api_key: str = "") -> IPLibrary:
+ key = utils.trim(name).lower()
+ if key not in LIBRARIES:
+ key = "ipnetcoffee"
+
+ cls = LIBRARIES[key]
+ if cls is IPApiLibrary:
+ return IPApiLibrary(api_key=api_key)
+
+ return cls()
diff --git a/subscribe/location.py b/subscribe/location.py
index 1a00668397..e4a9d9940f 100644
--- a/subscribe/location.py
+++ b/subscribe/location.py
@@ -3,35 +3,49 @@
# @Author : wzdnzd
# @Time : 2024-07-12
+import gzip
+import http.client
import json
import math
import os
import random
import re
import socket
+import ssl
import subprocess
-import sys
import time
-import urllib
+import urllib.parse
from collections import defaultdict
from dataclasses import dataclass
+from functools import partial
+from typing import Optional
import utils
import yaml
from executable import which_bin
from geoip2 import database
+from iplibrary import create_library, get_providers, resolve_egress_ipv4
from logger import logger
from clash import is_mihomo
@dataclass
-class ProxyInfo:
- """Proxy query result information"""
+class GeoInfo:
+ """Country and CDN attributes of an IP"""
- name: str = ""
country: str = ""
+ is_cdn: bool = False
+
+
+@dataclass
+class ProxyInfo(GeoInfo):
+ """Proxy query result, including geo attributes"""
+
+ name: str = ""
ip_type: str = ""
+ score: Optional[int] = None
+ provider: str = ""
@dataclass
@@ -296,6 +310,34 @@ class ProxyQueryResult:
}
+# Pattern for CDN providers and Loyalsoldier custom ISO codes
+CDN_PATTERN = r"cloudflare|cloudfront|fastly|google"
+_CDN_NAME_RE = re.compile(CDN_PATTERN, flags=re.I)
+
+
+def is_cdn_label(value: str) -> bool:
+ """Return True if a country name or ISO code refers to a CDN instead of a location"""
+ text = utils.trim(value)
+ return bool(text and _CDN_NAME_RE.search(text))
+
+
+def _mark_cdn(proxy: dict) -> None:
+ if isinstance(proxy, dict):
+ proxy["cdn"] = True
+
+
+def _is_cdn_proxy(proxy: dict) -> bool:
+ return isinstance(proxy, dict) and (bool(proxy.get("cdn")) or is_cdn_label(str(proxy.get("name", ""))))
+
+
+def _remove_temp_flags(proxies: list[dict]) -> list[dict]:
+ for proxy in proxies:
+ if isinstance(proxy, dict):
+ proxy.pop("cdn", None)
+ proxy.pop("renamed", None)
+ return proxies
+
+
def download_mmdb(repo: str, target: str, filepath: str, retry: int = 3) -> bool:
"""
Download GeoLite2-City.mmdb from github release
@@ -387,48 +429,51 @@ def load_mmdb(
return database.Reader(filepath)
-def query_ip_country(ip: str, reader: database.Reader) -> str:
+def lookup_ip_geo(ip: str, reader: database.Reader) -> GeoInfo:
"""
Query country information for an IP address using mmdb database
- Args:
- ip: The IP address to query
- reader: The mmdb database reader
-
- Returns:
- The country name in Chinese
+ CDN ranges such as Cloudflare are reported via is_cdn and never as a country
"""
if not ip or not reader:
- return ""
+ return GeoInfo()
try:
# fake ip
if ip.startswith("198.18.0."):
logger.warning("cannot get geolocation because IP address is faked")
- return ""
+ return GeoInfo()
response = reader.country(ip)
+ names = response.country.names or {}
+ iso_code = utils.trim(response.country.iso_code).upper()
+ country = utils.trim(names.get("zh-CN", ""))
- # Try to get country name in Chinese
- country = response.country.names.get("zh-CN", "")
-
- # If Chinese name is not available, try to convert ISO code to Chinese country name
- if not country and response.country.iso_code:
- iso_code = response.country.iso_code
- # Try to get Chinese country name from ISO code mapping
+ if not country and iso_code:
country = ISO_TO_CHINESE.get(iso_code, iso_code)
- # Special handling for well-known IPs
- if not country:
- if ip == "1.1.1.1" or ip == "1.0.0.1":
- country = "Cloudflare"
- elif ip.startswith("8.8.8.") or ip.startswith("8.8.4."):
- country = "Google"
+ well_known_cdn = ip in ("1.1.1.1", "1.0.0.1") or ip.startswith("8.8.8.") or ip.startswith("8.8.4.")
+ if well_known_cdn or is_cdn_label(iso_code) or is_cdn_label(country):
+ return GeoInfo(is_cdn=True)
- return country
+ return GeoInfo(country=country)
except Exception as e:
logger.error(f"query ip country failed, ip: {ip}, error: {str(e)}")
- return ""
+ return GeoInfo()
+
+
+def query_ip_country(ip: str, reader: database.Reader) -> str:
+ """
+ Query country information for an IP address using mmdb database
+
+ Args:
+ ip: The IP address to query
+ reader: The mmdb database reader
+
+ Returns:
+ The country name in Chinese
+ """
+ return lookup_ip_geo(ip, reader).country
def locate_by_geoip(proxy: dict, reader: database.Reader) -> dict:
@@ -446,258 +491,82 @@ def locate_by_geoip(proxy: dict, reader: database.Reader) -> dict:
return proxy
ip = socket.gethostbyname(address)
- country = query_ip_country(ip, reader)
-
- if country:
- proxy["name"] = country
+ geo = lookup_ip_geo(ip, reader)
+ if geo.is_cdn:
+ _mark_cdn(proxy)
+ logger.debug(f"server IP belongs to CDN, skip as location, address: {address}")
+ elif geo.country:
+ proxy["name"] = geo.country
proxy["renamed"] = True
else:
- logger.warning(f"cannot get geolocation and rename, address: {address}")
+ logger.warning(f"cannot get geolocation and name, address: {address}")
except Exception as e:
logger.error(f"query ip geolocation failed, address: {address}, error: {str(e)}")
return proxy
-# Cache for checked port statuses
-_PORT_STATUS_CACHE = {}
-_AVAILABLE_PORTS = set()
-
-
-def get_listening_ports() -> set:
- """Get the set of listening ports in the system, cross-platform compatible"""
- listening_ports = set()
-
- try:
- # Windows system
- if os.name == "nt":
- try:
- # Use 'cp437' encoding to handle Windows command line output
- output = subprocess.check_output("netstat -an", shell=True).decode("cp437", errors="replace")
- for line in output.split("\n"):
- if "LISTENING" in line:
- parts = line.split()
- if len(parts) >= 2:
- addr_port = parts[1]
- if ":" in addr_port:
- try:
- port = int(addr_port.split(":")[-1])
- listening_ports.add(port)
- except ValueError:
- pass
- except Exception as e:
- logger.warning(f"Windows netstat command failed: {str(e)}")
- return listening_ports
-
- # macOS system
- elif sys.platform == "darwin":
- try:
- output = subprocess.check_output("lsof -i -P -n | grep LISTEN", shell=True).decode(
- "utf-8", errors="replace"
- )
- for line in output.split("\n"):
- if ":" in line:
- try:
- port_part = line.split(":")[-1].split(" ")[0]
- port = int(port_part)
- listening_ports.add(port)
- except (ValueError, IndexError):
- pass
- except Exception as e:
- logger.warning(f"macOS lsof command failed: {str(e)}")
- return listening_ports
-
- # Linux and other systems
- else:
- # Try using ss command (newer Linux systems)
- try:
- output = subprocess.check_output("ss -tuln", shell=True).decode("utf-8", errors="replace")
- for line in output.split("\n"):
- if "LISTEN" in line:
- parts = line.split()
- for part in parts:
- if ":" in part:
- try:
- port = int(part.split(":")[-1])
- listening_ports.add(port)
- except ValueError:
- pass
- except Exception as e:
- logger.warning(f"Linux ss command failed, trying netstat: {str(e)}")
- # Fall back to netstat command (older Linux systems)
- try:
- output = subprocess.check_output("netstat -tuln", shell=True).decode("utf-8", errors="replace")
- for line in output.split("\n"):
- if "LISTEN" in line:
- parts = line.split()
- for part in parts:
- if ":" in part:
- try:
- port = int(part.split(":")[-1])
- listening_ports.add(port)
- except ValueError:
- pass
- except Exception as e:
- logger.warning(f"Linux netstat command also failed: {str(e)}")
- return listening_ports
- except Exception as e:
- logger.warning(f"Failed to get listening ports: {str(e)}")
-
- return listening_ports
-
-
-def scan_ports_batch(start_port: int, count: int = 100) -> dict:
- """Batch scan port statuses, return a dictionary of port statuses"""
- global _PORT_STATUS_CACHE, _AVAILABLE_PORTS
-
- # Create a list of ports to scan (excluding ports with known status)
- ports_to_scan = [p for p in range(start_port, start_port + count) if p not in _PORT_STATUS_CACHE]
-
- if not ports_to_scan:
- # If all ports are already cached, return cached results directly
- return {p: _PORT_STATUS_CACHE.get(p, True) for p in range(start_port, start_port + count)}
-
- # Use a more efficient way to check ports in batch
- results = {}
+class PortReservation:
+ """Reserve local TCP ports by binding them without listen/connect."""
- try:
- # Get the ports that are currently listening in the system
- listening_ports = get_listening_ports()
-
- # Update results
- for port in ports_to_scan:
- in_use = port in listening_ports
- results[port] = in_use
- _PORT_STATUS_CACHE[port] = in_use
- if not in_use:
- _AVAILABLE_PORTS.add(port)
- except Exception as e:
- logger.warning(f"Batch port scanning failed, falling back to individual port checks: {str(e)}")
- # If batch checking fails, fall back to individual port checks
- for port in ports_to_scan:
- in_use = check_single_port(port)
- results[port] = in_use
- _PORT_STATUS_CACHE[port] = in_use
- if not in_use:
- _AVAILABLE_PORTS.add(port)
-
- # Merge cached and newly scanned results
- return {
- **{
- p: _PORT_STATUS_CACHE.get(p, True) for p in range(start_port, start_port + count) if p in _PORT_STATUS_CACHE
- },
- **results,
- }
+ def __init__(self):
+ self._sockets = []
+ def reserve(self, n: int) -> list[int]:
+ if n <= 0:
+ return []
-def check_single_port(port: int) -> bool:
- """Helper function for checking a single port, checks if the port is listening"""
- try:
- # Use socket to check TCP port
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(0.2)
- result = sock.connect_ex(("127.0.0.1", port))
- sock.close()
- if result == 0:
- return True
-
- # Also check IPv6
+ ports = []
try:
- sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
- sock.settimeout(0.2)
- result = sock.connect_ex(("::1", port))
- sock.close()
- return result == 0
- except:
- pass
-
- return False
- except:
- # Assume port is not in use when an error occurs
- return False
-
-
-def is_port_in_use(port: int) -> bool:
- """Check if a port is in use (using cache)"""
- global _PORT_STATUS_CACHE, _AVAILABLE_PORTS
-
- # If port is known to be available, return directly
- if port in _AVAILABLE_PORTS:
- return False
-
- # If port status is already cached, return directly
- if port in _PORT_STATUS_CACHE:
- return _PORT_STATUS_CACHE[port]
-
- # Otherwise check the port and cache the result
- in_use = check_single_port(port)
- _PORT_STATUS_CACHE[port] = in_use
- if not in_use:
- _AVAILABLE_PORTS.add(port)
- return in_use
+ for _ in range(n):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ self._sockets.append(sock)
+ ports.append(sock.getsockname()[1])
+ return ports
+ except Exception:
+ self.close()
+ raise
+
+ def close(self) -> None:
+ sockets, self._sockets = self._sockets, []
+ for sock in sockets:
+ try:
+ sock.close()
+ except Exception:
+ pass
-def generate_mihomo_config(proxies: list[dict]) -> tuple[dict, dict]:
+def generate_mihomo_config(proxies: list[dict], listener_ports: list[int], mixed_port: int) -> tuple[dict, dict]:
"""Generate mihomo configuration for the given proxies"""
- # Base configuration
config = {
- "mixed-port": 7890,
+ "mixed-port": mixed_port,
"allow-lan": True,
"mode": "global",
"log-level": "error",
+ "ipv6": False,
+ "tcp-concurrent": True,
"proxies": proxies,
"dns": {
"enable": True,
- "enhanced-mode": "fake-ip",
- "fake-ip-range": "198.18.0.1/16",
+ "ipv6": False,
+ "enhanced-mode": "redir-host",
"default-nameserver": ["114.114.114.114", "223.5.5.5", "8.8.8.8"],
- "nameserver": ["https://doh.pub/dns-query"],
+ "nameserver": ["114.114.114.114", "8.8.8.8"],
},
"listeners": [],
}
- # Record the port assigned to each proxy
records = dict()
-
- # If there are no proxies, return directly
if not proxies:
return config, records
- # Pre-scan ports in batch to improve efficiency
- start_port = 32001
-
- # Scan enough ports to ensure there are sufficient available ports
- port_count = len(proxies) * 2
- port_status = scan_ports_batch(start_port, port_count)
-
- # Find all available ports
- available_ports = [p for p, in_use in port_status.items() if not in_use]
-
- # If available ports are insufficient, scan more ports
- if len(available_ports) < len(proxies):
- additional_ports = scan_ports_batch(start_port + port_count, port_count * 2)
- available_ports.extend([p for p, in_use in additional_ports.items() if not in_use])
-
- # Assign an available port to each proxy
for index, proxy in enumerate(proxies):
- if index < len(available_ports):
- port = available_ports[index]
- else:
- # If available ports are insufficient, use traditional method to find available ports
- port = start_port + port_count + index
- max_attempts = 1000
- attempts = 0
-
- while is_port_in_use(port) and attempts < max_attempts:
- port += 1
- attempts += 1
-
- if attempts >= max_attempts:
- logger.warning(
- f"Could not find an available port for proxy {proxy['name']} after {max_attempts} attempts"
- )
- continue
+ if index >= len(listener_ports):
+ logger.warning(f"No reserved port for proxy {proxy['name']}")
+ continue
+ port = listener_ports[index]
listener = {
"name": f"http-{index}",
"type": "http",
@@ -712,6 +581,223 @@ def generate_mihomo_config(proxies: list[dict]) -> tuple[dict, dict]:
return config, records
+def _idna_host(host: str) -> str:
+ host = utils.trim(host)
+ if not host:
+ return host
+ try:
+ return host.encode("idna").decode("ascii")
+ except Exception:
+ return host
+
+
+def _origin_headers(url: str, extra: dict = None) -> dict:
+ parsed = urllib.parse.urlparse(url)
+ base = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else ""
+ result = {
+ "User-Agent": utils.USER_AGENT,
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "identity",
+ "Cache-Control": "no-cache",
+ "Pragma": "no-cache",
+ "Connection": "close",
+ "Referer": f"{base}/" if base else url,
+ "Origin": base if base else url,
+ }
+ if extra and isinstance(extra, dict):
+ for key, value in extra.items():
+ name = utils.trim(str(key))
+ if not name:
+ continue
+ if value is None:
+ result.pop(name, None)
+ else:
+ result[name] = value
+ return result
+
+
+def _proxy_ssl_context() -> ssl.SSLContext:
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ ctx.options |= ssl.OP_NO_TICKET
+ if hasattr(ssl, "OP_IGNORE_UNEXPECTED_EOF"):
+ ctx.options |= ssl.OP_IGNORE_UNEXPECTED_EOF
+ try:
+ ctx.set_alpn_protocols(["http/1.1"])
+ except Exception:
+ pass
+ return ctx
+
+
+def _recv_until(sock: socket.socket, marker: bytes, limit: int = 65536) -> bytes:
+ buf = bytearray()
+ while marker not in buf:
+ chunk = sock.recv(min(4096, max(1, limit - len(buf))))
+ if not chunk:
+ raise OSError(f"tunnel: incomplete proxy response: {bytes(buf)!r}")
+ buf += chunk
+ if len(buf) > limit:
+ raise OSError("tunnel: proxy response too large")
+ index = buf.find(marker) + len(marker)
+ if index < len(buf):
+ raise OSError("tunnel: unexpected data after CONNECT")
+ return bytes(buf[:index])
+
+
+def _tls_server_hostname(host: str) -> Optional[str]:
+ text = _idna_host(host)
+ if not text:
+ return None
+ try:
+ socket.inet_pton(socket.AF_INET, text)
+ return None
+ except OSError:
+ pass
+ if ":" in text:
+ try:
+ socket.inet_pton(socket.AF_INET6, text.strip("[]"))
+ return None
+ except OSError:
+ pass
+ return text
+
+
+def _open_http_tunnel(sock: socket.socket, host: str, port: int) -> None:
+ host = _idna_host(host)
+ target = f"{host}:{port}"
+ # Do not send Connection/Proxy-Connection: close. Mihomo hijacks after 200;
+ # a close flag makes Windows RST the socket (WinError 10054) at TLS.
+ sock.sendall(f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n".encode("ascii"))
+ head = _recv_until(sock, b"\r\n\r\n")
+ status_line = head.split(b"\r\n", 1)[0].decode("iso-8859-1", "replace")
+ parts = status_line.split(" ", 2)
+ if len(parts) < 2:
+ raise OSError(f"tunnel: invalid CONNECT response: {status_line}")
+ try:
+ code = int(parts[1])
+ except ValueError as e:
+ raise OSError(f"tunnel: invalid CONNECT status: {status_line}") from e
+ if code != 200:
+ raise OSError(f"tunnel: CONNECT failed: {status_line}")
+
+
+def _assert_tunnel_open(sock: socket.socket, wait: float = 0.2) -> None:
+ """Fail fast if the CONNECT already died; otherwise wait out mihomo's peek."""
+ previous = sock.gettimeout()
+ sock.settimeout(max(wait, 0.01))
+ try:
+ peeked = sock.recv(1, socket.MSG_PEEK)
+ if not peeked:
+ raise OSError("tunnel: closed before TLS")
+ except (TimeoutError, socket.timeout):
+ return
+ except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as e:
+ raise OSError(f"tunnel: closed before TLS ({e})") from e
+ finally:
+ sock.settimeout(previous)
+
+
+def _http_get_on_sock(sock: socket.socket, host: str, path: str, headers: dict) -> tuple[int, str, bytes]:
+ host = _idna_host(host)
+ lines = [f"GET {path} HTTP/1.1", f"Host: {host}"]
+ sent = {"host"}
+ for key, value in (headers or {}).items():
+ name = utils.trim(str(key))
+ if not name or name.lower() in sent or name.lower().startswith("proxy-"):
+ continue
+ lines.append(f"{name}: {value}")
+ sent.add(name.lower())
+ if "connection" not in sent:
+ lines.append("Connection: close")
+
+ sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("latin-1"))
+ response = http.client.HTTPResponse(sock, method="GET")
+ try:
+ response.begin()
+ return response.status, utils.trim(response.getheader("Location", "")), response.read()
+ finally:
+ try:
+ response.close()
+ except Exception:
+ pass
+
+
+def _decode_body(body: bytes) -> str:
+ if not body:
+ return ""
+ if body[:2] == b"\x1f\x8b":
+ try:
+ body = gzip.decompress(body)
+ except Exception:
+ pass
+ for encoding in ("utf-8", "gbk"):
+ try:
+ return body.decode(encoding)
+ except Exception:
+ continue
+ return body.decode("utf-8", "replace")
+
+
+def _request_through_proxy(port: int, url: str, headers: dict, timeout: int, redirects: int = 3) -> tuple[int, bytes]:
+ """
+ Fetch URL via mihomo HTTP inbound.
+
+ http:// → absolute-form GET (plain HTTP proxy).
+ https:// → CONNECT then TLS in Python. Do not send `GET https://...`:
+ mihomo handles that with client.Do + TLS on net.Pipe, which races the
+ outbound dial and comes back as HTTP 502.
+ """
+ parsed = urllib.parse.urlparse(url)
+ scheme = utils.trim(parsed.scheme).lower()
+ host = parsed.hostname
+ if not host or scheme not in ("http", "https"):
+ raise OSError(f"http: unsupported url: {url}")
+
+ dst_port = parsed.port or (443 if scheme == "https" else 80)
+ path = parsed.path or "/"
+ if parsed.query:
+ path += "?" + parsed.query
+
+ sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
+ wrapped = None
+ try:
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ sock.settimeout(timeout)
+ target = sock
+ request_path = url
+ if scheme == "https":
+ _open_http_tunnel(sock, host, dst_port)
+ _assert_tunnel_open(sock)
+ try:
+ wrapped = _proxy_ssl_context().wrap_socket(
+ sock,
+ server_hostname=_tls_server_hostname(host),
+ suppress_ragged_eofs=True,
+ )
+ except OSError as e:
+ raise OSError(f"tls: {e}") from e
+ target = wrapped
+ request_path = path
+ status, location, body = _http_get_on_sock(target, host, request_path, headers)
+ finally:
+ for item in (wrapped, sock):
+ if item is None:
+ continue
+ try:
+ item.close()
+ except Exception:
+ pass
+
+ if status in (301, 302, 303, 307, 308) and redirects > 0 and location:
+ text = urllib.parse.urljoin(url, location)
+ if text.lower().startswith("http"):
+ return _request_through_proxy(port, text, _origin_headers(text, headers), timeout, redirects - 1)
+
+ return status, body
+
+
def make_proxy_request(
port: int,
url: str,
@@ -719,6 +805,7 @@ def make_proxy_request(
timeout: int = 10,
headers: dict = None,
deserialize: bool = True,
+ quiet: bool = False,
) -> tuple[bool, dict]:
"""
Make an HTTP request through a proxy and return the response
@@ -739,41 +826,26 @@ def make_proxy_request(
logger.warning("No port provided for proxy")
return False, {}
- # Configure the proxy for the request
- proxy_url = f"http://127.0.0.1:{port}"
- proxies_config = {"http": proxy_url, "https": proxy_url}
-
- # Configure proxy handler
- proxy_handler = urllib.request.ProxyHandler(proxies_config)
-
- # Build opener with proxy handler
- opener = urllib.request.build_opener(proxy_handler)
- if headers and isinstance(headers, dict):
- opener.addheaders = [(k, v) for k, v in headers.items() if k]
- else:
- opener.addheaders = [
- ("User-Agent", utils.USER_AGENT),
- ("Accept", "application/json"),
- ("Connection", "close"),
- ]
-
- # Try to get response with retry and backoff
+ default_headers = _origin_headers(url, headers)
attempt, success, data = 0, False, None
while not success and attempt < max(max_retries, 1):
try:
- # Random sleep to avoid being blocked by the API (increasing with each retry)
if attempt > 0:
wait_time = min(2**attempt * random.uniform(0.5, 1.5), 6)
time.sleep(wait_time)
- # Make request
- response = opener.open(url, timeout=timeout)
- if response.getcode() == 200:
- content = response.read().decode("utf-8")
- data = json.loads(content) if deserialize else content
- success = True
+ status, body = _request_through_proxy(port, url, default_headers, timeout)
+ if status != 200:
+ raise OSError(f"http: status {status}")
+ content = _decode_body(body)
+ data = json.loads(content) if deserialize else content
+ success = True
except Exception as e:
- logger.warning(f"Attempt {attempt+1} failed to request {url} through proxy port {port}: {str(e)}")
+ message = f"Attempt {attempt+1} failed to request {url} through proxy port {port}: {str(e)}"
+ if quiet or attempt + 1 < max(max_retries, 1):
+ logger.debug(message)
+ else:
+ logger.warning(message)
attempt += 1
@@ -781,44 +853,52 @@ def make_proxy_request(
def get_ipv4(port: int, max_retries: int = 5) -> str:
- """
- Get the IPv4 address by accessing https://api.ipify.org?format=json through a proxy
-
- Args:
- port: The port of the proxy
- max_retries: Maximum number of retry attempts
-
- Returns:
- The IPv4 address or empty string if failed
- """
+ """Get the egress IPv4 address through a proxy listener."""
if not port:
logger.warning("No port provided for proxy")
return ""
- success, data = make_proxy_request(port=port, url="https://api.ipify.org?format=json", max_retries=max_retries)
- return data.get("ip", "") if success else ""
+ return resolve_egress_ipv4(port, make_proxy_request, max_retries=max_retries, timeout=10)
+
+
+def _wait_listener(port: int, process: subprocess.Popen, timeout: int = 20) -> None:
+ deadline = time.time() + max(timeout, 1)
+ while time.time() < deadline:
+ if process.poll() is not None:
+ raise RuntimeError(f"mihomo exited before becoming ready, code={process.returncode}")
+ try:
+ sock = socket.create_connection(("127.0.0.1", port), timeout=1)
+ sock.close()
+ return
+ except OSError:
+ time.sleep(0.2)
+ raise RuntimeError(f"mihomo listener 127.0.0.1:{port} not ready after {timeout}s")
# Online API services for IP location
LOCATION_API_SERVICES = [
{"url": "https://ipinfo.io", "country_key": "country"},
- {"url": "https://ipapi.co/json/", "country_key": "country_code"},
+ {"url": "https://api.ip2location.io", "country_key": "country_code"},
{"url": "https://ipwho.is", "country_key": "country_code"},
- {"url": "https://freeipapi.com/api/json", "country_key": "countryCode"},
- {"url": "https://api.country.is", "country_key": "country"},
+ {"url": "https://free.freeipapi.com/api/json", "country_key": "countryCode"},
{"url": "https://api.ip.sb/geoip", "country_key": "country_code"},
]
-# Pattern for CDN providers
-CDN_PATTERN = r"cloudflare|cloudfront|fastly|google"
-
def random_delay(min_delay: float = 0.01, max_delay: float = 0.5):
"""Random delay to avoid API rate limiting"""
time.sleep(random.uniform(min_delay, max_delay))
-def check_residential(proxy: dict, port: int, api_key: str = "", use_ipinfo: bool = True) -> ProxyQueryResult:
+def check_residential(
+ proxy: dict,
+ port: int,
+ api_key: str = "",
+ ip_library: str = "ipnetcoffee",
+ reader: database.Reader = None,
+ max_retries: int = 2,
+ timeout: int = 12,
+) -> ProxyQueryResult:
"""
Check if a proxy is residential by making a request through it
@@ -826,40 +906,14 @@ def check_residential(proxy: dict, port: int, api_key: str = "", use_ipinfo: boo
proxy: The proxy information dict
port: The port of the proxy
api_key: Optional API key for ipapi.is. Uses free tier if not provided
- use_ipinfo: Whether to use ipinfo.io instead of ipapi.is, defaults to True
+ ip_library: IP query provider, supported: ipnetcoffee/meowvps/ippure/ip2location/iplark/ipinfo/ipapi
+ reader: Optional mmdb reader used to detect CDN egress IPs
+ max_retries: Retry count for provider queries
+ timeout: Timeout in seconds for provider queries
Returns:
ProxyQueryResult: Complete proxy query result
"""
-
- def _get_ipapi_url(key: str = "") -> str:
- url, key = "https://api.ipapi.is", utils.trim(key)
- if key:
- url += f"?key={key}"
- return url
-
- def _get_ipinfo_url(port: int, name: str) -> str:
- # First, get the IP address
- success, content = make_proxy_request(
- port=port,
- url="https://ipinfo.io/ip",
- max_retries=2,
- timeout=15,
- deserialize=False,
- )
- if not success or not content:
- logger.warning(f"Failed to get IP from ipinfo.io for proxy {name}")
- return ""
-
- # Extract IP from response
- ip = utils.trim(content)
- if not ip:
- logger.warning(f"Invalid IP address from ipinfo.io for proxy {name}")
- return ""
-
- # Now get detailed information using the IP
- return f"https://ipinfo.io/widget/demo/{ip}"
-
name = proxy.get("name", "")
result = ProxyInfo(name=name)
@@ -867,49 +921,91 @@ def _get_ipinfo_url(port: int, name: str) -> str:
logger.warning(f"No port found for proxy {name}")
return ProxyQueryResult(proxy=proxy, result=result, success=False)
- # Random delay to avoid being blocked by the API
random_delay()
try:
- url = ""
- if use_ipinfo:
- url = _get_ipinfo_url(port=port, name=name)
-
- if not url:
- url = _get_ipapi_url(key=api_key)
- use_ipinfo = False
-
- # Call API for IP information through the proxy
- success, response = make_proxy_request(port=port, url=url, max_retries=2, timeout=12)
-
- # Parse data from response
- if success:
- try:
- data = response.get("data", {}) if use_ipinfo else response
+ providers = get_providers(ip_library)
+ classified, provider = None, ""
+ egress_ip = None
+ request = partial(make_proxy_request, quiet=True)
+
+ def _cached_egress_ip() -> str:
+ nonlocal egress_ip
+ if egress_ip is None:
+ egress_ip = resolve_egress_ipv4(
+ port,
+ request,
+ max_retries=max_retries,
+ timeout=max(timeout, 15),
+ )
+ if not egress_ip:
+ logger.debug(f"Failed to get egress IP for proxy {name}")
+ return egress_ip
- # Extract country code from data
- if use_ipinfo:
- country_code = data.get("country", "")
+ if reader:
+ ip = _cached_egress_ip()
+ if ip and lookup_ip_geo(ip, reader).is_cdn:
+ result.is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"Egress IP for proxy {name} belongs to CDN, continue locating")
+ return ProxyQueryResult(proxy=proxy, result=result, success=False)
+
+ for idx, item in enumerate(providers):
+ library = create_library(item, api_key=api_key)
+ ip = _cached_egress_ip() if library.needs_egress_ip else ""
+ if library.needs_egress_ip and not ip:
+ if idx < len(providers) - 1:
+ fallback = providers[idx + 1]
+ logger.debug(f"Skip {item} for proxy {name}, no egress IP, trying fallback: {fallback}")
else:
- country_code = data.get("location", {}).get("country_code", "")
+ logger.debug(f"Skip {item} for proxy {name}, no egress IP")
+ continue
- result.country = ISO_TO_CHINESE.get(country_code, "") if country_code else ""
+ data = library.fetch(port, request, max_retries=max_retries, timeout=timeout, ip=ip)
+ if data:
+ provider = item
+ classified = library.classify(data)
+ logger.debug(f"IP info for proxy {name} successfully retrieved, provider: {provider}")
+ break
- company_type = data.get("company", {}).get("type", "")
- asn_type = data.get("asn", {}).get("type", "")
+ if idx < len(providers) - 1:
+ fallback = providers[idx + 1]
+ logger.debug(f"Failed to query {item} for proxy {name}, trying fallback: {fallback}")
+ else:
+ logger.debug(f"Failed to query {item} for proxy {name}")
- # Check if it's residential (both company and asn type should be "isp")
+ if classified:
+ try:
+ result.provider = utils.trim(provider)
+ country_code = utils.trim(classified.country_code).upper()
+ if country_code:
+ result.country = ISO_TO_CHINESE.get(country_code, "")
+
+ if not result.country:
+ raw = classified.raw or {}
+ result.country = utils.trim(
+ raw.get("country_zh", "") or raw.get("country", "") or raw.get("country_name", "")
+ )
+
+ company_type = utils.trim(classified.company_type).lower()
+ asn_type = utils.trim(classified.asn_type).lower()
if company_type == "isp" and asn_type == "isp":
result.ip_type = "isp"
elif company_type in ["business", "isp"] and asn_type in ["business", "isp"]:
result.ip_type = "business"
+ result.score = classified.score
except Exception as e:
- logger.error(f"Error parsing {url} response for proxy {name}: {str(e)}")
+ logger.error(f"Error parsing response for proxy {name}: {str(e)}")
else:
- logger.warning(f"Failed to query {url} for proxy {name}")
+ logger.warning(f"Failed to query residential info for proxy {name} with providers: {providers}")
+
+ if is_cdn_label(result.country):
+ result.is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"Residential country for proxy {name} is CDN, continue locating")
+ return ProxyQueryResult(proxy=proxy, result=result, success=False)
- # Determine if query was successful
flag = result.country != "" or result.ip_type != ""
return ProxyQueryResult(proxy=proxy, result=result, success=flag)
@@ -920,88 +1016,65 @@ def _get_ipinfo_url(port: int, name: str) -> str:
def locate_by_ipinfo(proxy: dict, port: int, reader: database.Reader = None) -> ProxyQueryResult:
"""Check the location of a single proxy by making a request through it"""
+ name = proxy.get("name", "")
+
+ is_cdn = _is_cdn_proxy(proxy)
- def _create_failed_result(reason: str = "") -> ProxyQueryResult:
- """Helper to create failed query result"""
- name = proxy.get("name", "")
+ def _failed(reason: str = "") -> ProxyQueryResult:
if reason:
logger.warning(f"Location query failed for proxy {name}: {reason}")
- return ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=name), success=False)
+ if is_cdn:
+ _mark_cdn(proxy)
+ return ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=name, is_cdn=is_cdn), success=False)
- def _create_success_result(country: str) -> ProxyQueryResult:
- """Helper to create successful query result"""
- info = ProxyInfo(name=proxy.get("name", ""))
- info.country = country
+ def _success(country: str) -> ProxyQueryResult:
+ info = ProxyInfo(name=name, country=country)
return ProxyQueryResult(proxy=proxy, result=info, success=True)
- def _try_local_mmdb_lookup() -> str:
- """Attempt to get country from local MMDB database"""
- if not reader:
- return ""
+ if not port:
+ return _failed("No port specified")
- ip = get_ipv4(port=port, max_retries=2)
- if ip:
- return query_ip_country(ip, reader) or ""
- return ""
+ random_delay()
- def _try_online_api_services() -> str:
- """Attempt to get country from online API services with retry logic"""
- retries = 3
+ try:
+ if reader:
+ ip = get_ipv4(port=port, max_retries=2)
+ geo = lookup_ip_geo(ip, reader) if ip else GeoInfo()
+ if geo.is_cdn:
+ is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"Egress IP for proxy {name} belongs to CDN, try online APIs")
+ elif geo.country:
+ logger.debug(f"Location found via MMDB for proxy {name}: {geo.country}")
+ return _success(geo.country)
+ retries = 3
for attempt in range(retries):
- # Select a random service for this attempt
service = random.choice(LOCATION_API_SERVICES)
-
- # Make the API request
- success, data = make_proxy_request(port=port, url=service["url"], max_retries=1, timeout=12)
-
+ success, data = make_proxy_request(port=port, url=service["url"], max_retries=1, timeout=12, quiet=True)
if success and data:
- # Parse country code from response
- key = service["country_key"]
- code = data.get(key, "")
-
+ code = data.get(service["country_key"], "")
if code:
- # Convert to Chinese country name
- return ISO_TO_CHINESE.get(code, code)
+ country = ISO_TO_CHINESE.get(code, code)
+ if is_cdn_label(code) or is_cdn_label(country):
+ is_cdn = True
+ _mark_cdn(proxy)
+ logger.debug(f"API country for proxy {name} is CDN, continue locating")
+ else:
+ logger.debug(f"Location found via API for proxy {name}: {country}")
+ return _success(country)
- # Handle retry delay for failed attempts
if attempt < retries - 1:
delay = min(2**attempt * random.uniform(1, 2), 6)
- logger.warning(
- f"API attempt {attempt+1} failed for proxy {proxy.get('name', '')} "
- f"using {service['url']}, retrying in {delay:.2f}s"
+ logger.debug(
+ f"API attempt {attempt+1} failed for proxy {name} using {service['url']}, retrying in {delay:.2f}s"
)
time.sleep(delay)
- return ""
-
- # Validate input parameters
- if not port:
- return _create_failed_result("No port specified")
-
- # Apply rate limiting
- random_delay()
-
- # Main location detection logic
- try:
- # Strategy 1: Try local MMDB database first (faster and more reliable)
- country = _try_local_mmdb_lookup()
- if country:
- logger.debug(f"Location found via MMDB for proxy {proxy.get('name', '')}: {country}")
- return _create_success_result(country)
-
- # Strategy 2: Fall back to online API services
- country = _try_online_api_services()
- if country:
- logger.debug(f"Location found via API for proxy {proxy.get('name', '')}: {country}")
- return _create_success_result(country)
-
- # No location detected from any source
- return _create_failed_result("Unable to determine location from any source")
-
+ return _failed("Unable to determine location from any source")
except Exception as e:
- logger.error(f"Unexpected error during location query for {proxy.get('name', '')}: {str(e)}")
- return _create_failed_result(f"Exception: {str(e)}")
+ logger.error(f"Unexpected error during location query for {name}: {str(e)}")
+ return _failed(f"Exception: {str(e)}")
def batch_query(
@@ -1011,8 +1084,6 @@ def batch_query(
show_progress: bool = True,
description: str = "Querying",
digits: int = 2,
- reader: database.Reader = None,
- api_key: str = "",
) -> list[ProxyQueryResult]:
"""
Run mihomo to query proxies information using the specified function
@@ -1024,84 +1095,87 @@ def batch_query(
show_progress: Whether to show progress
description: Description for progress display
digits: Number of digits for proxy naming
- reader: Optional mmdb reader for locate_by_ipinfo
- api_key: Optional API key for check_residential function
Returns:
List of ProxyQueryResult with complete information
"""
- if not proxies or not is_mihomo():
+ if not proxies:
return []
- # Rename proxies for consistent naming
- nodes = rename(proxies, digits, False)
+ if not is_mihomo():
+ return [
+ ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy.get("name", "")), success=False)
+ for proxy in proxies
+ ]
- logger.info(f"Generate clash listeners configuration for {len(nodes)} proxies")
- # Generate mihomo configuration
- config, records = generate_mihomo_config(nodes)
+ nodes = rename(proxies, digits, False)
+ failed = [ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False) for proxy in nodes]
- # Save the configuration to clash/config.yaml in the project directory
workspace = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), "clash")
- config_path = os.path.join(workspace, "config.yaml")
- with open(config_path, "w", encoding="utf-8") as f:
- yaml.dump(config, f, allow_unicode=True)
-
- logger.info(f"Mihomo configuration saved to {config_path}")
-
- # Check if we can find the mihomo binary
mihomo_bin = os.path.join(workspace, which_bin()[0])
if not os.path.exists(mihomo_bin) or not os.path.isfile(mihomo_bin):
logger.error("Mihomo binary not found, skipping proxy check")
- return [ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False) for proxy in nodes]
+ return failed
- # Make the binary executable
utils.chmod(mihomo_bin)
- # Start mihomo with the configuration
- logger.info(f"Starting mihomo with configuration {config_path}")
+ logger.info(f"Generate clash listeners configuration for {len(nodes)} proxies")
+ reservation = PortReservation()
process = None
try:
- # Run mihomo in background
+ ports = reservation.reserve(1 + len(nodes))
+ mixed_port, listener_ports = ports[0], ports[1:]
+ config, records = generate_mihomo_config(nodes, listener_ports, mixed_port)
+
+ config_path = os.path.join(workspace, "config.yaml")
+ with open(config_path, "w", encoding="utf-8") as f:
+ yaml.dump(config, f, allow_unicode=True)
+
+ logger.info(f"Mihomo configuration saved to {config_path}")
+ reservation.close()
+
+ logger.info(f"Starting mihomo with configuration {config_path}")
process = subprocess.Popen(
[mihomo_bin, "-d", workspace, "-f", config_path],
stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
)
- # Wait longer to ensure mihomo is fully started
logger.info("Waiting for mihomo to start...")
- time.sleep(8)
+ _wait_listener(mixed_port, process)
+ if listener_ports:
+ _wait_listener(listener_ports[0], process, timeout=10)
- # Create proxy info mapping for task generation
mappings = {proxy["name"]: proxy for proxy in nodes}
-
- # Generate tasks for each proxy
- if reader is not None:
- # For locate_by_ipinfo which needs reader parameter
- tasks = [(mappings[name], port, reader) for name, port in records.items() if name in mappings]
- elif api_key:
- # For check_residential with API key
- tasks = [(mappings[name], port, api_key) for name, port in records.items() if name in mappings]
- else:
- # For check_residential without API key
- tasks = [(mappings[name], port) for name, port in records.items() if name in mappings]
-
- # Check proxies using the specified function
- results = utils.multi_thread_run(
- func=func,
- tasks=tasks,
- num_threads=num_threads,
- show_progress=show_progress,
- description=description,
+ tasks = [(mappings[name], port) for name, port in records.items() if name in mappings]
+ results = (
+ utils.multi_thread_run(
+ func=func,
+ tasks=tasks,
+ num_threads=num_threads,
+ show_progress=show_progress,
+ description=description,
+ )
+ or []
)
- return results
+ queried, normalized = set(), []
+ for item in results:
+ if not item:
+ continue
+ normalized.append(item)
+ queried.add(item.proxy.get("name"))
+
+ for proxy in nodes:
+ if proxy["name"] not in queried:
+ normalized.append(ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False))
+ return normalized
except Exception as e:
logger.error(f"Error during mihomo check: {str(e)}")
- return [ProxyQueryResult(proxy=proxy, result=ProxyInfo(name=proxy["name"]), success=False) for proxy in nodes]
+ return failed
finally:
- # Always try to kill the mihomo process
+ reservation.close()
if process:
try:
process.terminate()
@@ -1110,13 +1184,16 @@ def batch_query(
pass
-def process_query_results(results: list[ProxyQueryResult], strategy: str) -> tuple[list[dict], list[dict]]:
+def process_query_results(
+ results: list[ProxyQueryResult], strategy: str, score: bool = False
+) -> tuple[list[dict], list[dict]]:
"""
Process proxy query results
Args:
results: List of query results
strategy: Processing strategy ('residential' or 'location')
+ score: Whether to prefix node names with provider and trust score
Returns:
tuple: (list of successful proxies, list of failed proxies)
@@ -1124,28 +1201,36 @@ def process_query_results(results: list[ProxyQueryResult], strategy: str) -> tup
successes, fails = [], []
for item in results:
- if item.success and item.result.country:
+ if not item:
+ continue
+
+ country = utils.trim(item.result.country) if item.result else ""
+ is_cdn = bool(item.result and item.result.is_cdn) or is_cdn_label(country)
+ if is_cdn:
+ _mark_cdn(item.proxy)
+
+ if item.success and country and not is_cdn:
# Copy proxy info to avoid modifying original data
proxy = item.proxy.copy()
if strategy == "residential":
# Residential IP check strategy
- name = item.result.country
+ name = country
if item.result.ip_type == "isp":
name += "家宽"
elif item.result.ip_type == "business":
name += "商宽"
-
- proxy["name"] = name
- successes.append(proxy)
- elif strategy == "location":
- # Location check strategy
- proxy["name"] = item.result.country
- successes.append(proxy)
else:
- # Unknown strategy, use query result directly
- proxy["name"] = item.result.country
- successes.append(proxy)
+ # Location check or unknown strategy
+ name = country
+
+ if score and item.result.score is not None:
+ source = utils.trim(item.result.provider).upper()
+ if source:
+ name = f"[{source}|{str(item.result.score).zfill(3)}] {name}"
+
+ proxy["name"] = name
+ successes.append(proxy)
else:
# Failed query proxies
fails.append(item.proxy)
@@ -1161,36 +1246,46 @@ def regularize(
show_progress: bool = True,
locate: bool = False,
residential: bool = False,
+ ip_library: str = "",
digits: int = 2,
+ score: bool = False,
) -> list[dict]:
if not proxies or not isinstance(proxies, list):
return proxies
# Phase 1: Residential check if necessary
successes, fails = [], []
+ reader = None
if residential:
- logger.info(f"Starting residential check for {len(proxies)} proxies")
-
- # Enable locate if residential check is enabled
locate = True
+ if residential or locate:
+ directory = utils.trim(directory)
+ if not directory:
+ directory = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), "data")
+ reader = load_mmdb(directory=directory, repo="Loyalsoldier/geoip", filename="Country.mmdb", update=update)
+ if not reader:
+ logger.error("cannot load mmdb: Country.mmdb")
+
+ if residential:
+ logger.info(f"Starting residential check for {len(proxies)} proxies")
+
# Get https://api.ipapi.is API key from environment variable
api_key = utils.trim(os.environ.get("IPAPI_IS_API_KEY", ""))
# Use mihomo to check for residential proxies
results = batch_query(
proxies=proxies,
- func=check_residential,
+ func=partial(check_residential, api_key=api_key, ip_library=ip_library, reader=reader),
num_threads=num_threads,
show_progress=show_progress,
description="Checking residential",
digits=digits,
- api_key=api_key,
)
# Process residential check results
- successes, fails = process_query_results(results, "residential")
+ successes, fails = process_query_results(results, "residential", score=score)
logger.info(f"Residential check completed: {len(successes)} successful, {len(fails)} failed")
else:
fails = proxies
@@ -1199,60 +1294,50 @@ def regularize(
if locate and fails:
logger.info(f"Starting location check for {len(fails)} proxies")
- # Initialize reader for locate functionality and load mmdb database if available
- directory = utils.trim(directory)
- if not directory:
- directory = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), "data")
-
- repo, filename = "Loyalsoldier/geoip", "Country.mmdb"
- reader = load_mmdb(directory=directory, repo=repo, filename=filename, update=update)
- if not reader:
- logger.error(f"Skipping location check due to cannot load mmdb: {filename}")
-
unconfirmed = list()
if reader:
# Try local mmdb lookup first
- tasks = [[p, reader] for p in fails if p and isinstance(p, dict)]
+ sources = [p for p in fails if p and isinstance(p, dict)]
+ tasks = [[p, reader] for p in sources]
mmdb_results = utils.multi_thread_run(locate_by_geoip, tasks, num_threads, show_progress, "")
- # Separate confirmed and unconfirmed proxies by regex
- regex = f"中国|{CDN_PATTERN}"
-
- for proxy in mmdb_results:
- if proxy.pop("renamed", False) and not re.search(regex, proxy["name"], flags=re.I):
- # Add to successes list if confirmed by mmdb lookup
- successes.append(proxy)
+ for source, proxy in zip(sources, mmdb_results or []):
+ node = proxy if proxy and isinstance(proxy, dict) else source
+ name = str(node.get("name", ""))
+ cdn = bool(node.get("cdn")) or is_cdn_label(name)
+ if cdn:
+ _mark_cdn(node)
+ if node.pop("renamed", False) and "中国" not in name and not cdn:
+ successes.append(node)
else:
- # Add to unconfirmed list if not confirmed by mmdb lookup
- unconfirmed.append(proxy)
+ unconfirmed.append(node)
else:
# No mmdb available, treat all as unconfirmed
unconfirmed = fails
- # For unconfirmed proxies, use online API services to get location info (fallback)
+ # For unconfirmed proxies, use online API services to get location info
if unconfirmed:
logger.info(f"Using online API services for {len(unconfirmed)} unconfirmed proxies")
# Use mihomo to check IP locations
query_results = batch_query(
proxies=unconfirmed,
- func=locate_by_ipinfo,
+ func=partial(locate_by_ipinfo, reader=reader),
num_threads=num_threads,
show_progress=show_progress,
description="Querying location",
digits=digits,
- reader=reader,
)
# Process location check results and handle CDN proxies
- query_successes, query_fails = process_query_results(query_results, "location")
+ query_successes, query_fails = process_query_results(query_results, "location", score=score)
# Add query successes to final results
successes.extend(query_successes)
- # Handle CDN proxies that failed location check
+ # CDN nodes without a real country fall back to US
for proxy in query_fails:
- if re.search(CDN_PATTERN, proxy["name"], flags=re.I):
+ if _is_cdn_proxy(proxy):
logger.warning(f"Failed to get location for proxy {proxy['name']}, assume it's in US")
proxy["name"] = "美国"
@@ -1264,7 +1349,7 @@ def regularize(
successes.extend(fails)
# Return final results
- return rename(proxies=successes, digits=digits, shuffle=True)
+ return rename(proxies=_remove_temp_flags(successes), digits=digits, shuffle=True)
def rename(proxies: list[dict], digits: int = 2, shuffle: bool = False) -> list[dict]:
diff --git a/subscribe/process.py b/subscribe/process.py
index cc98014986..b767ae1e2a 100644
--- a/subscribe/process.py
+++ b/subscribe/process.py
@@ -62,6 +62,7 @@ def load_configs(
only_check: bool = False,
num_threads: int = 0,
display: bool = True,
+ retry: int = 3,
) -> ProcessConfig:
def parse_config(config: dict) -> None:
tasks.extend(config.get("domains", []))
@@ -273,7 +274,7 @@ def verify(storage: dict, groups: dict) -> bool:
url,
):
headers = {"User-Agent": utils.USER_AGENT, "Referer": url}
- content = utils.http_get(url=url, headers=headers)
+ content = utils.http_get(url=url, headers=headers, retry=max(retry, 1), timeout=120)
if not content:
logger.error(f"cannot fetch config from remote, url: {utils.hide(url=url)}")
else:
@@ -509,6 +510,7 @@ def aggregate(args: argparse.Namespace) -> None:
clash_bin, subconverter_bin = executable.which_bin()
display = not args.invisible
+ retry = min(max(1, args.retry), 10)
# parse config
server = utils.trim(args.server) or os.environ.get("SUBSCRIBE_CONF", "").strip()
@@ -517,11 +519,11 @@ def aggregate(args: argparse.Namespace) -> None:
only_check=args.check,
num_threads=args.num,
display=display,
+ retry=retry,
)
storages = process_config.storage or {}
pushtool = push.get_instance(config=push.PushConfig.from_dict(storages))
- retry = min(max(1, args.retry), 10)
# generate tasks
tasks, groups, sites = assign(
@@ -572,7 +574,7 @@ def aggregate(args: argparse.Namespace) -> None:
filename = "config.yaml"
proxies = clash.generate_config(workspace, proxies, filename)
- # filer
+ # filter
skip = utils.trim(os.environ.get("SKIP_ALIVE_CHECK", "false")).lower() in ["true", "1"]
nochecks, starttime = proxies, time.time()
@@ -637,6 +639,8 @@ def aggregate(args: argparse.Namespace) -> None:
if regularize and isinstance(regularize, dict) and regularize.get("enable", False):
locate = regularize.get("locate", False)
residential = regularize.get("residential", False)
+ ip_library = regularize.get("library", "")
+ score = regularize.get("score", False)
try:
bits = max(1, int(regularize.get("bits", 2)))
except:
@@ -648,7 +652,9 @@ def aggregate(args: argparse.Namespace) -> None:
show_progress=display,
locate=locate,
residential=residential,
+ ip_library=ip_library,
digits=bits,
+ score=score,
)
source_file, data = "config.yaml", {"proxies": nochecks}
diff --git a/subscribe/scripts/fofa.py b/subscribe/scripts/fofa.py
index 30e8ef3e94..d1bbdd4695 100644
--- a/subscribe/scripts/fofa.py
+++ b/subscribe/scripts/fofa.py
@@ -67,7 +67,7 @@ def extract_one(url: str) -> list[str]:
regex = r"(?:https?://)?(?:[a-zA-Z0-9\u4e00-\u9fa5\-]+\.)+[a-zA-Z0-9\u4e00-\u9fa5\-]+(?:(?:(?:/index.php)?/api/v1/client/subscribe\?token=[a-zA-Z0-9]{16,32})|(?:/link/[a-zA-Z0-9]+\?(?:sub|mu|clash)=\d)|(?:/(?:s|sub)/[a-zA-Z0-9]{32}))"
- headers = {"User-Agent": "Clash.Meta; Mihomo"}
+ headers = {"User-Agent": f"{utils.USER_AGENT}; Clash.Meta; Mihomo; Shadowrocket;"}
subscriptions, content = [], ""
count, retry = 0, 2
diff --git a/subscribe/subconverter.py b/subscribe/subconverter.py
index fa4b552ad0..d7fa3bc07a 100644
--- a/subscribe/subconverter.py
+++ b/subscribe/subconverter.py
@@ -103,7 +103,7 @@ def generate_conf(
lines.extend(["emoji=false", "add_emoji=false"])
if ignore_exclude:
- lines.append("exclude=流量|过期|剩余|时间|Expire|Traffic")
+ lines.append("exclude=[到过]期|Expire|Traffic|剩余流量|时间|官网|产品|联系")
lines.append("\n")
content = "\n".join(lines)
diff --git a/subscribe/utils.py b/subscribe/utils.py
index 957731a715..75f8962acb 100644
--- a/subscribe/utils.py
+++ b/subscribe/utils.py
@@ -35,7 +35,7 @@
CTX.verify_mode = ssl.CERT_NONE
USER_AGENT = (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
)
diff --git a/tools/clean.py b/tools/clean.py
index dc7301cdb0..21f5c481ca 100644
--- a/tools/clean.py
+++ b/tools/clean.py
@@ -223,7 +223,7 @@ def main(args: argparse.Namespace) -> None:
elif country:
name = country
else:
- print("cannot get geolocation and rename because IP address is faked")
+ print("cannot get geolocation and name because IP address is faked")
item["name"] = name
except Exception:
diff --git a/tools/proxy-gen.py b/tools/proxy-gen.py
new file mode 100644
index 0000000000..26a275d610
--- /dev/null
+++ b/tools/proxy-gen.py
@@ -0,0 +1,335 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""Generate dynamic HTTP/SOCKS proxy nodes from CLI options"""
+
+from __future__ import annotations
+
+import argparse
+import ipaddress
+import json
+import os
+import re
+import secrets
+import string
+import sys
+from dataclasses import dataclass
+
+SCHEMES = ("socks5", "socks5h", "http", "https")
+FORMATS = ("uri", "clash")
+NAMING_MODES = ("sid", "index", "none")
+SID_CHARS = string.ascii_letters + string.digits
+KEYWORD_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
+DOMAIN_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
+POSITIVE_INT = re.compile(r"^[1-9]\d*$")
+ILLEGAL_CHARS = set(":@#/?%")
+ISO_CODES = frozenset("""
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
+ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
+ DE DJ DK DM DO DZ
+ EC EE EG EH ER ES ET
+ FI FJ FK FM FO FR
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
+ HK HM HN HR HT HU
+ ID IE IL IM IN IO IQ IR IS IT
+ JE JM JO JP
+ KE KG KH KI KM KN KP KR KW KY KZ
+ LA LB LC LI LK LR LS LT LU LV LY
+ MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
+ NA NC NE NF NG NI NL NO NP NR NU NZ
+ OM
+ PA PE PF PG PH PK PL PM PN PR PS PT PW PY
+ QA
+ RE RO RS RU RW
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
+ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
+ UA UG UM US UY UZ
+ VA VC VE VG VI VN VU
+ WF WS
+ XK
+ YE YT
+ ZA ZM ZW
+ """.split())
+
+
+class ValidationError(ValueError):
+ pass
+
+
+@dataclass
+class Config:
+ scheme: str
+ username: str
+ password: str
+ host: str
+ port: int
+ count: int
+ region_key: str
+ country: str
+ state_key: str
+ state: str
+ session_key: str
+ sid_length: int
+ duration_key: str
+ duration: int
+ naming_mode: str
+ output_format: str
+ output_path: str
+
+
+def trim(value: object | None) -> str:
+ return "" if value is None else str(value).strip()
+
+
+def require_text(value: object | None, name: str) -> str:
+ text = trim(value)
+ if not text:
+ raise ValidationError(f"{name} is required")
+ if any(ch.isspace() for ch in text):
+ raise ValidationError(f"{name} must not contain whitespace")
+ if any(ch in ILLEGAL_CHARS for ch in text):
+ raise ValidationError(f"{name} contains illegal character")
+ return text
+
+
+def require_keyword(value: object | None, name: str, required: bool = True) -> str:
+ text = trim(value)
+ if not text:
+ if required:
+ raise ValidationError(f"{name} is required")
+ return ""
+ if any(ch.isspace() for ch in text):
+ raise ValidationError(f"{name} must not contain whitespace")
+ if not KEYWORD_PATTERN.fullmatch(text):
+ raise ValidationError(f"{name} may contain only letters, digits, '-' and '_'")
+ return text
+
+
+def require_integer(value: object | None, name: str, maximum: int | None = None) -> int:
+ text = trim(value)
+ if not POSITIVE_INT.fullmatch(text):
+ raise ValidationError(f"{name} must be an integer > 0")
+ number = int(text)
+ if maximum is not None and number > maximum:
+ raise ValidationError(f"{name} must be <= {maximum}")
+ return number
+
+
+def require_host(value: object | None) -> str:
+ host = require_text(value, "host")
+ try:
+ ipaddress.IPv4Address(host)
+ return host
+ except ValueError:
+ pass
+ hostname = host[:-1] if host.endswith(".") else host
+ if not hostname or len(hostname) > 253:
+ raise ValidationError("host must be a domain or IPv4 address")
+ labels = hostname.split(".")
+ if not all(DOMAIN_LABEL.fullmatch(label) for label in labels):
+ raise ValidationError("host must be a domain or IPv4 address")
+ return host
+
+
+def require_country(value: object | None) -> str:
+ text = trim(value)
+ if not text:
+ return ""
+ country = text.upper()
+ if country not in ISO_CODES:
+ raise ValidationError("country must be an ISO 3166-1 alpha-2 code")
+ return country
+
+
+def require_choice(value: object | None, name: str, options: tuple[str, ...], default: str) -> str:
+ text = trim(value).lower()
+ if not text:
+ return default
+ if text not in options:
+ raise ValidationError(f"{name} must be one of {', '.join(options)}")
+ return text
+
+
+def generate_sids(count: int, length: int) -> list[str]:
+ capacity = len(SID_CHARS) ** length
+ if count > capacity:
+ raise ValidationError(f"count {count} exceeds unique sid space {capacity} for sid-length {length}")
+ randomizer = secrets.SystemRandom()
+ if length == 1:
+ return randomizer.sample(list(SID_CHARS), count)
+ seen: set[str] = set()
+ sids: list[str] = []
+ while len(sids) < count:
+ sid = "".join(randomizer.choice(SID_CHARS) for _ in range(length))
+ if sid in seen:
+ continue
+ seen.add(sid)
+ sids.append(sid)
+ return sids
+
+
+def build_username(config: Config, sid: str) -> str:
+ parts = [config.username]
+ if config.country:
+ parts.extend((config.region_key, config.country))
+ if config.state:
+ parts.extend((config.state_key, config.state))
+ parts.extend((config.session_key, sid, config.duration_key, str(config.duration)))
+ return "-".join(parts)
+
+
+def build_node_name(config: Config, sid: str, index: int, index_width: int) -> str:
+ if config.naming_mode == "none":
+ return ""
+ suffix = sid if config.naming_mode == "sid" else f"{index:0{index_width}d}"
+ return f"{config.country}-{suffix}" if config.country else suffix
+
+
+def format_uri(config: Config, username: str, node_name: str) -> str:
+ uri = f"{config.scheme}://{username}:{config.password}@{config.host}:{config.port}"
+ return f"{uri}#{node_name}" if node_name else uri
+
+
+def format_clash(config: Config, username: str, node_name: str) -> str:
+ proxy_type = "http" if config.scheme in ("http", "https") else "socks5"
+ proxy = {
+ "name": node_name,
+ "type": proxy_type,
+ "server": config.host,
+ "port": config.port,
+ "username": username,
+ "password": config.password,
+ }
+ if config.scheme in ("socks5", "socks5h"):
+ proxy["udp"] = True
+ proxy["tls"] = True
+ proxy["skip-cert-verify"] = False
+ elif config.scheme == "https":
+ proxy["tls"] = True
+ proxy["skip-cert-verify"] = False
+ return json.dumps(proxy, ensure_ascii=False, separators=(",", ":"))
+
+
+def parse_config(args: argparse.Namespace) -> Config:
+ scheme = require_choice(args.scheme, "scheme", SCHEMES, "socks5")
+ username = require_text(args.username, "username")
+ password = require_text(args.password, "password")
+ host = require_host(args.host)
+ port = require_integer(args.port, "port", maximum=65535)
+ count = require_integer(args.count, "count")
+ region_key = require_keyword(args.region_key, "region-key")
+ country = require_country(args.country)
+ state_key = require_keyword(args.state_key, "state-key", required=False)
+ state = require_keyword(args.state, "state", required=False)
+ session_key = require_keyword(args.session_key, "session-key")
+ sid_length = require_integer(args.sid_length, "sid-length", maximum=64)
+ duration_key = require_keyword(args.duration_key, "duration-key")
+ duration = require_integer(args.duration, "duration")
+ naming_mode = require_choice(args.naming, "naming", NAMING_MODES, "sid")
+ output_format = require_choice(args.format, "format", FORMATS, "uri")
+ output_path = trim(args.output)
+
+ if bool(state_key) != bool(state):
+ raise ValidationError("state-key and state must be provided together")
+ if (state_key or state) and not country:
+ raise ValidationError("state requires country")
+ if naming_mode == "none" and output_format != "uri":
+ raise ValidationError("naming=none is only supported when format=uri")
+
+ return Config(
+ scheme=scheme,
+ username=username,
+ password=password,
+ host=host,
+ port=port,
+ count=count,
+ region_key=region_key,
+ country=country,
+ state_key=state_key,
+ state=state,
+ session_key=session_key,
+ sid_length=sid_length,
+ duration_key=duration_key,
+ duration=duration,
+ naming_mode=naming_mode,
+ output_format=output_format,
+ output_path=output_path,
+ )
+
+
+def generate_nodes(config: Config) -> list[str]:
+ sids = generate_sids(config.count, config.sid_length)
+ index_width = len(str(config.count))
+ nodes: list[str] = []
+ for index, sid in enumerate(sids, start=1):
+ username = build_username(config, sid)
+ node_name = build_node_name(config, sid, index, index_width)
+ if config.output_format == "clash":
+ nodes.append(f" - {format_clash(config, username, node_name)}")
+ else:
+ nodes.append(format_uri(config, username, node_name))
+ if config.output_format == "clash":
+ return ["proxies:", *nodes]
+ return nodes
+
+
+def write_output(nodes: list[str], output_path: str) -> None:
+ content = "\n".join(nodes)
+ if content:
+ content += "\n"
+ if not output_path:
+ sys.stdout.write(content)
+ return
+ path = os.path.abspath(output_path)
+ directory = os.path.dirname(path)
+ if directory and not os.path.isdir(directory):
+ raise ValidationError(f"output directory not found: {directory}")
+ with open(path, "w", encoding="utf-8", newline="\n") as output_file:
+ output_file.write(content)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Generate dynamic HTTP/SOCKS proxy nodes",
+ formatter_class=argparse.RawTextHelpFormatter,
+ epilog=(
+ "example:\n"
+ " python proxies-gen.py --username alice --password secret --host gate.example.com --port 1080 --count 10 "
+ "--session-key sid --sid-length 8 --duration-key t --country US --state-key st --state california"
+ ),
+ allow_abbrev=False,
+ )
+ parser.add_argument("--scheme", default="socks5", help="socks5, socks5h, http or https")
+ parser.add_argument("--username", required=True, help="base username")
+ parser.add_argument("--password", required=True, help="password")
+ parser.add_argument("--host", required=True, help="domain or IPv4 address")
+ parser.add_argument("--port", required=True, help="port, 1-65535")
+ parser.add_argument("--count", required=True, help="number of nodes")
+ parser.add_argument("--region-key", default="region", help="region keyword, default: region")
+ parser.add_argument("--country", default="", help="ISO 3166-1 alpha-2 country code")
+ parser.add_argument("--state-key", default="", help="state or city keyword, e.g. st")
+ parser.add_argument("--state", default="", help="state or city name")
+ parser.add_argument("--session-key", required=True, help="session keyword, e.g. sid")
+ parser.add_argument("--sid-length", required=True, help="random sid length")
+ parser.add_argument("--duration-key", required=True, help="session duration keyword, e.g. t")
+ parser.add_argument("--duration", default="5", help="session duration, default: 5")
+ parser.add_argument("--naming", default="sid", help="node name style: sid, index or none, none only for uri")
+ parser.add_argument("--format", default="uri", help="uri or clash")
+ parser.add_argument("--output", default="", help="output file, default: stdout")
+ return parser
+
+
+def main() -> None:
+ parser = build_parser()
+ try:
+ config = parse_config(parser.parse_args())
+ write_output(generate_nodes(config), config.output_path)
+ except ValidationError as error:
+ parser.error(str(error))
+ except OSError as error:
+ parser.error(str(error))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/socks-checker.py b/tools/socks-checker.py
index c7bbb26388..7f8e4a8b37 100644
--- a/tools/socks-checker.py
+++ b/tools/socks-checker.py
@@ -11,13 +11,15 @@
import argparse
import asyncio
+import html
import ipaddress
+import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime
-from typing import Dict, List, Optional, Tuple
-from urllib.parse import urlparse
+from typing import Any, Callable, Dict, List, Optional, Tuple
+from urllib.parse import quote, urlparse
import aiohttp
import yaml
@@ -276,6 +278,97 @@
}
+def country_flag_emoji(country_code: str) -> str:
+ if not country_code or len(country_code) != 2:
+ return ""
+
+ code = country_code.upper()
+ if not code.isalpha():
+ return ""
+
+ return chr(0x1F1E6 + ord(code[0]) - ord("A")) + chr(0x1F1E6 + ord(code[1]) - ord("A"))
+
+
+def country_name_zh(country_code: str) -> str:
+ if not country_code:
+ return ""
+
+ return COUNTRY_NAME_ZH.get(country_code.upper(), "")
+
+
+CHINA_PROVINCE_SUFFIXES = (
+ "特别行政区",
+ "维吾尔自治区",
+ "壮族自治区",
+ "回族自治区",
+ "自治区",
+ "省",
+ "市",
+)
+CHINA_PROVINCE_ALIASES = {
+ "anhui": "安徽",
+ "beijing": "北京",
+ "chongqing": "重庆",
+ "fujian": "福建",
+ "gansu": "甘肃",
+ "guangdong": "广东",
+ "guangxi": "广西",
+ "guangxizhuang": "广西",
+ "guizhou": "贵州",
+ "hainan": "海南",
+ "hebei": "河北",
+ "heilongjiang": "黑龙江",
+ "henan": "河南",
+ "hubei": "湖北",
+ "hunan": "湖南",
+ "innermongolia": "内蒙古",
+ "jiangsu": "江苏",
+ "jiangxi": "江西",
+ "jilin": "吉林",
+ "liaoning": "辽宁",
+ "neimenggu": "内蒙古",
+ "neimongol": "内蒙古",
+ "ningxia": "宁夏",
+ "ningxiahuizu": "宁夏",
+ "qinghai": "青海",
+ "shaanxi": "陕西",
+ "shandong": "山东",
+ "shanghai": "上海",
+ "shanxi": "山西",
+ "sichuan": "四川",
+ "tianjin": "天津",
+ "tibet": "西藏",
+ "xinjiang": "新疆",
+ "xinjianguygur": "新疆",
+ "xinjianguyghur": "新疆",
+ "xizang": "西藏",
+ "yunnan": "云南",
+ "zhejiang": "浙江",
+}
+CHINA_MAINLAND_PROVINCES = frozenset(CHINA_PROVINCE_ALIASES.values())
+CHINA_PROVINCE_ALIAS_PREFIXES = tuple(
+ sorted(CHINA_PROVINCE_ALIASES.items(), key=lambda item: len(item[0]), reverse=True)
+)
+CHINA_PROVINCE_EN_SUFFIXES = (
+ "autonomousregion",
+ "municipality",
+ "province",
+ "region",
+ "sheng",
+ "city",
+)
+
+REGION_KEYS = set(["province", "province_name", "region", "region_name", "state", "state_name"])
+
+
+def short_company_name(value: str) -> str:
+ if not value:
+ return "UNKNOWN"
+
+ parts = [part for part in re.split(r"[\s,\.\-_@;:]+", value.strip()) if part]
+ return parts[0].upper() if parts else "UNKNOWN"
+
+
@dataclass
class ProxyInfo:
protocol: str
@@ -309,6 +402,820 @@ def from_proxy(cls, proxy_info: ProxyInfo) -> "TestResult":
)
+@dataclass
+class IpLookupResult:
+ ip: Optional[str]
+ data: Optional[Dict]
+ error: Optional[str] = None
+
+
+class IPLibrary:
+ name: str = ""
+
+ def __init__(self):
+ self._caches: Dict[str, str] = {}
+
+ async def lookup(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> IpLookupResult:
+ data, error = await self._fetch(session, proxy_info, retries, timeout)
+ if not data:
+ host = "" if not proxy_info else proxy_info.host
+ return IpLookupResult(None, None, error or f"Failed to get IP info from {self.name}, host: {host}")
+
+ return self._verify(data, self.name)
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ raise NotImplementedError
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ raise NotImplementedError
+
+ @staticmethod
+ def _build_headers(url: str) -> Dict[str, str]:
+ result = urlparse(url)
+ base = f"{result.scheme}://{result.netloc}" if result.scheme and result.netloc else ""
+
+ return {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Cache-Control": "no-cache",
+ "Pragma": "no-cache",
+ "Connection": "close",
+ "Referer": f"{base}/" if base else url,
+ "Origin": base if base else url,
+ }
+
+ async def _make_request(
+ self,
+ session: aiohttp.ClientSession,
+ url: str,
+ retries: int,
+ timeout: int,
+ headers: Optional[Dict[str, str]] = None,
+ deserialize: bool = True,
+ parser: Optional[Callable[[str], Any]] = None,
+ ) -> Tuple[Optional[Any], Optional[str]]:
+ default_headers = self._build_headers(url)
+ if headers and isinstance(headers, dict):
+ default_headers.update({k: v for k, v in headers.items() if k and v is not None})
+
+ error = None
+ for attempt in range(1, retries + 1):
+ try:
+ async with session.get(
+ url,
+ headers=default_headers,
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ ) as response:
+ if response.status == 200:
+ content = await response.text()
+
+ if parser is not None:
+ data = parser(content)
+ if data:
+ return data, None
+
+ error = "Invalid response payload"
+ elif deserialize:
+ try:
+ data = json.loads(content)
+ except Exception:
+ data = None
+
+ if isinstance(data, dict):
+ return data, None
+
+ error = "Invalid JSON response"
+ else:
+ return content, None
+
+ else:
+ error = f"HTTP {response.status}"
+ except asyncio.TimeoutError:
+ error = "Timeout"
+ except Exception as e:
+ error = str(e)[:100]
+
+ if attempt < retries:
+ await asyncio.sleep(attempt)
+
+ return None, error
+
+ @staticmethod
+ def _verify(data: Dict, source: str) -> IpLookupResult:
+ address = (data.get("ip") or "").strip()
+ if not address:
+ return IpLookupResult(None, None, f"Invalid IP from {source}")
+
+ try:
+ ipaddress.ip_address(address)
+ except ValueError:
+ return IpLookupResult(None, None, f"Invalid IP from {source}, ip: {address}")
+
+ return IpLookupResult(address, data, None)
+
+ @staticmethod
+ def _format_remark(
+ country_code: str,
+ country: str,
+ label: str,
+ include_asn_name: bool,
+ company_name: str,
+ detail: str = "",
+ ) -> str:
+ flag = country_flag_emoji(country_code)
+ base = f"{flag} {country}{label}".strip()
+
+ if include_asn_name and company_name:
+ if detail:
+ return f"{base} [{company_name}::{detail}]".strip()
+
+ return f"{base} [{company_name}]".strip()
+
+ return base
+
+ async def _resolve_country(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ country_code: str,
+ country: str,
+ retries: int,
+ timeout: int,
+ data: Optional[Dict] = None,
+ ) -> str:
+ country_code = (country_code or "").upper()
+ if country_code != "CN" or not ip:
+ return country
+
+ if ip in self._caches:
+ return self._caches[ip]
+
+ province = self._extract_province(data)
+ if not province:
+ return "中国"
+
+ resolved = f"中国{province}"
+ self._caches[ip] = resolved
+ return resolved
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ if not isinstance(data, dict):
+ return ""
+
+ candidates: List[Any] = []
+ for key in REGION_KEYS:
+ candidates.append(data.get(key))
+
+ for parent_key in ("location", "geo"):
+ nested = data.get(parent_key)
+ if isinstance(nested, dict):
+ for key in REGION_KEYS:
+ candidates.append(nested.get(key))
+
+ for value in candidates:
+ province = self._normalize_province(value)
+ if province:
+ return province
+
+ return ""
+
+ @staticmethod
+ def _province_alias_key(province: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "", province.lower())
+
+ @staticmethod
+ def _match_province_alias(key: str) -> str:
+ if not key or key in {"china", "cn", "mainlandchina", "unknown", "na", "null", "none"}:
+ return ""
+
+ candidates = [key]
+ for suffix in CHINA_PROVINCE_EN_SUFFIXES:
+ if key.endswith(suffix):
+ stripped = key[: -len(suffix)]
+ if stripped:
+ candidates.append(stripped)
+
+ for candidate in candidates:
+ province = CHINA_PROVINCE_ALIASES.get(candidate)
+ if province:
+ return province
+
+ for candidate in candidates:
+ for alias, province in CHINA_PROVINCE_ALIAS_PREFIXES:
+ if candidate.startswith(alias):
+ return province
+
+ return ""
+
+ @classmethod
+ def _normalize_province(cls, province: Any) -> str:
+ if isinstance(province, dict):
+ for key in ("name", "name_en", "en", "value"):
+ value = cls._normalize_province(province.get(key))
+ if value:
+ return value
+
+ return ""
+
+ if not isinstance(province, str):
+ return ""
+
+ province = province.strip()
+ if not province:
+ return ""
+
+ if province.lower() in {"-", "n/a", "na", "none", "null", "unknown"}:
+ return ""
+
+ for suffix in CHINA_PROVINCE_SUFFIXES:
+ if province.endswith(suffix):
+ province = province[: -len(suffix)].strip()
+ break
+
+ if re.search(r"[\u4e00-\u9fff]", province):
+ return province if province in CHINA_MAINLAND_PROVINCES else ""
+
+ key = cls._province_alias_key(province)
+ return cls._match_province_alias(key)
+
+
+class IPInfoLibrary(IPLibrary):
+ name = "ipinfo"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ country_code = (data.get("country") or "").upper()
+ flag = country_flag_emoji(country_code)
+
+ asn_info = data.get("asn", {}) or {}
+ company_info = data.get("company", {}) or {}
+ asn_type = (asn_info.get("type") or "").lower()
+ company_type = (company_info.get("type") or "").lower()
+
+ asn_name = (asn_info.get("domain") or "").strip()
+ if not asn_name or re.match(r"^as\d+\.", asn_name, flags=re.I):
+ asn_name = (asn_info.get("name") or "").strip()
+
+ company_name = short_company_name(asn_name)
+
+ if asn_type == "isp" and company_type == "isp":
+ label = "家宽"
+ elif asn_type == "isp" or company_type == "isp":
+ label = "商宽"
+ elif asn_type == "edu" or company_type == "edu":
+ label = "教育"
+ else:
+ label = ""
+
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or country_code or "未知",
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+ base = f"{flag} {country}{label}".strip()
+ if include_asn_name and company_name:
+ return f"{base} [{company_name}]".strip()
+
+ return base
+
+ @staticmethod
+ def _is_ipv4(host: str) -> bool:
+ if not host:
+ return False
+ try:
+ return isinstance(ipaddress.ip_address(host), ipaddress.IPv4Address)
+ except ValueError:
+ return False
+
+ async def _resolve_ip(self, session: aiohttp.ClientSession, host: str, retries: int, timeout: int) -> Optional[str]:
+ if self._is_ipv4(host):
+ return host
+
+ url = "https://ipinfo.io/ip"
+ for attempt in range(1, retries + 1):
+ try:
+ async with session.get(
+ url,
+ headers=self._build_headers(url),
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ ) as response:
+ if response.status == 200:
+ text = (await response.text()).strip()
+ try:
+ ipaddress.ip_address(text)
+ return text
+ except ValueError:
+ pass
+ except asyncio.TimeoutError:
+ pass
+ except Exception:
+ pass
+
+ if attempt < retries:
+ await asyncio.sleep(attempt)
+
+ return None
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ host = proxy_info.host if proxy_info else ""
+ address = await self._resolve_ip(session, host, retries, timeout)
+ if not address:
+ return None, f"Failed to get IP from ipinfo.io/ip, host: {host}"
+
+ url = f"https://ipinfo.io/widget/demo/{address}"
+ data, error = await self._make_request(session, url, retries, timeout)
+ if not data:
+ return None, error or f"Failed to get IP info from ipinfo.io, ip: {address}"
+
+ return data.get("data", data), None
+
+
+class IPPureLibrary(IPLibrary):
+ name = "ippure"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ residential = data.get("isResidential")
+ label = "家宽" if residential is True else ""
+
+ country_code = (data.get("countryCode") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or (data.get("country") or "未知"),
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ company_name = short_company_name(data.get("asOrganization") or "")
+ score = str(data.get("fraudScore")).zfill(3) if "fraudScore" in data else "NUL"
+
+ # broadcast or native
+ categroy = "NUL" if "isBroadcast" not in data else "B" if data.get("isBroadcast") else "N"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=f"{score}::{categroy}",
+ )
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, _: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ url = "https://my.ippure.com/v1/info"
+ return await self._make_request(session, url, retries, timeout)
+
+
+class IP2LocationLibrary(IPLibrary):
+ name = "ip2location"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ as_info = data.get("as_info") or {}
+
+ usage_type = (data.get("usage_type") or "").strip().lower()
+ as_usage_type = ((as_info.get("as_usage_type") if isinstance(as_info, dict) else "") or "").strip().lower()
+
+ check = lambda usage: usage.startswith("isp") or usage == "mob"
+ label = "家宽" if check(usage_type) and check(as_usage_type) else ""
+
+ country_code = (data.get("country_code") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code)
+ or data.get("country_name")
+ or data.get("country", {}).get("name", "")
+ or "未知",
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ provider = (data.get("as", "") or data.get("isp", "") or "").strip()
+ if not provider and as_info and isinstance(as_info, dict):
+ provider = (as_info.get("as_name", "") or as_info.get("as_domain", "")).strip()
+ if not provider:
+ provider = data.get("domain", "").strip() or ""
+
+ company_name = short_company_name(provider)
+ score = str(data.get("fraud_score")).zfill(3) if "fraud_score" in data else "NUL"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=score,
+ )
+
+ @staticmethod
+ def _extract_data(content: str) -> Dict:
+ if not content or not isinstance(content, str):
+ return {}
+
+ pattern = r']*class=["\'][^"\']*\blanguage-json\b[^"\']*["\'][^>]*>(.*?)\s*'
+ groups = re.findall(pattern, content, flags=re.I | re.S)
+ if not groups:
+ return {}
+
+ for group in groups:
+ payload = group.strip()
+ if not payload:
+ continue
+
+ payload = re.sub(r"<[^>]+>", "", payload, flags=re.I | re.S)
+ payload = html.unescape(payload)
+
+ try:
+ data = json.loads(payload)
+ if isinstance(data, dict):
+ return data
+ except Exception:
+ continue
+
+ return {}
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, _: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ url = "https://www.ip2location.com/demo"
+ headers = {"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
+ data, error = await self._make_request(
+ session=session,
+ url=url,
+ retries=retries,
+ timeout=timeout,
+ headers=headers,
+ deserialize=False,
+ parser=self._extract_data,
+ )
+ if not data:
+ return None, "Invalid HTML response" if error == "Invalid response payload" else error
+
+ return data, None
+
+
+class IPLarkLibrary(IPLibrary):
+ name = "iplark"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ node_type = (data.get("type") or "").strip().lower()
+ if node_type == "isp":
+ label = "家宽"
+ elif node_type == "business":
+ label = "商宽"
+ elif node_type == "education":
+ label = "教育"
+ else:
+ label = ""
+
+ country_code = (data.get("country_code") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or (data.get("country_zh") or data.get("country") or "未知"),
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ # native if registered country code equals country code else broadcast
+ categroy = "N" if (data.get("registered_country_code") or "").upper() == country_code else "B"
+
+ asn = str(data.get("asn") or "").strip()
+ detail = f"{'AS'+asn if asn else 'NUL'}::{categroy}"
+
+ company_name = short_company_name(data.get("organization") or "")
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=detail,
+ )
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ return ""
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, _: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ url = "https://iplark.com/ipapi/public/ipinfo"
+ return await self._make_request(session, url, retries, timeout)
+
+
+class IPNetCoffeeLibrary(IPLibrary):
+ name = "ipnetcoffee"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ label = "家宽" if data.get("isResidential") is True and data.get("company_type", "") != "business" else ""
+
+ country_code = (data.get("countryCode") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or (data.get("country") or "未知"),
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ company_name = short_company_name(
+ data.get("asOrganization") or data.get("isp") or data.get("company_name") or ""
+ )
+ score = str(data.get("trust_score")).zfill(3) if "trust_score" in data else "NUL"
+
+ # native if registered country code equals country code else broadcast
+ category = "N" if (data.get("registered_country_code") or "").upper() == country_code else "B"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=label,
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=f"{score}::{category}",
+ )
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ province = super()._extract_province(data)
+ if province:
+ return province
+
+ if not isinstance(data, dict):
+ return ""
+
+ for source in data.get("geo_sources") or []:
+ if not isinstance(source, dict):
+ continue
+
+ province = self._normalize_province(source.get("region"))
+ if province:
+ return province
+
+ return ""
+
+ async def _resolve_ip(self, session: aiohttp.ClientSession, retries: int, timeout: int) -> Optional[str]:
+ url = "https://ipinfo.io/ip"
+ text, _ = await self._make_request(session, url, retries, timeout, deserialize=False)
+ if not isinstance(text, str):
+ return None
+
+ address = text.strip()
+ try:
+ ipaddress.ip_address(address)
+ return address
+ except ValueError:
+ return None
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ address = await self._resolve_ip(session, retries, timeout)
+ if not address:
+ host = "" if not proxy_info else proxy_info.host
+ return None, f"Failed to get egress IP, host: {host}"
+
+ url = f"https://ip.net.coffee/api/ip/lookup/{quote(address, safe='')}"
+ data, error = await self._make_request(session, url, retries, timeout)
+ if not data:
+ return None, error or f"Failed to get IP info from ip.net.coffee, ip: {address}"
+
+ return data, None
+
+
+class MeowVPSLibrary(IPLibrary):
+ name = "meowvps"
+
+ async def build_remark(
+ self,
+ session: aiohttp.ClientSession,
+ ip: str,
+ data: Dict,
+ include_asn_name: bool,
+ retries: int,
+ timeout: int,
+ ) -> str:
+ core = data.get("core_data") if isinstance(data.get("core_data"), dict) else {}
+ minfraud = data.get("minfraud") if isinstance(data.get("minfraud"), dict) else {}
+ traits = minfraud.get("traits") if isinstance(minfraud.get("traits"), dict) else {}
+
+ country_code = (core.get("country_code") or "").upper()
+ country = await self._resolve_country(
+ session=session,
+ ip=ip,
+ country_code=country_code,
+ country=country_name_zh(country_code) or minfraud.get("country") or core.get("country") or "未知",
+ retries=retries,
+ timeout=timeout,
+ data=data,
+ )
+
+ company_name = short_company_name(core.get("as_name") or traits.get("isp") or core.get("as_domain") or "")
+ scores = self._nested(data, "risk_assessment", "ipdata", "scores")
+ score = str(scores.get("trust_score")).zfill(3) if "trust_score" in scores else "NUL"
+
+ registered = (minfraud.get("registered_country") or "").strip()
+ current = (minfraud.get("country") or "").strip()
+ category = "N" if registered and current and registered == current else "B"
+
+ return self._format_remark(
+ country_code=country_code,
+ country=country,
+ label=self._build_label(data),
+ include_asn_name=include_asn_name,
+ company_name=company_name,
+ detail=f"{score}::{category}",
+ )
+
+ @staticmethod
+ def _nested(data: Optional[Dict], *keys: str) -> Dict:
+ current: Any = data
+ for key in keys:
+ if not isinstance(current, dict):
+ return {}
+ current = current.get(key)
+ return current if isinstance(current, dict) else {}
+
+ @classmethod
+ def _build_label(cls, data: Dict) -> str:
+ digital = cls._nested(data, "api4", "digital")
+ traits = cls._nested(data, "minfraud", "traits")
+ digital_type = "" if digital.get("type") is None else str(digital.get("type")).strip().lower()
+ user_type = str(traits.get("user_type") or "").strip().lower()
+
+ if digital_type == "edu" or user_type in {"college", "education", "edu"}:
+ return "教育"
+
+ if cls._is_residential(digital_type, user_type, data):
+ return "家宽"
+
+ return ""
+
+ @classmethod
+ def _is_residential(cls, digital_type: str, user_type: str, data: Dict) -> bool:
+ # Verified against representative IPs: empty api4.digital.type usually means ISP/residential, but 114.114.114.114 also has empty type while user_type/hosting/datacenter say DC
+ if digital_type in {"hosting", "edu"}:
+ return False
+ if user_type in {"hosting", "content_delivery_network", "college"}:
+ return False
+ if user_type in {"residential", "traveler", "cellular"}:
+ return True
+ if digital_type:
+ return False
+
+ ipapi = cls._nested(data, "risk_assessment", "ipapi")
+ threat = cls._nested(data, "risk_assessment", "ipdata", "threat")
+ if ipapi.get("hosting") is True or threat.get("is_datacenter") is True:
+ return False
+
+ return True
+
+ def _extract_province(self, data: Optional[Dict]) -> str:
+ province = super()._extract_province(data)
+ if province:
+ return province
+
+ minfraud = data.get("minfraud") if isinstance(data, dict) else None
+ if not isinstance(minfraud, dict):
+ return ""
+
+ for item in minfraud.get("subdivisions") or []:
+ province = self._normalize_province(item)
+ if province:
+ return province
+
+ return ""
+
+ async def _resolve_ip(self, session: aiohttp.ClientSession, retries: int, timeout: int) -> Optional[str]:
+ url = "https://ipinfo.io/ip"
+ text, _ = await self._make_request(session, url, retries, timeout, deserialize=False)
+ if not isinstance(text, str):
+ return None
+
+ address = text.strip()
+ try:
+ ipaddress.ip_address(address)
+ return address
+ except ValueError:
+ return None
+
+ async def _fetch(
+ self, session: aiohttp.ClientSession, proxy_info: ProxyInfo, retries: int, timeout: int
+ ) -> Tuple[Optional[Dict], Optional[str]]:
+ address = await self._resolve_ip(session, retries, timeout)
+ if not address:
+ host = "" if not proxy_info else proxy_info.host
+ return None, f"Failed to get egress IP, host: {host}"
+
+ url = f"https://meowvps.com/api/ip-aggregator/{quote(address, safe='')}"
+ headers = {
+ "Accept": "*/*",
+ "Accept-Language": "zh-CN,zh;q=0.8",
+ "Origin": "https://meowvps.com",
+ "Referer": "https://meowvps.com/tools/ip-check/",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/152.0.0.0 Safari/537.36"
+ ),
+ }
+ data, error = await self._make_request(session, url, retries, timeout, headers=headers)
+ if not data:
+ return None, error or f"Failed to get IP info from meowvps, ip: {address}"
+ if data.get("success") is False:
+ return None, f"MeowVPS lookup failed, ip: {address}"
+
+ return data, None
+
+
+IP_LIBRARIES = {
+ "ip2location": IP2LocationLibrary,
+ "iplark": IPLarkLibrary,
+ "ipinfo": IPInfoLibrary,
+ "ipnetcoffee": IPNetCoffeeLibrary,
+ "ippure": IPPureLibrary,
+ "meowvps": MeowVPSLibrary,
+}
+
+
+def get_ip_library(name: str) -> IPLibrary:
+ key = (name or "ip2location").strip().lower()
+ library = IP_LIBRARIES.get(key)
+ if not library:
+ supported = ", ".join(sorted(IP_LIBRARIES.keys()))
+ raise ValueError(f"Unsupported ip library: {name}. Supported: {supported}")
+
+ return library()
+
+
class ProxyChecker:
def __init__(
self,
@@ -316,6 +1223,7 @@ def __init__(
format_pattern: Optional[str] = None,
default_port: int = 1080,
include_asn_name: bool = False,
+ ip_library: str = "ip2location",
):
"""
初始化代理检测器
@@ -332,6 +1240,7 @@ def __init__(
self.format_pattern = format_pattern
self.default_port = default_port
self.include_asn_name = include_asn_name
+ self.ip_library = get_ip_library(ip_library)
self.results: List[TestResult] = []
self.summary: Optional[Dict[str, float]] = None
@@ -340,7 +1249,7 @@ def parse_proxy(self, text: str, format_pattern: Optional[str] = None) -> Option
解析代理字符串,支持自定义格式
支持的格式占位符:
- - {protocol}: 协议类型 (socks5/socks4/http等)
+ - {protocol}: 协议类型 (socks5/socks4/http/https等)
- {username}: 用户名
- {password}: 密码
- {host}: 主机地址
@@ -382,10 +1291,7 @@ def parse_proxy(self, text: str, format_pattern: Optional[str] = None) -> Option
prefix = f"socks5://{prefix}"
result = urlparse(prefix)
-
protocol = result.scheme or "socks5"
- if protocol == "https":
- protocol = "http"
return ProxyInfo(
protocol=protocol,
@@ -472,9 +1378,6 @@ def _parse_custom_format(self, text: str, format_pattern: str) -> Optional[Proxy
elif placeholder == "host":
host = value
- if protocol == "https":
- protocol = "http"
-
return ProxyInfo(
protocol=protocol,
username=username,
@@ -490,33 +1393,35 @@ async def test_proxy(self, proxy_info: ProxyInfo, retries: int = 3) -> TestResul
Test a single proxy with retries.
"""
result = TestResult.from_proxy(proxy_info)
-
- # Build proxy URL
- if proxy_info.username and proxy_info.password:
- proxy_url = (
- f"{proxy_info.protocol}://{proxy_info.username}:{proxy_info.password}"
- f"@{proxy_info.host}:{proxy_info.port}"
- )
- else:
- proxy_url = f"{proxy_info.protocol}://{proxy_info.host}:{proxy_info.port}"
-
start_time = datetime.now()
try:
- connector = ProxyConnector.from_url(proxy_url)
- async with aiohttp.ClientSession(connector=connector) as session:
- ip_address = await self._resolve_ip_with_proxy(session, proxy_info.host, retries)
- if not ip_address:
- result.error = "Failed to get IP from ipinfo.io/ip"
- return result
-
- ip_data, ip_error = await self._fetch_ipinfo(session, ip_address, retries)
- if not ip_data:
- result.error = ip_error or "Failed to get IP info from ipinfo.io"
+ protocol = (proxy_info.protocol or "").lower()
+ if protocol in ("http", "https"):
+ proxy_url = self._build_proxy_url(proxy_info, include_auth=False)
+ proxy_auth = self._build_proxy_auth(proxy_info)
+ connector = aiohttp.TCPConnector(ssl=False)
+ session = aiohttp.ClientSession(connector=connector, proxy=proxy_url, proxy_auth=proxy_auth)
+ else:
+ proxy_url = self._build_proxy_url(proxy_info, include_auth=True)
+ connector = ProxyConnector.from_url(proxy_url)
+ session = aiohttp.ClientSession(connector=connector)
+
+ async with session:
+ lookup = await self.ip_library.lookup(session, proxy_info, retries, self.timeout)
+ if not lookup.ip or not lookup.data:
+ result.error = lookup.error or f"Failed to get IP info from {self.ip_library.name}"
return result
- remark = self._build_remark_from_ipinfo(ip_data)
+ remark = await self.ip_library.build_remark(
+ session=session,
+ ip=lookup.ip,
+ data=lookup.data,
+ include_asn_name=self.include_asn_name,
+ retries=retries,
+ timeout=self.timeout,
+ )
result.remark = remark
- result.ip = ip_address
+ result.ip = lookup.ip
result.status = "success"
result.response_time = round((datetime.now() - start_time).total_seconds(), 2)
result.error = None
@@ -531,77 +1436,6 @@ async def test_proxy(self, proxy_info: ProxyInfo, retries: int = 3) -> TestResul
result.error = str(e)[:100]
return result
- def _is_ipv4(self, host: str) -> bool:
- if not host:
- return False
- try:
- return isinstance(ipaddress.ip_address(host), ipaddress.IPv4Address)
- except ValueError:
- return False
-
- async def _resolve_ip_with_proxy(self, session: aiohttp.ClientSession, host: str, retries: int) -> Optional[str]:
- if self._is_ipv4(host):
- return host
-
- url = "https://ipinfo.io/ip"
- for attempt in range(1, retries + 1):
- try:
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=self.timeout)) as response:
- if response.status == 200:
- text = (await response.text()).strip()
- try:
- ipaddress.ip_address(text)
- return text
- except ValueError:
- pass
- except asyncio.TimeoutError:
- pass
- except Exception:
- pass
-
- if attempt < retries:
- await asyncio.sleep(attempt)
-
- return None
-
- async def _fetch_ipinfo(
- self, session: aiohttp.ClientSession, ip_address: str, retries: int
- ) -> Tuple[Optional[Dict], Optional[str]]:
- url = f"https://ipinfo.io/widget/demo/{ip_address}"
- last_error = None
- for attempt in range(1, retries + 1):
- try:
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=self.timeout)) as response:
- if response.status == 200:
- data = await response.json()
- if isinstance(data, dict):
- return data.get("data", data), None
- last_error = "Invalid JSON response"
- else:
- last_error = f"HTTP {response.status}"
- except asyncio.TimeoutError:
- last_error = "Timeout"
- except Exception as e:
- last_error = str(e)[:100]
-
- if attempt < retries:
- await asyncio.sleep(attempt)
-
- return None, last_error
-
- def _country_flag_emoji(self, country_code: str) -> str:
- if not country_code or len(country_code) != 2:
- return ""
- code = country_code.upper()
- if not code.isalpha():
- return ""
- return chr(0x1F1E6 + ord(code[0]) - ord("A")) + chr(0x1F1E6 + ord(code[1]) - ord("A"))
-
- def _country_name_zh(self, country_code: str) -> str:
- if not country_code:
- return ""
- return COUNTRY_NAME_ZH.get(country_code.upper(), "")
-
def _format_standard(self, proxy_info: ProxyInfo, remark: str) -> str:
auth = ""
if proxy_info.username or proxy_info.password:
@@ -611,58 +1445,43 @@ def _format_standard(self, proxy_info: ProxyInfo, remark: str) -> str:
return f"{base}#{remark}"
return base
+ def _build_proxy_url(self, proxy_info: ProxyInfo, include_auth: bool) -> str:
+ auth = ""
+ if include_auth and (proxy_info.username or proxy_info.password):
+ username = quote(proxy_info.username or "", safe="")
+ password = quote(proxy_info.password or "", safe="")
+ auth = f"{username}:{password}@"
+
+ return f"{proxy_info.protocol}://{auth}{proxy_info.host}:{proxy_info.port}"
+
+ def _build_proxy_auth(self, proxy_info: ProxyInfo) -> Optional[aiohttp.BasicAuth]:
+ if not (proxy_info.username or proxy_info.password):
+ return None
+
+ return aiohttp.BasicAuth(proxy_info.username or "", proxy_info.password or "")
+
def _yaml_quote(self, value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _format_yaml_line(self, result: TestResult) -> str:
name = result.remark or result.host
+ protocol = (result.protocol or "").lower()
+ clash_type = "http" if protocol == "https" else protocol
parts = [
f"name: {self._yaml_quote(name)}",
f"server: {self._yaml_quote(result.host)}",
f"port: {result.port}",
- f"type: {self._yaml_quote(result.protocol)}",
+ f"type: {self._yaml_quote(clash_type)}",
]
+ if protocol == "https":
+ parts.append("tls: true")
if result.username:
parts.append(f"username: {self._yaml_quote(result.username)}")
if result.password:
parts.append(f"password: {self._yaml_quote(result.password)}")
return " - {" + ", ".join(parts) + "}"
- def _build_remark_from_ipinfo(self, ip_data: Dict) -> str:
- country_code = (ip_data.get("country") or "").upper()
- flag = self._country_flag_emoji(country_code)
- country_display = self._country_name_zh(country_code) or "未知"
-
- asn_info = ip_data.get("asn", {}) or {}
- company_info = ip_data.get("company", {}) or {}
- asn_type = (asn_info.get("type") or "").lower()
- company_type = (company_info.get("type") or "").lower()
-
- asn_name = (asn_info.get("domain") or "").strip()
- if not asn_name:
- asn_name = (asn_info.get("name") or "").strip()
-
- if asn_name:
- parts = [p for p in re.split(r"[\s,\.]+", asn_name) if p]
- company_name = parts[0].upper() if parts else "UNKNOWN"
- else:
- company_name = "UNKNOWN"
-
- if asn_type == "isp" and company_type == "isp":
- label = "家宽"
- elif asn_type == "isp" or company_type == "isp":
- label = "商宽"
- elif asn_type == "edu" or company_type == "edu":
- label = "教育"
- else:
- label = ""
-
- base = f"{flag} {country_display}{label}".strip()
- if self.include_asn_name and company_name:
- return f"{base} [{company_name}]".strip()
- return base
-
def _convert(self, input_file: str, output_file: str, output_format: str, digits: int = 2) -> None:
proxies = read_proxies(input_file)
if not proxies:
@@ -765,7 +1584,7 @@ async def check_proxies(
output_handle = None
if not output_file:
- output_file = f'{output_format}.{"txt" if output_format == "v2ray" else "yaml"}'
+ output_file = f'{output_format}-{self.ip_library}.{"txt" if output_format == "v2ray" else "yaml"}'
output_handle = open(output_file, "w", encoding="utf-8")
if output_format == "clash":
@@ -797,12 +1616,22 @@ async def test_with_semaphore(proxy_info):
# 实时输出结果
status_icon = "✓" if result.status == "success" else "✗"
if result.status == "success":
- print(f"{status_icon} {result.original[:60]}... | {result.response_time}s | IP: {result.ip}")
+ print(
+ f"{status_icon} {result.original[:60]}... | {result.response_time}s | Export IP: {result.ip}".encode(
+ "utf-8", errors="ignore"
+ ).decode(
+ "utf-8"
+ )
+ )
if write_queue:
line = self._format_yaml_line(result) if output_format == "clash" else result.proxy
await write_queue.put(line + "\n")
else:
- print(f"{status_icon} {result.original[:60]}... | {result.error}")
+ print(
+ f"{status_icon} {result.original[:60]}... | {result.error}".encode(
+ "utf-8", errors="ignore"
+ ).decode("utf-8")
+ )
async with stats_lock:
if result.status == "success":
@@ -930,8 +1759,8 @@ def _build_proxy(entry: Dict) -> Optional[str]:
return None
protocol = str(entry.get("type") or "socks5").strip().lower()
- if protocol == "https":
- protocol = "http"
+ if protocol == "http" and entry.get("tls") is True:
+ protocol = "https"
elif protocol == "socks":
protocol = "socks5"
@@ -982,7 +1811,8 @@ def _load_proxies(data) -> List[str]:
def _parse_yaml(text: str) -> Tuple[Optional[List[str]], Optional[object]]:
try:
- data = yaml.safe_load(text)
+ content = text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
+ data = yaml.safe_load(content)
except yaml.YAMLError:
return None, None
@@ -1055,7 +1885,7 @@ async def main():
%(prog)s -f proxies.txt --input-format "socks5://{host}:{port}:{username}:{password}"
支持的格式占位符:
- {protocol} - 协议类型 (socks5/socks4/http等)
+ {protocol} - 协议类型 (socks5/socks4/http/https等)
{username} - 用户名
{password} - 密码
{host} - 主机地址/IP
@@ -1089,6 +1919,14 @@ async def main():
help="在备注中追加 ASN 名称 (默认不追加)",
)
+ parser.add_argument(
+ "--ip-library",
+ dest="ip_library",
+ choices=sorted(IP_LIBRARIES.keys()),
+ default="ip2location",
+ help="IP地址数据库服务商: ip2location、iplark、ipinfo、ipnetcoffee、ippure 或 meowvps (默认: ip2location)",
+ )
+
args = parser.parse_args()
# 获取代理列表
@@ -1116,6 +1954,7 @@ async def main():
format_pattern=args.format_pattern,
default_port=args.default_port,
include_asn_name=args.include_asn_name,
+ ip_library=args.ip_library,
)
await checker.check_proxies(