feat(tradein/proxy_rotation): ротация IP через mobileproxy.space (aproxy.site) #3298
2 changed files with 506 additions and 66 deletions
|
|
@ -1,8 +1,13 @@
|
|||
"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5).
|
||||
"""Ротация exit-IP прокси по требованию, со счётчиком и громким отказом (#2600 п.5).
|
||||
|
||||
АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный
|
||||
PR #2609, конфликт исключён: вся новая логика тут, в новом модуле).
|
||||
|
||||
Два провайдера, диспетчеризуются по хосту scrape_proxies.rotate_url (см.
|
||||
"⛔ Хост-пиннинг" ниже) — секрет каждого провайдера уходит ТОЛЬКО на его хост.
|
||||
|
||||
── ASocks (api.asocks.com) ────────────────────────────────────────────────────
|
||||
|
||||
Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой):
|
||||
- Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=)
|
||||
для безлимитных портов НЕ работает.
|
||||
|
|
@ -19,12 +24,34 @@ PR #2609, конфликт исключён: вся новая логика ту
|
|||
Когда это случится, провайдер ответит 401 — это ГРОМКИЙ отказ ниже
|
||||
(logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка.
|
||||
|
||||
── mobileproxy.space (aproxy.site) ─────────────────────────────────────────────
|
||||
|
||||
Протокол ротации (эмпирика, проверено вживую на проде):
|
||||
- GET <rotate_url>&format=json — БЕЗ заголовков авторизации, секрет (proxy_key)
|
||||
уже внутри URL в query-string. Без format=json провайдер отдаёт HTML-страницу
|
||||
вместо JSON.
|
||||
- Успех: {"status": "OK", "code": 200, "new_ip": "...", "rt": "2.41", "proxy_id": N}.
|
||||
Признак успеха — ИМЕННО status == "OK" в теле, не HTTP-код ответа.
|
||||
"rt" — секунды на переподключение канала (2.4-11.7 в замерах), отдаём
|
||||
вызывающему как RotationResult.reconnect_delay_s — следующая задача
|
||||
(интеграция в скраперы) должна выждать это время перед повторным запросом.
|
||||
- Кулдауна у провайдера не обнаружено (13 ротаций подряд — 13 уникальных IP,
|
||||
две подряд прошли за 5 и 8 секунд). MOBILEPROXY_DAILY_ROTATION_LIMIT (200) —
|
||||
это ТОЛЬКО наш предохранитель от runaway-цикла, не лимит провайдера.
|
||||
- Домен changeip.mobileproxy.space из прод-сети не резолвится/не отвечает —
|
||||
поддерживаем только aproxy.site.
|
||||
- proxy_key — секрет ровно как ASOCKS_API_TOKEN: не должен появиться ни в
|
||||
reason, ни в исключениях, ни в scrape_proxy_rotations, ни в логах (см.
|
||||
"⛔ Токен никогда не должен появиться..." ниже — тот же принцип).
|
||||
|
||||
Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198):
|
||||
Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера
|
||||
и обработанные им — т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование:
|
||||
401 — это буквально описание провайдера "Unauthenticated": запрос отсеян на
|
||||
уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не
|
||||
мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые
|
||||
Против лимита (3/сутки ASocks, 200/сутки mobileproxy) считаются ТОЛЬКО попытки,
|
||||
реально дошедшие до провайдера и обработанные им — т.е. любой HTTP-ответ
|
||||
провайдера, КРОМЕ 401. Обоснование: 401 — это буквально описание провайдера
|
||||
"Unauthenticated": запрос отсеян на уровне аутентификации ДО обращения к самой
|
||||
логике ротации порта, провайдер не мог засчитать использование ротации тому,
|
||||
кого даже не подтвердил (у mobileproxy авторизации нет вовсе, поэтому 401 для
|
||||
него не ожидается, но правило проверки общее для обоих провайдеров). Сетевые
|
||||
ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не
|
||||
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
|
||||
отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не
|
||||
|
|
@ -35,27 +62,27 @@ PR #2609, конфликт исключён: вся новая логика ту
|
|||
если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до
|
||||
реальной rotate-логики и мог быть учтён в лимите на его стороне).
|
||||
|
||||
⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
|
||||
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
|
||||
str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь
|
||||
admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали,
|
||||
поэтому наружу — только нейтральный reason, полные детали — в лог с exc_info=True.
|
||||
⛔ Токен/ключ провайдера никогда не должен появиться в возвращаемом клиенту
|
||||
reason, в тексте исключения, ни в одной записи scrape_proxy_rotations. Прецедент
|
||||
утечки через str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3)
|
||||
changeip-путь admin.rotate_proxy_ip: httpx-исключения несут полный request
|
||||
URL/детали, поэтому наружу — только нейтральный reason, полные детали — в лог с
|
||||
exc_info=True.
|
||||
|
||||
⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
|
||||
НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
|
||||
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
|
||||
тот же формат, что читал удалённый #2616 шаг 2/3 admin.rotate_proxy_ip /
|
||||
Settings.avito_proxy_rotate_url), не ASocks.
|
||||
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
|
||||
на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно,
|
||||
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
|
||||
успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет
|
||||
urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) — несовпадение
|
||||
это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без
|
||||
Authorization: смысл ручной ротации — конкретный провайдер (ASocks), молчаливый
|
||||
вызов чужой ручки без авторизации — это сюрприз оператору (он думает "ASocks
|
||||
ротировал", а фактически задел mobileproxy), которого проще не допустить, чем
|
||||
потом объяснять админу расхождение счётчиков.
|
||||
НЕОДНОРОДНА — часть строк пула несёт ASocks-ссылки, часть — mobileproxy
|
||||
aproxy.site-ссылки (`https://aproxy.site/?proxy_key=<секрет>`), формат которых
|
||||
похож на старый (ныне неподдерживаемый) changeip.mobileproxy.space путь, но это
|
||||
ДРУГОЙ хост с ДРУГИМ протоколом ротации (GET, без заголовков).
|
||||
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` мог бы
|
||||
уйти на ЧУЖОЙ провайдер. rotate_proxy ПЕРЕД любым HTTP-вызовом определяет
|
||||
провайдера по urlparse(rotate_url).hostname (https-only, exact match) через
|
||||
_rotate_provider_for_host — несовпадение ни с одним известным хостом это ОТКАЗ
|
||||
(ok=False, нейтральный reason), а НЕ попытка безголового запроса: смысл ручной
|
||||
ротации — конкретный провайдер, молчаливый вызов чужой ручки — это сюрприз
|
||||
оператору (он думает "ASocks ротировал", а фактически задел другого
|
||||
провайдера), которого проще не допустить, чем потом объяснять админу
|
||||
расхождение счётчиков.
|
||||
|
||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||
"""
|
||||
|
|
@ -77,8 +104,10 @@ from app.services.proxy_pool import clear_source_bans
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_MOBILEPROXY_ROTATE_HOST",
|
||||
"ALLOWED_ROTATE_HOST",
|
||||
"DAILY_ROTATION_LIMIT",
|
||||
"MOBILEPROXY_DAILY_ROTATION_LIMIT",
|
||||
"RotationResult",
|
||||
"rotate_proxy",
|
||||
]
|
||||
|
|
@ -86,23 +115,42 @@ __all__ = [
|
|||
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
|
||||
DAILY_ROTATION_LIMIT = 3
|
||||
|
||||
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
|
||||
# У mobileproxy.space кулдауна/лимита провайдера не обнаружено (эмпирика: 13 ротаций
|
||||
# подряд дали 13 уникальных IP). Это наш собственный предохранитель от runaway-цикла
|
||||
# ротаций, НЕ ограничение провайдера.
|
||||
MOBILEPROXY_DAILY_ROTATION_LIMIT = 200
|
||||
|
||||
# Таймаут HTTP-вызова ротации (оба провайдера). Пункт задачи требует "~30с".
|
||||
_ROTATE_TIMEOUT_S = 30.0
|
||||
|
||||
# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке
|
||||
# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может
|
||||
# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
|
||||
# нести ЧУЖИЕ rotate-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
|
||||
ALLOWED_ROTATE_HOST = "api.asocks.com"
|
||||
|
||||
# Единственный хост mobileproxy.space, поддерживаемый нашей ротацией. Домен
|
||||
# changeip.mobileproxy.space из прод-сети не резолвится/не отвечает — НЕ добавлять.
|
||||
ALLOWED_MOBILEPROXY_ROTATE_HOST = "aproxy.site"
|
||||
|
||||
def _is_allowed_rotate_url(url: str) -> bool:
|
||||
"""https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо —
|
||||
urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url."""
|
||||
|
||||
def _rotate_provider_for_host(url: str) -> str | None:
|
||||
"""Определить провайдера ротации по хосту rotate_url (https-only, exact match,
|
||||
без исключений на кривом url — см. "⛔ Хост-пиннинг" в docstring модуля).
|
||||
|
||||
Возвращает "asocks" / "mobileproxy" / None (неизвестный/неразрешённый хост —
|
||||
отказ ДО любого HTTP-вызова, чтобы не увести чужой секрет на непроверенный хост).
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST
|
||||
return None
|
||||
if parsed.scheme != "https":
|
||||
return None
|
||||
if parsed.hostname == ALLOWED_ROTATE_HOST:
|
||||
return "asocks"
|
||||
if parsed.hostname == ALLOWED_MOBILEPROXY_ROTATE_HOST:
|
||||
return "mobileproxy"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -116,7 +164,15 @@ class RotationResult:
|
|||
# Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль
|
||||
# docstring за определением quota-consuming). Для локально отклонённых попыток
|
||||
# (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток.
|
||||
# Лимит зависит от провайдера (DAILY_ROTATION_LIMIT для ASocks,
|
||||
# MOBILEPROXY_DAILY_ROTATION_LIMIT для mobileproxy) — значение по умолчанию тут
|
||||
# используется только до определения провайдера, вызывающая ветка всегда
|
||||
# подставляет правильное число.
|
||||
rotations_remaining_today: int = DAILY_ROTATION_LIMIT
|
||||
# mobileproxy-only: секунды на переподключение канала после ротации ("rt" в
|
||||
# ответе провайдера) — следующая задача (интеграция в скраперы) ждёт это время
|
||||
# перед повторным запросом через прокси. None для ASocks и для неуспешных попыток.
|
||||
reconnect_delay_s: float | None = None
|
||||
|
||||
|
||||
def _quota_used_today(db: Session, proxy_id: int) -> int:
|
||||
|
|
@ -173,6 +229,21 @@ def _record_attempt(
|
|||
db.commit()
|
||||
|
||||
|
||||
def _update_exit_ip(db: Session, proxy_id: int, new_ip: str) -> None:
|
||||
"""Обновить scrape_proxies.exit_ip после успешной ротации (mobileproxy)."""
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_proxies
|
||||
SET exit_ip = CAST(:exit_ip AS text), updated_at = now()
|
||||
WHERE id = CAST(:proxy_id AS bigint)
|
||||
"""
|
||||
),
|
||||
{"exit_ip": new_ip, "proxy_id": proxy_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _alert_stale_token(proxy_id: int) -> None:
|
||||
"""Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort).
|
||||
|
||||
|
|
@ -222,29 +293,30 @@ def _extract_new_ip(resp: httpx.Response) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _parse_rt(raw: Any) -> float | None:
|
||||
"""Распарсить "rt" (секунды переподключения канала) из ответа mobileproxy.
|
||||
Формат не документирован — best-effort, неудача не является ошибкой ротации."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
||||
"""Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5).
|
||||
"""Сменить exit-IP одного прокси пула — диспетчер по провайдеру (#2600 п.5).
|
||||
|
||||
Порядок:
|
||||
1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный.
|
||||
2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка).
|
||||
3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) → ok=False
|
||||
ДО HTTP-вызова — токен не должен уйти на чужой провайдер (mobileproxy
|
||||
changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле).
|
||||
4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False,
|
||||
внятный отказ, ничего не ломается.
|
||||
5. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ
|
||||
обращения к API.
|
||||
6. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
|
||||
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
||||
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
||||
- 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
|
||||
в лимите), нейтральный reason.
|
||||
- Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл
|
||||
auth и ответил своей бизнес-логикой), нейтральный reason.
|
||||
- 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort.
|
||||
3. rotate_url хост определяет провайдера (_rotate_provider_for_host) ДО
|
||||
любого HTTP-вызова — секрет провайдера не должен уйти на чужой хост
|
||||
(см. "⛔ Хост-пиннинг" в docstring модуля). Неизвестный хост → ok=False.
|
||||
4. Известный провайдер → делегируем в _rotate_via_asocks /
|
||||
_rotate_via_mobileproxy (детали протокола — там и в docstring модуля).
|
||||
|
||||
Ни в одном из reason/логов НЕ появляется токен.
|
||||
Ни в одном из reason/логов НЕ появляется токен/ключ.
|
||||
"""
|
||||
row = (
|
||||
db.execute(
|
||||
|
|
@ -266,24 +338,51 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
|||
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
|
||||
)
|
||||
|
||||
if not _is_allowed_rotate_url(rotate_url):
|
||||
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
|
||||
# mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш
|
||||
# Authorization: Bearer <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
|
||||
# Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он
|
||||
# несёт их собственный секрет в query-string, тот же класс утечки, что
|
||||
# и в rotate_proxy_ip, см. модуль docstring).
|
||||
logger.warning(
|
||||
"proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host "
|
||||
"(%s) — refusing before any HTTP call to avoid leaking the token to it",
|
||||
proxy_id,
|
||||
urlparse(rotate_url).hostname,
|
||||
ALLOWED_ROTATE_HOST,
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
|
||||
)
|
||||
provider = _rotate_provider_for_host(rotate_url)
|
||||
|
||||
if provider == "asocks":
|
||||
return await _rotate_via_asocks(db, proxy_id, rotate_url)
|
||||
|
||||
if provider == "mobileproxy":
|
||||
return await _rotate_via_mobileproxy(db, proxy_id, rotate_url)
|
||||
|
||||
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
|
||||
# ссылки других провайдеров) — отправлять секрет ЛЮБОГО провайдера на
|
||||
# непроверенный хост нельзя. Логируем ТОЛЬКО hostname (не полный url — на
|
||||
# других провайдерах он несёт их собственный секрет в query-string, тот же
|
||||
# класс утечки, что и в rotate_proxy_ip, см. модуль docstring).
|
||||
logger.warning(
|
||||
"proxy_rotation: proxy_id=%d rotate_url host=%r is not a recognised rotation "
|
||||
"provider (allowed: %s, %s) — refusing before any HTTP call to avoid leaking "
|
||||
"credentials to it",
|
||||
proxy_id,
|
||||
urlparse(rotate_url).hostname,
|
||||
ALLOWED_ROTATE_HOST,
|
||||
ALLOWED_MOBILEPROXY_ROTATE_HOST,
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
|
||||
)
|
||||
|
||||
|
||||
async def _rotate_via_asocks(db: Session, proxy_id: int, rotate_url: str) -> RotationResult:
|
||||
"""ASocks refresh-ip (см. docstring модуля § ASocks). rotate_url уже проверен
|
||||
вызывающим (_rotate_provider_for_host == "asocks").
|
||||
|
||||
Порядок:
|
||||
1. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False,
|
||||
внятный отказ, ничего не ломается.
|
||||
2. Суточный лимит (см. _quota_used_today, DAILY_ROTATION_LIMIT) исчерпан →
|
||||
ok=False, отказ БЕЗ обращения к API.
|
||||
3. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
|
||||
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
||||
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
||||
- 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
|
||||
в лимите), нейтральный reason.
|
||||
- Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл
|
||||
auth и ответил своей бизнес-логикой), нейтральный reason.
|
||||
- 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort.
|
||||
"""
|
||||
token = settings.asocks_api_token
|
||||
if not token:
|
||||
logger.warning(
|
||||
|
|
@ -374,3 +473,136 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
|||
new_ip=new_ip,
|
||||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
|
||||
)
|
||||
|
||||
|
||||
async def _rotate_via_mobileproxy(db: Session, proxy_id: int, rotate_url: str) -> RotationResult:
|
||||
"""mobileproxy.space (aproxy.site) rotation (см. docstring модуля § mobileproxy).
|
||||
rotate_url уже проверен вызывающим (_rotate_provider_for_host == "mobileproxy").
|
||||
|
||||
Порядок:
|
||||
1. Предохранительный суточный лимит (см. _quota_used_today,
|
||||
MOBILEPROXY_DAILY_ROTATION_LIMIT) исчерпан → ok=False, отказ БЕЗ
|
||||
обращения к API (это наш собственный runaway-guard, не лимит провайдера).
|
||||
2. GET rotate_url&format=json, БЕЗ заголовков, timeout ~30с.
|
||||
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
||||
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
||||
- HTTP 4xx/5xx → аудит-запись (считается в лимите), нейтральный reason.
|
||||
- HTTP 2xx, но JSON status != "OK" → аудит-запись success=False (считается
|
||||
в лимите — провайдер ответил, просто бизнес-неудача), нейтральный reason.
|
||||
- HTTP 2xx и status == "OK" → аудит-запись success=True, new_ip записывается
|
||||
в scrape_proxies.exit_ip, бан-история узла снимается (тот же принцип,
|
||||
что и у ASocks), rt возвращается вызывающему как reconnect_delay_s.
|
||||
"""
|
||||
used = _quota_used_today(db, proxy_id)
|
||||
if used >= MOBILEPROXY_DAILY_ROTATION_LIMIT:
|
||||
logger.warning(
|
||||
"proxy_rotation: mobileproxy daily limit reached proxy_id=%d used=%d/%d — "
|
||||
"skipping API call",
|
||||
proxy_id,
|
||||
used,
|
||||
MOBILEPROXY_DAILY_ROTATION_LIMIT,
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason=f"daily rotation limit reached ({MOBILEPROXY_DAILY_ROTATION_LIMIT}/day)",
|
||||
rotations_remaining_today=0,
|
||||
)
|
||||
|
||||
# proxy_key живёт в query-string самого rotate_url — request_url несёт его
|
||||
# так же, как и исходный url. НИКОГДА не логировать/не возвращать request_url
|
||||
# или rotate_url целиком (см. "⛔ Токен..." в docstring модуля).
|
||||
request_url = httpx.URL(rotate_url).copy_merge_params({"format": "json"})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
|
||||
resp = await client.get(request_url)
|
||||
except Exception as exc:
|
||||
# Тот же принцип, что и у ASocks выше: str(exc) может нести полный URL
|
||||
# (значит и proxy_key) — наружу только exc_info=True в лог,
|
||||
# type(exc).__name__ в note (не секрет, полезен оператору).
|
||||
logger.warning(
|
||||
"proxy_rotation: mobileproxy request failed (no response) proxy_id=%d",
|
||||
proxy_id,
|
||||
exc_info=True,
|
||||
)
|
||||
_record_attempt(
|
||||
db,
|
||||
proxy_id,
|
||||
success=False,
|
||||
http_status=None,
|
||||
note=f"request failed: {type(exc).__name__}",
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason="rotation request failed (network error)",
|
||||
rotations_remaining_today=max(0, MOBILEPROXY_DAILY_ROTATION_LIMIT - used),
|
||||
)
|
||||
|
||||
status = resp.status_code
|
||||
remaining_after = max(0, MOBILEPROXY_DAILY_ROTATION_LIMIT - (used + 1))
|
||||
|
||||
if status >= 400:
|
||||
logger.warning(
|
||||
"proxy_rotation: mobileproxy provider returned error proxy_id=%d status=%d",
|
||||
proxy_id,
|
||||
status,
|
||||
)
|
||||
_record_attempt(
|
||||
db, proxy_id, success=False, http_status=status, note="provider returned error"
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason=f"rotation request failed (provider status {status})",
|
||||
rotations_remaining_today=remaining_after,
|
||||
)
|
||||
|
||||
data: Any
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = None
|
||||
|
||||
provider_status = data.get("status") if isinstance(data, dict) else None
|
||||
if provider_status != "OK":
|
||||
logger.warning(
|
||||
"proxy_rotation: mobileproxy provider reported non-OK status proxy_id=%d status=%r",
|
||||
proxy_id,
|
||||
provider_status,
|
||||
)
|
||||
_record_attempt(
|
||||
db,
|
||||
proxy_id,
|
||||
success=False,
|
||||
http_status=status,
|
||||
note=f"provider status={provider_status!r}",
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason="rotation request failed (provider reported failure)",
|
||||
rotations_remaining_today=remaining_after,
|
||||
)
|
||||
|
||||
raw_new_ip = data.get("new_ip")
|
||||
new_ip = str(raw_new_ip) if raw_new_ip else None
|
||||
reconnect_delay_s = _parse_rt(data.get("rt"))
|
||||
|
||||
logger.info(
|
||||
"proxy_rotation: mobileproxy rotated proxy_id=%d status=%d new_ip=%s rt=%s",
|
||||
proxy_id,
|
||||
status,
|
||||
new_ip,
|
||||
reconnect_delay_s,
|
||||
)
|
||||
_record_attempt(db, proxy_id, success=True, http_status=status, note=None)
|
||||
if new_ip:
|
||||
_update_exit_ip(db, proxy_id, new_ip)
|
||||
# Тот же принцип, что и у ASocks (см. docstring модуля): IP сменился → баны
|
||||
# площадок на СТАРОМ адресе недействительны.
|
||||
clear_source_bans(db, proxy_id, reason=f"exit ip rotated (mobileproxy status={status})")
|
||||
return RotationResult(
|
||||
ok=True,
|
||||
reason=None,
|
||||
new_ip=new_ip,
|
||||
rotations_remaining_today=remaining_after,
|
||||
reconnect_delay_s=reconnect_delay_s,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class FakeSession:
|
|||
# адреса недействителен), см. proxy_pool.clear_source_bans.
|
||||
self.source_bans: list[dict[str, Any]] = source_bans or []
|
||||
self.commits = 0
|
||||
self.exit_ip_updates: list[tuple[int, str]] = []
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
sql = str(stmt)
|
||||
|
|
@ -109,6 +110,12 @@ class FakeSession:
|
|||
self.source_bans = [b for b in self.source_bans if b not in cleared]
|
||||
return _FakeResult([{"source": b["source"]} for b in cleared])
|
||||
|
||||
if "UPDATE scrape_proxies" in sql and "exit_ip" in sql:
|
||||
self.exit_ip_updates.append((p["proxy_id"], p["exit_ip"]))
|
||||
if self.proxy_row is not None and self.proxy_row["id"] == p["proxy_id"]:
|
||||
self.proxy_row["exit_ip"] = p["exit_ip"]
|
||||
return _FakeResult([])
|
||||
|
||||
raise AssertionError(f"unhandled SQL: {sql}")
|
||||
|
||||
def commit(self) -> None:
|
||||
|
|
@ -179,10 +186,45 @@ def _no_http_allowed():
|
|||
async def post(self, *a: object, **kw: object) -> None:
|
||||
raise AssertionError("HTTP call must NOT happen for this scenario")
|
||||
|
||||
async def get(self, *a: object, **kw: object) -> None:
|
||||
raise AssertionError("HTTP call must NOT happen for this scenario")
|
||||
|
||||
return _ForbiddenClient
|
||||
|
||||
|
||||
def _fake_async_client_get(
|
||||
*,
|
||||
response: tuple[int, dict[str, Any] | None] | None,
|
||||
exception: Exception | None,
|
||||
):
|
||||
"""Аналог _fake_async_client, но для GET (mobileproxy — без Authorization,
|
||||
секрет живёт в URL). calls накапливает str(url) каждого get()."""
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class _FakeClientImpl:
|
||||
def __init__(self, timeout: float | None = None) -> None:
|
||||
self.timeout = timeout
|
||||
|
||||
async def __aenter__(self) -> _FakeClientImpl:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
async def get(self, url: object) -> _FakeResponse:
|
||||
calls.append({"url": str(url)})
|
||||
if exception is not None:
|
||||
raise exception
|
||||
assert response is not None
|
||||
status, body = response
|
||||
return _FakeResponse(status, body)
|
||||
|
||||
return _FakeClientImpl, calls
|
||||
|
||||
|
||||
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
|
||||
MOBILEPROXY_KEY = "deadbeef00112233deadbeef00112233"
|
||||
_MOBILEPROXY_ROTATE_URL = f"https://aproxy.site/?proxy_key={MOBILEPROXY_KEY}"
|
||||
|
||||
# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один
|
||||
# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт).
|
||||
|
|
@ -557,3 +599,169 @@ async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.Mo
|
|||
result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type]
|
||||
assert result.ok is False
|
||||
assert result.reason is not None
|
||||
|
||||
|
||||
# ── mobileproxy.space (aproxy.site) ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_mobileproxy_success_writes_history_and_exit_ip(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_client, calls = _fake_async_client_get(
|
||||
response=(200, {"status": "OK", "code": 200, "new_ip": "128.71.225.59", "rt": "2.41"}),
|
||||
exception=None,
|
||||
)
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||
|
||||
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is True
|
||||
assert result.new_ip == "128.71.225.59"
|
||||
assert result.reconnect_delay_s == pytest.approx(2.41)
|
||||
assert (
|
||||
result.rotations_remaining_today
|
||||
== proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT - 1
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert "format=json" in calls[0]["url"]
|
||||
|
||||
assert len(db.rotations) == 1
|
||||
assert db.rotations[0]["success"] is True
|
||||
assert db.rotations[0]["http_status"] == 200
|
||||
assert db.exit_ip_updates == [(1, "128.71.225.59")]
|
||||
|
||||
|
||||
async def test_mobileproxy_success_clears_source_bans(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake_client, _calls = _fake_async_client_get(
|
||||
response=(200, {"status": "OK", "new_ip": "9.9.9.9", "rt": "3.1"}), exception=None
|
||||
)
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||
|
||||
db = FakeSession(
|
||||
_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL),
|
||||
source_bans=[{"proxy_id": 1, "source": "avito"}],
|
||||
)
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is True
|
||||
assert db.source_bans == []
|
||||
|
||||
|
||||
async def test_mobileproxy_non_ok_status_is_failure_and_consumes_quota(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""HTTP 200, но body.status != "OK" — провал, даже без транспортной ошибки.
|
||||
Провайдер ответил (транспортно дошли) → попытка засчитывается в предохранитель."""
|
||||
fake_client, _calls = _fake_async_client_get(
|
||||
response=(200, {"status": "FAIL", "code": 400}), exception=None
|
||||
)
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||
|
||||
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is False
|
||||
assert result.new_ip is None
|
||||
assert db.exit_ip_updates == []
|
||||
assert len(db.rotations) == 1
|
||||
assert db.rotations[0]["success"] is False
|
||||
assert db.rotations[0]["http_status"] == 200
|
||||
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_mobileproxy_http_error_status_is_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake_client, _calls = _fake_async_client_get(response=(503, None), exception=None)
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||
|
||||
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is False
|
||||
assert db.rotations[0]["http_status"] == 503
|
||||
|
||||
|
||||
async def test_mobileproxy_network_error_does_not_consume_quota(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
boom = httpx.ConnectError(f"connection failed for proxy_key={MOBILEPROXY_KEY}")
|
||||
fake_client, _calls = _fake_async_client_get(response=None, exception=boom)
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||
|
||||
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is False
|
||||
assert db.rotations[0]["http_status"] is None
|
||||
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
|
||||
assert MOBILEPROXY_KEY not in (result.reason or "")
|
||||
assert MOBILEPROXY_KEY not in (db.rotations[0]["note"] or "")
|
||||
|
||||
|
||||
async def test_mobileproxy_daily_limit_rejected_without_api_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||
|
||||
db = FakeSession(
|
||||
_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL),
|
||||
_quota_rows(1, proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT),
|
||||
)
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is False
|
||||
assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower()
|
||||
assert result.rotations_remaining_today == 0
|
||||
assert len(db.rotations) == proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT
|
||||
|
||||
|
||||
async def test_mobileproxy_key_never_leaks_in_reason_or_db_or_logs(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Проверка секретной гигиены для proxy_key — тот же класс требований, что и
|
||||
для ASOCKS_API_TOKEN (см. модуль docstring): reason, note аудит-записи и
|
||||
аргументы logger.*() не должны содержать ключ ни в одном сценарии."""
|
||||
scenarios: list[tuple[str, tuple[int, dict[str, Any] | None] | None, Exception | None]] = [
|
||||
("success", (200, {"status": "OK", "new_ip": "1.1.1.1", "rt": "2.0"}), None),
|
||||
("non_ok", (200, {"status": "FAIL"}), None),
|
||||
("http_error", (500, {"message": "err"}), None),
|
||||
(
|
||||
"network_error",
|
||||
None,
|
||||
httpx.ConnectError(f"boom proxy_key={MOBILEPROXY_KEY}"),
|
||||
),
|
||||
]
|
||||
|
||||
for name, response, exception in scenarios:
|
||||
fake_client, _ = _fake_async_client_get(response=response, exception=exception)
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||
|
||||
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
caplog.clear()
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert MOBILEPROXY_KEY not in (result.reason or ""), name
|
||||
for row in db.rotations:
|
||||
assert MOBILEPROXY_KEY not in (row["note"] or ""), name
|
||||
for record in caplog.records:
|
||||
assert MOBILEPROXY_KEY not in record.getMessage(), (
|
||||
f"scenario={name}: proxy_key leaked into log message args"
|
||||
)
|
||||
|
||||
|
||||
async def test_mobileproxy_unknown_query_shape_still_masks_url_in_refusal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Хост-пиннинг: неизвестный хост, похожий на mobileproxy по форме query
|
||||
(проверка того, что дефолтный отказ путь тоже не светит секрет из url)."""
|
||||
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||
|
||||
foreign_url = f"https://changeip.mobileproxy.space/?proxy_key={MOBILEPROXY_KEY}"
|
||||
db = FakeSession(_proxy_row(rotate_url=foreign_url))
|
||||
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is False
|
||||
assert db.rotations == []
|
||||
assert MOBILEPROXY_KEY not in (result.reason or "")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue