Files
robotex/src/api/geoip.py
T

137 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Резолвинг геолокации/таймзоны исходящего IP через сам прокси (не локальный IP), с кэшем в файле.
Раньше это делал Camoufox (geoip=True) под капотом. Смысл: если браузер репортит IP из
одной страны/города (через прокси), а navigator.timezone/geolocation — из другой, это
явное рассогласование, которое легко ловится антиботом. Поэтому таймзону/координаты нужно
брать по тому же IP, что увидит целевой сайт — то есть запрашивать через прокси, а не с
локальной машины.
Кэш нужен по двум причинам: у ipinfo.io есть квота на бесплатные запросы, и результат для
одного и того же прокси (пока он не сменил exit-IP) не меняется от запроса к запросу — незачем
дёргать внешний сервис на каждый /v1/solve.
"""
import asyncio
import json
import logging
import time
from pathlib import Path
import httpx
from patchright.async_api import ProxySettings
log = logging.getLogger(__name__)
IPINFO_URL = "https://ipinfo.io/json"
DEFAULT_CACHE_FILE = Path(__file__).resolve().parent.parent.parent / "extra" / "geoip_cache.json"
DEFAULT_TTL = 24 * 3600 # прокси может сменить exit-IP — не кэшируем навечно
DEFAULT_LOCALE = "en-US"
# Страна exit-IP -> основная локаль. Раньше это (как и timezone/geolocation) под капотом
# делал Camoufox(geoip=True); теперь резолвим сами и прокидываем в browserforge (locale
# согласованно выставляет navigator.language/languages и Accept-Language header) и в
# launch-флаги Chromium --lang=/--accept-lang= (engine/browser_launch.py). Не более чем
# точечная таблица под реально используемые прокси-гео — не претендует на полноту ISO-3166.
_COUNTRY_LOCALE: dict[str, str] = {
"RU": "ru-RU", "BY": "ru-BY", "KZ": "ru-KZ", "UA": "uk-UA",
"US": "en-US", "GB": "en-GB", "CA": "en-CA", "AU": "en-AU", "IE": "en-IE", "IN": "en-IN",
"DE": "de-DE", "AT": "de-AT", "CH": "de-CH",
"FR": "fr-FR", "BE": "fr-BE",
"ES": "es-ES", "MX": "es-MX", "AR": "es-AR",
"IT": "it-IT", "PT": "pt-PT", "BR": "pt-BR", "NL": "nl-NL",
"PL": "pl-PL", "CZ": "cs-CZ", "SK": "sk-SK", "RO": "ro-RO", "HU": "hu-HU",
"SE": "sv-SE", "NO": "nb-NO", "FI": "fi-FI", "DK": "da-DK",
"TR": "tr-TR", "GR": "el-GR",
"CN": "zh-CN", "TW": "zh-TW", "JP": "ja-JP", "KR": "ko-KR",
}
_cache_lock = asyncio.Lock()
def proxy_settings_to_url(proxy: ProxySettings) -> str:
server = proxy["server"]
username = proxy.get("username")
password = proxy.get("password")
if not username:
return server
scheme, _, rest = server.partition("://")
return f"{scheme}://{username}:{password}@{rest}"
def _load_cache(cache_file: Path) -> dict:
if not cache_file.exists():
return {}
try:
return json.loads(cache_file.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _save_cache(cache_file: Path, cache: dict) -> None:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(cache, indent=2, ensure_ascii=False))
async def resolve_geo(
proxy: ProxySettings,
timeout: float = 10.0,
cache_file: Path = DEFAULT_CACHE_FILE,
ttl: float = DEFAULT_TTL,
) -> dict | None:
"""Возвращает {"timezone_id", "geolocation": {"latitude", "longitude"}, "country", "ip", "locale"}
по IP, который виден снаружи через прокси, или None, если резолв не удался и в кэше
тоже ничего нет — в этом случае вызывающий код просто не переопределяет timezone/geolocation/locale.
"locale" — основная локаль страны exit-IP (см. _COUNTRY_LOCALE), с фолбэком DEFAULT_LOCALE
для неизвестных/отсутствующих кодов страны. Реальный человек с российским прокси не сидит
с английской раскладкой браузера, поэтому locale — такой же производный от IP сигнал, как
timezone/geolocation, а не независимое "предпочтение пользователя". Прокидывается вызывающим
кодом в browserforge (согласованно выставляет navigator.language/languages и Accept-Language,
см. engine/fingerprint.py) и в launch-флаги Chromium (engine/browser_launch.py)."""
key = proxy_settings_to_url(proxy)
async with _cache_lock:
cache = _load_cache(cache_file)
entry = cache.get(key)
if entry and (time.time() - entry["ts"]) < ttl:
return entry["data"]
try:
async with httpx.AsyncClient(proxy=key, timeout=timeout) as client:
resp = await client.get(IPINFO_URL)
resp.raise_for_status()
data = resp.json()
except Exception as e:
log.warning("не удалось резолвнуть geoip через прокси: %r", e)
# сеть/квота ipinfo могли отказать разово — лучше отдать протухший кэш, чем ничего,
# если прокси тот же самый (скорее всего и IP тот же)
if entry:
log.info("используем протухший geoip-кэш из-за сбоя запроса")
return entry["data"]
return None
loc = data.get("loc") # "lat,lon"
geolocation = None
if loc and "," in loc:
lat_str, lon_str = loc.split(",", 1)
try:
geolocation = {"latitude": float(lat_str), "longitude": float(lon_str)}
except ValueError:
geolocation = None
country = data.get("country") # "RU", "US", ...
result = {
"ip": data.get("ip"),
"country": country,
"timezone_id": data.get("timezone"),
"geolocation": geolocation,
"locale": _COUNTRY_LOCALE.get(country, DEFAULT_LOCALE),
}
async with _cache_lock:
cache = _load_cache(cache_file)
cache[key] = {"ts": time.time(), "data": result}
_save_cache(cache_file, cache)
return result