""" 静态住宅 IP / 出口画像检测(参考 whoer、ping0.cc 维度)。 数据源: - ip-api.com:hosting / proxy / mobile、ISP、ASN、地理 - ping0.cc/geo:中文归属、ASN、运营商(本机默认出口) - ipify / ifconfig:公网出口 IP 采样 """ from __future__ import annotations import asyncio import re import time from typing import Any, Optional import httpx IP_API_FIELDS = ( "status,message,query,country,countryCode,regionName,city,zip,isp,org,as,asname," "mobile,proxy,hosting,reverse" ) IP_API_SELF = f"http://ip-api.com/json/?fields={IP_API_FIELDS}" IP_API_IP = f"http://ip-api.com/json/{{ip}}?fields={IP_API_FIELDS}" PING0_GEO_URL = "https://ping0.cc/geo" PING0_IPV4_GEO = "https://ipv4.ping0.cc/geo" IPIFY_URLS = ( "https://api.ipify.org?format=json", "https://api64.ipify.org?format=json", ) IFCONFIG_URL = "https://ifconfig.me/ip" IDC_KEYWORDS = ( "hosting", "datacenter", "data center", "cloud", "colo", "vps", "server", "amazon", "google cloud", "azure", "digitalocean", "linode", "vultr", "ovh", "hetzner", "leaseweb", "psychz", "choopa", "contabo", "spartan", "colocrossing", ) PROXY_KEYWORDS = ( "vpn", "proxy", "tor", "mullvad", "nordvpn", "expressvpn", "surfshark", "datacamp", "bright data", "luminati", ) RESIDENTIAL_KEYWORDS = ( "telecom", "broadband", "cable", "fiber", "fibre", "communications", "chinanet", "unicom", "cmcc", "telecom", "comcast", "verizon", "att", "residential", "home", "宽带", "电信", "联通", "移动", ) CONNECT_TIMEOUT = 8.0 READ_TIMEOUT = 12.0 UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) def _norm(s: Optional[str]) -> str: return (s or "").strip().lower() def _classify_ip_type(data: dict[str, Any]) -> tuple[str, str]: """返回 (type_id, label_zh)。""" if data.get("proxy"): return "proxy", "代理 / VPN" if data.get("hosting"): return "datacenter", "IDC 机房" if data.get("mobile"): return "mobile", "移动网络" blob = " ".join( _norm(data.get(k)) for k in ("isp", "org", "as", "asname", "reverse") if data.get(k) ) if any(k in blob for k in IDC_KEYWORDS): return "datacenter", "IDC 机房" if any(k in blob for k in PROXY_KEYWORDS): return "proxy", "代理 / VPN" if any(k in blob for k in RESIDENTIAL_KEYWORDS): return "residential", "家庭宽带" if data.get("isp"): return "residential", "疑似 ISP 宽带" return "unknown", "未知类型" def _quality_score(data: dict[str, Any], ip_type: str) -> int: score = 100 if data.get("hosting") or ip_type == "datacenter": score -= 55 if data.get("proxy") or ip_type == "proxy": score -= 40 if data.get("mobile") or ip_type == "mobile": score -= 15 blob = _norm(data.get("org")) + " " + _norm(data.get("isp")) if any(k in blob for k in PROXY_KEYWORDS): score -= 25 if ip_type == "residential": score += 5 return max(0, min(100, score)) def _quality_label(score: int) -> str: if score >= 85: return "优质(偏住宅)" if score >= 70: return "较干净" if score >= 45: return "一般" return "高风险 / 机房" def _guess_native(data: dict[str, Any], ip_type: str) -> tuple[Optional[bool], str]: if ip_type in ("datacenter", "proxy"): return False, "广播 / 非原生" cc = (data.get("countryCode") or "").upper() asname = _norm(data.get("asname")) org = _norm(data.get("org")) if not cc: return None, "无法判断" if cc == "CN" and any(x in org + asname for x in ("chinanet", "unicom", "cmcc", "telecom", "电信", "联通", "移动")): return True, "原生 IP(ISP)" if ip_type == "residential": return True, "疑似原生" if data.get("hosting"): return False, "广播 IP" return None, "待确认" def _parse_ping0_geo(text: str) -> dict[str, Any]: lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()] if len(lines) < 1: return {"error": "ping0 返回为空"} out: dict[str, Any] = {"ip": lines[0]} if len(lines) > 1: out["location"] = lines[1] if len(lines) > 2: out["asn"] = lines[2] if len(lines) > 3: out["org"] = lines[3] return out async def _fetch_ping0_geo(client: httpx.AsyncClient, ipv4: bool = True) -> dict[str, Any]: url = PING0_IPV4_GEO if ipv4 else PING0_GEO_URL try: r = await client.get(url, timeout=8.0) r.raise_for_status() return _parse_ping0_geo(r.text) except Exception as e: return {"error": str(e)} async def _ip_api_lookup(client: httpx.AsyncClient, ip: Optional[str] = None) -> dict[str, Any]: url = IP_API_IP.format(ip=ip) if ip else IP_API_SELF try: r = await client.get(url, timeout=8.0) r.raise_for_status() data = r.json() except Exception as e: return {"error": str(e), "query": ip} if data.get("status") != "success": return { "error": data.get("message") or "lookup failed", "query": ip or data.get("query"), } ip_type, ip_type_label = _classify_ip_type(data) score = _quality_score(data, ip_type) native, native_label = _guess_native(data, ip_type) return { "ip": data.get("query"), "country": data.get("country"), "country_code": data.get("countryCode"), "region": data.get("regionName"), "city": data.get("city"), "zip": data.get("zip"), "isp": data.get("isp"), "org": data.get("org"), "asn": data.get("as"), "asname": data.get("asname"), "reverse": data.get("reverse"), "mobile": bool(data.get("mobile")), "proxy": bool(data.get("proxy")), "hosting": bool(data.get("hosting")), "ip_type": ip_type, "ip_type_label": ip_type_label, "native": native, "native_label": native_label, "quality_score": score, "quality_label": _quality_label(score), "error": None, } async def _fetch_public_ip(client: httpx.AsyncClient) -> tuple[Optional[str], Optional[str]]: for url in IPIFY_URLS: try: r = await client.get(url, timeout=8.0) r.raise_for_status() data = r.json() ip = (data.get("ip") or "").strip() if ip: return ip, None except Exception: continue try: r = await client.get(IFCONFIG_URL, timeout=8.0) r.raise_for_status() ip = r.text.strip() if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip) or ":" in ip: return ip, None except Exception as e: return None, str(e) return None, "无法获取公网 IP" def _build_exit_profile( label: str, ip_api: dict[str, Any], ping0: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: out = {"label": label, **ip_api} if ping0 and not ping0.get("error"): out["ping0"] = ping0 return out def _tz_country_hint(tz: str) -> Optional[str]: """粗略:常见时区 → 国家码(仅用于 whoer 式一致性参考)。""" tz = (tz or "").strip() mapping = { "Asia/Shanghai": "CN", "Asia/Chongqing": "CN", "Asia/Hong_Kong": "HK", "Asia/Taipei": "TW", "America/New_York": "US", "America/Los_Angeles": "US", "Europe/London": "GB", "Europe/Berlin": "DE", "Asia/Tokyo": "JP", } return mapping.get(tz) def _build_checks( default_exit: dict[str, Any], overseas_exit: dict[str, Any], *, client_tz: str = "", client_lang: str = "", webrtc_leak: Optional[bool] = None, ) -> list[dict[str, Any]]: checks: list[dict[str, Any]] = [] def add(cid: str, label: str, ok: bool, hint: str = "") -> None: checks.append({"id": cid, "label": label, "ok": ok, "hint": hint}) d = default_exit o = overseas_exit add( "not_datacenter", "默认出口非机房 IP", d.get("ip_type") not in ("datacenter", "proxy") and not d.get("hosting"), d.get("ip_type_label") or "", ) add( "residential_like", "默认出口偏住宅 / ISP", d.get("ip_type") == "residential", "静态住宅推流更稳" if d.get("ip_type") != "residential" else "", ) add("not_proxy", "默认出口非代理标记", not d.get("proxy"), "ip-api 检测到 proxy=true" if d.get("proxy") else "") add( "native_ip", "原生 IP(参考)", d.get("native") is True, d.get("native_label") or "", ) add( "quality_ok", "IP 洁净度 ≥ 70", (d.get("quality_score") or 0) >= 70, f"评分 {d.get('quality_score', '—')} · {d.get('quality_label', '')}", ) d_ip = d.get("ip") o_ip = o.get("ip") if d_ip and o_ip: split = d_ip != o_ip add( "split_exit", "分流", split, "两路 IP 不同" if split else "两路 IP 相同", ) if client_tz and d.get("country_code"): hint_cc = _tz_country_hint(client_tz) if hint_cc: add( "tz_match", "系统时区与 IP 国家一致(参考)", hint_cc == (d.get("country_code") or "").upper(), f"时区 {client_tz}", ) if client_lang and d.get("country_code"): lang = client_lang.lower() cc = (d.get("country_code") or "").lower() if cc == "cn": lang_ok = lang.startswith("zh") elif cc == "us": lang_ok = lang.startswith("en") else: # 非中美地区不做严格语言判定,避免误报。 lang_ok = True add("lang_soft", "浏览器语言与 IP 地区不冲突", lang_ok, client_lang) if webrtc_leak is not None: add( "webrtc", "WebRTC 未泄露本地网卡 IP", not webrtc_leak, "检测到额外本地候选地址" if webrtc_leak else "", ) return checks async def run_ip_profile( *, client_tz: str = "", client_lang: str = "", webrtc_leak: Optional[bool] = None, ) -> dict[str, Any]: timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT) limits = httpx.Limits(max_keepalive_connections=6, max_connections=10) async with httpx.AsyncClient( timeout=timeout, limits=limits, verify=True, headers={"User-Agent": UA}, ) as client: ping0_task = asyncio.create_task(_fetch_ping0_geo(client)) default_api_task = asyncio.create_task(_ip_api_lookup(client, None)) overseas_ip_task = asyncio.create_task(_fetch_public_ip(client)) ping0, default_api, (overseas_ip, overseas_ip_err) = await asyncio.gather( ping0_task, default_api_task, overseas_ip_task ) if default_api.get("error") and ping0.get("ip") and not ping0.get("error"): default_api = await _ip_api_lookup(client, ping0["ip"]) overseas_api: dict[str, Any] = {"error": overseas_ip_err or "unknown"} if overseas_ip: overseas_api = await _ip_api_lookup(client, overseas_ip) default_exit = _build_exit_profile("当前出口", default_api, ping0) overseas_exit = _build_exit_profile("海外线路", overseas_api) checks = _build_checks( default_exit, overseas_exit, client_tz=client_tz, client_lang=client_lang, webrtc_leak=webrtc_leak, ) residential_ok = default_exit.get("ip_type") == "residential" and (default_exit.get("quality_score") or 0) >= 70 return { "default_exit": default_exit, "overseas_exit": overseas_exit, "checks": checks, "summary": { "residential_like": residential_ok, "headline": ( "当前出口偏静态住宅" if residential_ok else ( "出口疑似机房/代理,不适合伪装住宅" if default_exit.get("ip_type") in ("datacenter", "proxy") else "出口类型一般,建议结合代理规则确认" ) ), }, "meta": { "at": time.time(), "sources": ["ip-api.com", "ping0.cc/geo", "ipify"], "disclaimer": "结果仅供参考;ping0 完整 IDC/原生判定需其付费 API。", }, }