geo_fingerprint.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. """根据代理出口的地理位置自动生成匹配的浏览器指纹参数。
  2. 支持通过不同的探测域名拿到不同分流链路上的真实出口信息,
  3. 例如 OpenAI 走日本节点、PayPal 走美国节点时,即使都共用同一个
  4. 本地 Clash 端口,也能分别得到对应链路的 locale / timezone。
  5. """
  6. from __future__ import annotations
  7. import json
  8. import urllib.request
  9. import urllib.error
  10. from typing import Callable
  11. from dataclasses import dataclass
  12. # 国家代码 → (locale, Accept-Language header)
  13. _COUNTRY_LOCALE_MAP: dict[str, tuple[str, str]] = {
  14. "JP": ("ja-JP", "ja-JP,ja;q=0.9,en;q=0.5"),
  15. "US": ("en-US", "en-US,en;q=0.9"),
  16. "GB": ("en-GB", "en-GB,en;q=0.9"),
  17. "CA": ("en-CA", "en-CA,en;q=0.9,fr;q=0.5"),
  18. "AU": ("en-AU", "en-AU,en;q=0.9"),
  19. "DE": ("de-DE", "de-DE,de;q=0.9,en;q=0.5"),
  20. "FR": ("fr-FR", "fr-FR,fr;q=0.9,en;q=0.5"),
  21. "KR": ("ko-KR", "ko-KR,ko;q=0.9,en;q=0.5"),
  22. "TW": ("zh-TW", "zh-TW,zh;q=0.9,en;q=0.5"),
  23. "HK": ("zh-HK", "zh-HK,zh;q=0.9,en;q=0.5"),
  24. "SG": ("en-SG", "en-SG,en;q=0.9,zh;q=0.5"),
  25. "IN": ("en-IN", "en-IN,en;q=0.9,hi;q=0.5"),
  26. "BR": ("pt-BR", "pt-BR,pt;q=0.9,en;q=0.5"),
  27. "MX": ("es-MX", "es-MX,es;q=0.9,en;q=0.5"),
  28. "ES": ("es-ES", "es-ES,es;q=0.9,en;q=0.5"),
  29. "IT": ("it-IT", "it-IT,it;q=0.9,en;q=0.5"),
  30. "NL": ("nl-NL", "nl-NL,nl;q=0.9,en;q=0.5"),
  31. "RU": ("ru-RU", "ru-RU,ru;q=0.9,en;q=0.5"),
  32. "TR": ("tr-TR", "tr-TR,tr;q=0.9,en;q=0.5"),
  33. "TH": ("th-TH", "th-TH,th;q=0.9,en;q=0.5"),
  34. "VN": ("vi-VN", "vi-VN,vi;q=0.9,en;q=0.5"),
  35. "PH": ("en-PH", "en-PH,en;q=0.9,fil;q=0.5"),
  36. "ID": ("id-ID", "id-ID,id;q=0.9,en;q=0.5"),
  37. "MY": ("ms-MY", "ms-MY,ms;q=0.9,en;q=0.5"),
  38. "CN": ("zh-CN", "zh-CN,zh;q=0.9,en;q=0.5"),
  39. }
  40. _DEFAULT_LOCALE = "en-US"
  41. _DEFAULT_ACCEPT_LANG = "en-US,en;q=0.9"
  42. _DEFAULT_TIMEZONE = "America/New_York"
  43. _DEFAULT_PROBE_URL = "http://ip-api.com/json/?fields=status,countryCode,country,city,timezone,query"
  44. # 通过 Clash 规则把这两个探测域名分别固定到日本 / 美国出口,
  45. # 从而为不同业务链路生成匹配的浏览器指纹。
  46. OPENAI_GEO_PROBE_URL = "https://ipinfo.io/json"
  47. PAYPAL_GEO_PROBE_URL = "https://api.ip.sb/geoip"
  48. @dataclass
  49. class GeoFingerprint:
  50. country_code: str
  51. locale: str
  52. timezone_id: str
  53. accept_language: str
  54. ip: str = ""
  55. city: str = ""
  56. def detect_geo_fingerprint(
  57. proxy_url: str = "",
  58. *,
  59. log: Callable[[str], None] = print,
  60. timeout: int = 10,
  61. probe_url: str = _DEFAULT_PROBE_URL,
  62. ) -> GeoFingerprint:
  63. """通过代理请求探测接口检测出口 IP 的地理信息,返回匹配的浏览器指纹参数。
  64. proxy_url 为空时直连检测。检测失败时回退到美国默认值。
  65. """
  66. result = _query_geo_payload(proxy_url, probe_url=probe_url, log=log, timeout=timeout)
  67. if not result:
  68. log(f"[geo] IP 检测失败 probe={probe_url},使用默认指纹(美国)")
  69. return GeoFingerprint(
  70. country_code="US",
  71. locale=_DEFAULT_LOCALE,
  72. timezone_id=_DEFAULT_TIMEZONE,
  73. accept_language=_DEFAULT_ACCEPT_LANG,
  74. )
  75. country = _extract_country_code(result)
  76. timezone = str(result.get("timezone") or "")
  77. ip = str(result.get("query") or result.get("ip") or "")
  78. city = str(result.get("city") or "")
  79. locale_info = _COUNTRY_LOCALE_MAP.get(country, (_DEFAULT_LOCALE, _DEFAULT_ACCEPT_LANG))
  80. locale_str, accept_lang = locale_info
  81. tz = timezone if timezone else _DEFAULT_TIMEZONE
  82. fp = GeoFingerprint(
  83. country_code=country,
  84. locale=locale_str,
  85. timezone_id=tz,
  86. accept_language=accept_lang,
  87. ip=ip,
  88. city=city,
  89. )
  90. log(f"[geo] probe={probe_url} IP={ip} 国家={country} 城市={city} → locale={locale_str} timezone={tz}")
  91. return fp
  92. def detect_openai_geo_fingerprint(
  93. proxy_url: str = "",
  94. *,
  95. log: Callable[[str], None] = print,
  96. timeout: int = 10,
  97. ) -> GeoFingerprint:
  98. """为 OpenAI 链路生成指纹。"""
  99. return detect_geo_fingerprint(
  100. proxy_url,
  101. log=log,
  102. timeout=timeout,
  103. probe_url=OPENAI_GEO_PROBE_URL,
  104. )
  105. def detect_paypal_geo_fingerprint(
  106. proxy_url: str = "",
  107. *,
  108. log: Callable[[str], None] = print,
  109. timeout: int = 10,
  110. ) -> GeoFingerprint:
  111. """为 PayPal / Stripe 链路生成指纹。"""
  112. return detect_geo_fingerprint(
  113. proxy_url,
  114. log=log,
  115. timeout=timeout,
  116. probe_url=PAYPAL_GEO_PROBE_URL,
  117. )
  118. def _extract_country_code(result: dict) -> str:
  119. country = (
  120. result.get("countryCode")
  121. or result.get("country_code")
  122. or result.get("country")
  123. or "US"
  124. )
  125. country_str = str(country).strip().upper()
  126. if len(country_str) == 2:
  127. return country_str
  128. return "US"
  129. def _query_geo_payload(
  130. proxy_url: str,
  131. *,
  132. probe_url: str,
  133. log: Callable[[str], None],
  134. timeout: int = 10,
  135. ) -> dict | None:
  136. """调用探测接口获取地理信息。"""
  137. req = urllib.request.Request(probe_url, method="GET", headers={
  138. "Accept": "application/json",
  139. "User-Agent": "Mozilla/5.0",
  140. })
  141. opener = urllib.request.build_opener()
  142. if proxy_url and proxy_url.strip():
  143. proxy_handler = urllib.request.ProxyHandler({
  144. "http": proxy_url.strip(),
  145. "https": proxy_url.strip(),
  146. })
  147. opener = urllib.request.build_opener(proxy_handler)
  148. try:
  149. with opener.open(req, timeout=timeout) as resp:
  150. text = resp.read().decode("utf-8", errors="replace")
  151. data = json.loads(text)
  152. if not isinstance(data, dict):
  153. log(f"[geo] 探测接口返回非对象 probe={probe_url}: {text[:200]}")
  154. return None
  155. if data.get("status") == "fail":
  156. log(f"[geo] 探测接口返回失败 probe={probe_url}: {text[:200]}")
  157. return None
  158. if _extract_country_code(data):
  159. return data
  160. log(f"[geo] 探测接口缺少国家信息 probe={probe_url}: {text[:200]}")
  161. return None
  162. except Exception as exc:
  163. log(f"[geo] 探测接口请求失败 probe={probe_url}: {exc!r}")
  164. return None