| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- """根据代理出口的地理位置自动生成匹配的浏览器指纹参数。
- 支持通过不同的探测域名拿到不同分流链路上的真实出口信息,
- 例如 OpenAI 走日本节点、PayPal 走美国节点时,即使都共用同一个
- 本地 Clash 端口,也能分别得到对应链路的 locale / timezone。
- """
- from __future__ import annotations
- import json
- import urllib.request
- import urllib.error
- from typing import Callable
- from dataclasses import dataclass
- # 国家代码 → (locale, Accept-Language header)
- _COUNTRY_LOCALE_MAP: dict[str, tuple[str, str]] = {
- "JP": ("ja-JP", "ja-JP,ja;q=0.9,en;q=0.5"),
- "US": ("en-US", "en-US,en;q=0.9"),
- "GB": ("en-GB", "en-GB,en;q=0.9"),
- "CA": ("en-CA", "en-CA,en;q=0.9,fr;q=0.5"),
- "AU": ("en-AU", "en-AU,en;q=0.9"),
- "DE": ("de-DE", "de-DE,de;q=0.9,en;q=0.5"),
- "FR": ("fr-FR", "fr-FR,fr;q=0.9,en;q=0.5"),
- "KR": ("ko-KR", "ko-KR,ko;q=0.9,en;q=0.5"),
- "TW": ("zh-TW", "zh-TW,zh;q=0.9,en;q=0.5"),
- "HK": ("zh-HK", "zh-HK,zh;q=0.9,en;q=0.5"),
- "SG": ("en-SG", "en-SG,en;q=0.9,zh;q=0.5"),
- "IN": ("en-IN", "en-IN,en;q=0.9,hi;q=0.5"),
- "BR": ("pt-BR", "pt-BR,pt;q=0.9,en;q=0.5"),
- "MX": ("es-MX", "es-MX,es;q=0.9,en;q=0.5"),
- "ES": ("es-ES", "es-ES,es;q=0.9,en;q=0.5"),
- "IT": ("it-IT", "it-IT,it;q=0.9,en;q=0.5"),
- "NL": ("nl-NL", "nl-NL,nl;q=0.9,en;q=0.5"),
- "RU": ("ru-RU", "ru-RU,ru;q=0.9,en;q=0.5"),
- "TR": ("tr-TR", "tr-TR,tr;q=0.9,en;q=0.5"),
- "TH": ("th-TH", "th-TH,th;q=0.9,en;q=0.5"),
- "VN": ("vi-VN", "vi-VN,vi;q=0.9,en;q=0.5"),
- "PH": ("en-PH", "en-PH,en;q=0.9,fil;q=0.5"),
- "ID": ("id-ID", "id-ID,id;q=0.9,en;q=0.5"),
- "MY": ("ms-MY", "ms-MY,ms;q=0.9,en;q=0.5"),
- "CN": ("zh-CN", "zh-CN,zh;q=0.9,en;q=0.5"),
- }
- _DEFAULT_LOCALE = "en-US"
- _DEFAULT_ACCEPT_LANG = "en-US,en;q=0.9"
- _DEFAULT_TIMEZONE = "America/New_York"
- _DEFAULT_PROBE_URL = "http://ip-api.com/json/?fields=status,countryCode,country,city,timezone,query"
- # 通过 Clash 规则把这两个探测域名分别固定到日本 / 美国出口,
- # 从而为不同业务链路生成匹配的浏览器指纹。
- OPENAI_GEO_PROBE_URL = "https://ipinfo.io/json"
- PAYPAL_GEO_PROBE_URL = "https://api.ip.sb/geoip"
- @dataclass
- class GeoFingerprint:
- country_code: str
- locale: str
- timezone_id: str
- accept_language: str
- ip: str = ""
- city: str = ""
- def detect_geo_fingerprint(
- proxy_url: str = "",
- *,
- log: Callable[[str], None] = print,
- timeout: int = 10,
- probe_url: str = _DEFAULT_PROBE_URL,
- ) -> GeoFingerprint:
- """通过代理请求探测接口检测出口 IP 的地理信息,返回匹配的浏览器指纹参数。
- proxy_url 为空时直连检测。检测失败时回退到美国默认值。
- """
- result = _query_geo_payload(proxy_url, probe_url=probe_url, log=log, timeout=timeout)
- if not result:
- log(f"[geo] IP 检测失败 probe={probe_url},使用默认指纹(美国)")
- return GeoFingerprint(
- country_code="US",
- locale=_DEFAULT_LOCALE,
- timezone_id=_DEFAULT_TIMEZONE,
- accept_language=_DEFAULT_ACCEPT_LANG,
- )
- country = _extract_country_code(result)
- timezone = str(result.get("timezone") or "")
- ip = str(result.get("query") or result.get("ip") or "")
- city = str(result.get("city") or "")
- locale_info = _COUNTRY_LOCALE_MAP.get(country, (_DEFAULT_LOCALE, _DEFAULT_ACCEPT_LANG))
- locale_str, accept_lang = locale_info
- tz = timezone if timezone else _DEFAULT_TIMEZONE
- fp = GeoFingerprint(
- country_code=country,
- locale=locale_str,
- timezone_id=tz,
- accept_language=accept_lang,
- ip=ip,
- city=city,
- )
- log(f"[geo] probe={probe_url} IP={ip} 国家={country} 城市={city} → locale={locale_str} timezone={tz}")
- return fp
- def detect_openai_geo_fingerprint(
- proxy_url: str = "",
- *,
- log: Callable[[str], None] = print,
- timeout: int = 10,
- ) -> GeoFingerprint:
- """为 OpenAI 链路生成指纹。"""
- return detect_geo_fingerprint(
- proxy_url,
- log=log,
- timeout=timeout,
- probe_url=OPENAI_GEO_PROBE_URL,
- )
- def detect_paypal_geo_fingerprint(
- proxy_url: str = "",
- *,
- log: Callable[[str], None] = print,
- timeout: int = 10,
- ) -> GeoFingerprint:
- """为 PayPal / Stripe 链路生成指纹。"""
- return detect_geo_fingerprint(
- proxy_url,
- log=log,
- timeout=timeout,
- probe_url=PAYPAL_GEO_PROBE_URL,
- )
- def _extract_country_code(result: dict) -> str:
- country = (
- result.get("countryCode")
- or result.get("country_code")
- or result.get("country")
- or "US"
- )
- country_str = str(country).strip().upper()
- if len(country_str) == 2:
- return country_str
- return "US"
- def _query_geo_payload(
- proxy_url: str,
- *,
- probe_url: str,
- log: Callable[[str], None],
- timeout: int = 10,
- ) -> dict | None:
- """调用探测接口获取地理信息。"""
- req = urllib.request.Request(probe_url, method="GET", headers={
- "Accept": "application/json",
- "User-Agent": "Mozilla/5.0",
- })
- opener = urllib.request.build_opener()
- if proxy_url and proxy_url.strip():
- proxy_handler = urllib.request.ProxyHandler({
- "http": proxy_url.strip(),
- "https": proxy_url.strip(),
- })
- opener = urllib.request.build_opener(proxy_handler)
- try:
- with opener.open(req, timeout=timeout) as resp:
- text = resp.read().decode("utf-8", errors="replace")
- data = json.loads(text)
- if not isinstance(data, dict):
- log(f"[geo] 探测接口返回非对象 probe={probe_url}: {text[:200]}")
- return None
- if data.get("status") == "fail":
- log(f"[geo] 探测接口返回失败 probe={probe_url}: {text[:200]}")
- return None
- if _extract_country_code(data):
- return data
- log(f"[geo] 探测接口缺少国家信息 probe={probe_url}: {text[:200]}")
- return None
- except Exception as exc:
- log(f"[geo] 探测接口请求失败 probe={probe_url}: {exc!r}")
- return None
|