chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) (#2650)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 2m45s
Deploy Trade-In / build-browser (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m29s

This commit is contained in:
bot-backend 2026-08-05 09:35:54 +00:00
parent ac0c472faf
commit 59c072fc4c
26 changed files with 286 additions and 844 deletions

View file

@ -2175,7 +2175,8 @@ async def scrape_house_imv_backfill(
) )
# ── Единая scrapers-страница: unified runs + health + rotate-ip (epic) ──────── # ── Единая scrapers-страница: unified runs + health (epic) ───────────────────
# rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая подписка (#2613).
class UnifiedScrapeRunRow(BaseModel): class UnifiedScrapeRunRow(BaseModel):
@ -2219,33 +2220,23 @@ class ScraperHealthResponse(BaseModel):
providers: list[ProviderHealth] providers: list[ProviderHealth]
class RotateIpResponse(BaseModel):
ok: bool
new_ip: str | None = None
reason: str | None = None
_ROTATABLE_SOURCES = ("avito", "cian", "yandex") _ROTATABLE_SOURCES = ("avito", "cian", "yandex")
def _provider_proxy_url(source: str) -> str | None: def _provider_proxy_url(source: str) -> str | None:
"""Effective proxy URL для source (учитывает property-fallback в settings).""" """Effective proxy URL для source (учитывает property-fallback в settings).
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты мёртвая
mobileproxy-подписка, #2613).
"""
return { return {
"avito": settings.avito_proxy_url, "avito": settings.scraper_proxy_url,
"cian": settings.cian_proxy_url, "cian": settings.cian_proxy_url,
"yandex": settings.yandex_proxy_url, "yandex": settings.yandex_proxy_url,
}.get(source) }.get(source)
def _provider_rotate_url(source: str) -> str | None:
"""changeip-URL для source (None → auto-rotate прокси без ручной ротации)."""
return {
"avito": settings.avito_proxy_rotate_url,
"cian": settings.cian_proxy_rotate_url,
"yandex": settings.yandex_proxy_rotate_url,
}.get(source)
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]: def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port).""" """Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
if not proxy_url: if not proxy_url:
@ -2344,8 +2335,10 @@ async def scraper_health() -> ScraperHealthResponse:
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser). - fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги). - browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
- providers: для avito/cian/yandex proxy host/port, rotate_supported, - providers: для avito/cian/yandex proxy host/port, rotate_supported
best-effort current_ip (параллельный пробинг через прокси на ipify). (#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
Все пробинги параллельны (asyncio.gather) и time-boxed суммарно 10с. Все пробинги параллельны (asyncio.gather) и time-boxed суммарно 10с.
""" """
@ -2365,7 +2358,7 @@ async def scraper_health() -> ScraperHealthResponse:
source=source, source=source,
proxy_host=host, proxy_host=host,
proxy_port=port, proxy_port=port,
rotate_supported=bool(_provider_rotate_url(source)), rotate_supported=False,
current_ip=ip_by_source[source], current_ip=ip_by_source[source],
) )
) )
@ -2377,46 +2370,6 @@ async def scraper_health() -> ScraperHealthResponse:
) )
@router.post("/scraper/{source}/rotate-ip", response_model=RotateIpResponse)
async def rotate_proxy_ip(
source: Literal["avito", "cian", "yandex"],
) -> RotateIpResponse:
"""Сменить мобильный exit-IP провайдера через changeip-ссылку (mobileproxy).
Зеркалит логику AvitoScraper._rotate_ip (GET rotate_url + &format=json), но БЕЗ
settle-sleep API сразу возвращает ответ changeip. Если rotate_url не задан
прокси с авто-ротацией (свежий IP на новое соединение), ручная ротация не нужна.
"""
rotate_url = _provider_rotate_url(source)
if not rotate_url:
return RotateIpResponse(ok=False, reason="no rotate url (auto-rotate proxy)")
sep = "&" if "?" in rotate_url else "?"
try:
async with httpx.AsyncClient(timeout=20.0) as client:
resp = await client.get(f"{rotate_url}{sep}format=json")
resp.raise_for_status()
try:
data = resp.json()
except Exception:
data = {}
except Exception:
# НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения
# несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API-
# ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в
# HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся
# в логах (exc_info=True) для диагностики; наружу — только нейтральный reason.
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
return RotateIpResponse(ok=False, reason="changeip request failed")
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
new_ip = None
if isinstance(data, dict):
new_ip = data.get("new_ip") or data.get("ip") or data.get("proxy_ip")
logger.info("rotate-ip: source=%s new_ip=%s", source, new_ip)
return RotateIpResponse(ok=True, new_ip=str(new_ip) if new_ip else None)
# ── Pacing live-регулятор (GET/PUT /scraper/pacing) ────────────────────────── # ── Pacing live-регулятор (GET/PUT /scraper/pacing) ──────────────────────────
@ -2896,10 +2849,10 @@ def patch_proxy(
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ─────────────── # ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
# #
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси # Раньше отдельно от /scraper/{source}/rotate-ip (env-прокси mobileproxy,
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без # changeip-ссылка) — тот эндпоинт удалён вместе с мёртвой подпиской (#2616 шаг 3).
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies # Этот эндпоинт — единственная живая ручная ротация, по proxy_id из пула
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см. # scrape_proxies (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
# app.services.proxy_rotation.rotate_proxy. # app.services.proxy_rotation.rotate_proxy.

View file

@ -651,45 +651,41 @@ class Settings(BaseSettings):
"www.domclick.ru", "www.domclick.ru",
} }
# ── Scraper mobile proxy (#806) ────────────────────────────────────────── # ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями: # Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами. # Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
# Пусто = прямое подключение (dev/staging без прокси). # Пусто = прямое подключение (dev/staging без прокси).
# #
# Приоритет ENV-переменных (precedence): # #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым. # YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже # на закрытые аккаунты (407/connection refused, проверено вживую #2613).
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806). # SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
# property `scraper_proxy_url` реализует эту логику; используй его везде.
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него # validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup). # pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL") scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
avito_proxy_url: str | None = None # ENV: AVITO_PROXY_URL (legacy fallback)
@property @property
def scraper_proxy_url(self) -> str | None: def scraper_proxy_url(self) -> str | None:
"""Единый прокси URL для всех scraper-сессий (Avito + Cian). """Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение). Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env. fallback снят мёртвая mobileproxy-переменная).
""" """
return self.scraper_proxy_url_env or self.avito_proxy_url return self.scraper_proxy_url_env
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при # ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу). # Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
# ENV: AVITO_PROXY_ROTATE_URL. # (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
avito_proxy_rotate_url: str | None = None # (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу). # _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной # `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
# ротации на старте (Datadome ban recovery). # state machine (scraper_kit.orchestration.pipeline._try_rotate_within_budget)
# — те же 0 попыток "успеха", что и раньше при мёртвом changeip, просто без
# затрат на HTTP; `avito_proxy_rotate_settle_s` — верхняя граница
# asyncio.wait_for в app.tasks.avito_detail_backfill (страховка от зависания).
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611).
avito_proxy_max_rotations: int = 4 avito_proxy_max_rotations: int = 4
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
avito_proxy_rotate_settle_s: float = 9.0 avito_proxy_rotate_settle_s: float = 9.0
# #1950: retry-параметры changeip-GET (_rotate_proxy_ip). Вместо одношотного 30s-timeout
# делаем proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая.
# Короткий timeout (8s) означает, что зависший changeip не блокирует весь run на 30s.
# ENV: PROXY_ROTATE_ATTEMPT_TIMEOUT_S / PROXY_ROTATE_ATTEMPTS.
proxy_rotate_attempt_timeout_s: float = 8.0 proxy_rotate_attempt_timeout_s: float = 8.0
proxy_rotate_attempts: int = 3 proxy_rotate_attempts: int = 3
@ -710,16 +706,12 @@ class Settings(BaseSettings):
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED. # False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
avito_serp_ok_not_banned: bool = True avito_serp_ok_not_banned: bool = True
# ── Cian dedicated mobile proxy (separate egress from Avito) ────────────── # ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция # Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
# за единственный egress → взаимные таймауты/баны при параллельных прогонах. # общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на # cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL. # остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL") # (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
# changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан —
# fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL.
cian_proxy_rotate_url: str | None = None
# Максимум IP-ротаций для Cian на один sweep-прогон. Аналог avito_proxy_max_rotations.
# ENV: CIAN_PROXY_MAX_ROTATIONS. # ENV: CIAN_PROXY_MAX_ROTATIONS.
cian_proxy_max_rotations: int = 4 cian_proxy_max_rotations: int = 4
@ -739,25 +731,21 @@ class Settings(BaseSettings):
@property @property
def cian_proxy_url(self) -> str | None: def cian_proxy_url(self) -> str | None:
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback).""" """Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
return self.cian_proxy_url_env or self.scraper_proxy_url override снят свойство оставлено для scraper_kit.contracts.ScraperConfig
совместимости)."""
return self.scraper_proxy_url
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ──────── # ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах. # Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
# Если не задан — fallback на общий scraper_proxy_url (backward-compat). # yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
# ENV: YANDEX_PROXY_URL.
yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL")
# changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан —
# fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL.
yandex_proxy_rotate_url: str | None = None
# Максимум IP-ротаций для Yandex на один sweep-прогон. Аналог avito_proxy_max_rotations.
# ENV: YANDEX_PROXY_MAX_ROTATIONS. # ENV: YANDEX_PROXY_MAX_ROTATIONS.
yandex_proxy_max_rotations: int = 4 yandex_proxy_max_rotations: int = 4
@property @property
def yandex_proxy_url(self) -> str | None: def yandex_proxy_url(self) -> str | None:
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback).""" """Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
return self.yandex_proxy_url_env or self.scraper_proxy_url return self.scraper_proxy_url
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня # full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда # (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда

View file

@ -48,8 +48,11 @@ _TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b") _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit): # Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py # исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`). # admin.rotate_proxy_ip) несла провайдерский API-ключ в query
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
# будущий query-секрет с распространённым именем параметра тоже покрыт).
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше: # Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url), # 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе # sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
@ -58,13 +61,14 @@ _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг # span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
# когда-нибудь включат. # когда-нибудь включат.
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие # 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`, # от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
# `exc` в rotate_proxy_ip) в traceback открытым текстом. # открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
# admin.rotate_proxy_ip).
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет # Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ # может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
# завязано на конкретного провайдера — покрывает любой query-параметр из # завязано на конкретного провайдера — покрывает любой query-параметр из
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/ # общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально # access_token/auth) — живой пример: ASOCKS_API_TOKEN (потенциально
# другой провайдер, другое имя параметра). # другой провайдер, другое имя параметра).
_URL_SECRET_QUERY_RE = re.compile( _URL_SECRET_QUERY_RE = re.compile(
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|" r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"

View file

@ -37,14 +37,15 @@ PR #2609, конфликт исключён: вся новая логика ту
Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
str(exc) см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400): str(exc) тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь
httpx-исключения несут полный request URL/детали, поэтому наружу только admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали,
нейтральный reason, полные детали в лог с exc_info=True. поэтому наружу только нейтральный reason, полные детали в лог с exc_info=True.
Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
НЕОДНОРОДНА часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip- НЕОДНОРОДНА часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`, ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks. тот же формат, что читал удалённый #2616 шаг 2/3 admin.rotate_proxy_ip /
Settings.avito_proxy_rotate_url), не ASocks.
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
на ЧУЖОЙ провайдер (mobileproxy) плюс сам GET/POST по их changeip, вероятно, на ЧУЖОЙ провайдер (mobileproxy) плюс сам GET/POST по их changeip, вероятно,
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как

View file

@ -136,10 +136,6 @@ class RealScraperConfig:
def scraper_proxy_url(self) -> str | None: def scraper_proxy_url(self) -> str | None:
return _settings.scraper_proxy_url return _settings.scraper_proxy_url
@property
def avito_proxy_rotate_url(self) -> str | None:
return _settings.avito_proxy_rotate_url
@property @property
def avito_proxy_max_rotations(self) -> int: def avito_proxy_max_rotations(self) -> int:
return _settings.avito_proxy_max_rotations return _settings.avito_proxy_max_rotations
@ -148,10 +144,6 @@ class RealScraperConfig:
def avito_serp_ekb_only(self) -> bool: def avito_serp_ekb_only(self) -> bool:
return _settings.avito_serp_ekb_only return _settings.avito_serp_ekb_only
@property
def yandex_proxy_rotate_url(self) -> str | None:
return _settings.yandex_proxy_rotate_url
@property @property
def cian_proxy_url(self) -> str | None: def cian_proxy_url(self) -> str | None:
return _settings.cian_proxy_url return _settings.cian_proxy_url
@ -189,10 +181,6 @@ class RealScraperConfig:
def proxy_rotate_attempt_timeout_s(self) -> float: def proxy_rotate_attempt_timeout_s(self) -> float:
return _settings.proxy_rotate_attempt_timeout_s return _settings.proxy_rotate_attempt_timeout_s
@property
def cian_proxy_rotate_url(self) -> str | None:
return _settings.cian_proxy_rotate_url
@property @property
def cian_proxy_max_rotations(self) -> int: def cian_proxy_max_rotations(self) -> int:
return _settings.cian_proxy_max_rotations return _settings.cian_proxy_max_rotations

View file

@ -173,7 +173,7 @@ async def run_avito_detail_backfill(
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) — # kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без # RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
# аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()). # аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()).
scraper = AvitoScraper(RealScraperConfig()) scraper = AvitoScraper(RealScraperConfig())
start = time.monotonic() start = time.monotonic()

View file

@ -141,8 +141,8 @@ async def test_backfill_processes_snapshot_to_completion() -> None:
runs.mark_done.assert_called_once() runs.mark_done.assert_called_once()
runs.mark_failed.assert_not_called() runs.mark_failed.assert_not_called()
# #2310 regression guard: kit fetch_detail silently drops the backconnect- # #2310 regression guard: kit fetch_detail silently drops the backconnect-
# on-403 retry (and kit AvitoScraper can't read avito_proxy_rotate_url at # on-403 retry (and kit AvitoScraper can't read scraper_proxy_url at all)
# all) unless config=RealScraperConfig() is passed/injected at the call # unless config=RealScraperConfig() is passed/injected at the call
# site — assert_called()/call_count alone wouldn't catch someone dropping # site — assert_called()/call_count alone wouldn't catch someone dropping
# that kwarg later (mirrors #2306's test_backfill_wave2.py:282-286 pattern). # that kwarg later (mirrors #2306's test_backfill_wave2.py:282-286 pattern).
_, fetch_call_kwargs = mock_fetch.call_args _, fetch_call_kwargs = mock_fetch.call_args

View file

@ -212,7 +212,6 @@ def _avito_config(*, use_pool: bool) -> types.SimpleNamespace:
browser_http_endpoint="http://browser:3000", browser_http_endpoint="http://browser:3000",
use_proxy_pool_browser=use_pool, use_proxy_pool_browser=use_pool,
scraper_fetch_mode="browser", scraper_fetch_mode="browser",
avito_proxy_rotate_url=None,
scraper_proxy_url=None, scraper_proxy_url=None,
) )

View file

@ -1,9 +1,11 @@
"""Offline tests для unified scrapers-страницы admin-API (epic консолидации UI). """Offline tests для unified scrapers-страницы admin-API (epic консолидации UI).
Покрытие 3 эндпоинтов (db/httpx мокаются, NO live network/DB): Покрытие эндпоинтов (db/httpx мокаются, NO live network/DB):
- GET /api/v1/admin/scrape/runs unified runs + total + фильтры - GET /api/v1/admin/scrape/runs unified runs + total + фильтры
- GET /api/v1/admin/scraper/health fetch_mode + browser + providers - GET /api/v1/admin/scraper/health fetch_mode + browser + providers
- POST /api/v1/admin/scraper/{src}/rotate-ip changeip + no-rotate-url кейс
POST /scraper/{src}/rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая
подписка (#2613); её тесты удалены вместе с ручкой.
""" """
from __future__ import annotations from __future__ import annotations
@ -147,13 +149,11 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
"cian": None, "cian": None,
"yandex": "http://u:p@host.y:10051", "yandex": "http://u:p@host.y:10051",
} }
rotate_urls = {"avito": "http://ch/changeip", "cian": None, "yandex": None}
with ( with (
patch.object(admin_module, "_probe_browser_health", fake_browser_health), patch.object(admin_module, "_probe_browser_health", fake_browser_health),
patch.object(admin_module, "_probe_current_ip", fake_current_ip), patch.object(admin_module, "_probe_current_ip", fake_current_ip),
patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]), patch.object(admin_module, "_provider_proxy_url", lambda s: proxy_urls[s]),
patch.object(admin_module, "_provider_rotate_url", lambda s: rotate_urls[s]),
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"), patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
): ):
r = client.get("/api/v1/admin/scraper/health") r = client.get("/api/v1/admin/scraper/health")
@ -169,13 +169,14 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
assert set(by_source) == {"avito", "cian", "yandex"} assert set(by_source) == {"avito", "cian", "yandex"}
assert by_source["avito"]["proxy_host"] == "host.a" assert by_source["avito"]["proxy_host"] == "host.a"
assert by_source["avito"]["proxy_port"] == 10049 assert by_source["avito"]["proxy_port"] == 10049
assert by_source["avito"]["rotate_supported"] is True # #2616 шаг 2: changeip mobileproxy-ротация снята — rotate_supported всегда
# False (живая ASocks-ротация — /admin/proxies/{id}/rotate, не per-source).
assert by_source["avito"]["rotate_supported"] is False
assert by_source["avito"]["current_ip"] == "1.2.3.4" assert by_source["avito"]["current_ip"] == "1.2.3.4"
# cian без proxy → host/port/ip null, rotate_supported False # cian без proxy → host/port/ip null
assert by_source["cian"]["proxy_host"] is None assert by_source["cian"]["proxy_host"] is None
assert by_source["cian"]["current_ip"] is None assert by_source["cian"]["current_ip"] is None
assert by_source["cian"]["rotate_supported"] is False assert by_source["cian"]["rotate_supported"] is False
# yandex имеет proxy но нет rotate_url
assert by_source["yandex"]["proxy_host"] == "host.y" assert by_source["yandex"]["proxy_host"] == "host.y"
assert by_source["yandex"]["rotate_supported"] is False assert by_source["yandex"]["rotate_supported"] is False
@ -204,7 +205,6 @@ def test_health_browser_unreachable(client: TestClient) -> None:
patch.object(admin_module.httpx, "AsyncClient", _BoomClient), patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
patch.object(admin_module, "_probe_current_ip", fake_current_ip), patch.object(admin_module, "_probe_current_ip", fake_current_ip),
patch.object(admin_module, "_provider_proxy_url", lambda s: None), patch.object(admin_module, "_provider_proxy_url", lambda s: None),
patch.object(admin_module, "_provider_rotate_url", lambda s: None),
): ):
r = client.get("/api/v1/admin/scraper/health") r = client.get("/api/v1/admin/scraper/health")
@ -214,137 +214,6 @@ def test_health_browser_unreachable(client: TestClient) -> None:
assert body["browser"]["browsers"] == {} assert body["browser"]["browsers"] == {}
# ── API 3: POST /scraper/{source}/rotate-ip ──────────────────────────────────
def test_rotate_ip_calls_changeip(client: TestClient) -> None:
"""rotate_url задан → GET changeip + format=json, возврат new_ip из ответа."""
from app.api.v1 import admin as admin_module
captured: dict[str, str] = {}
class _FakeResp:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, str]:
return {"new_ip": "9.9.9.9"}
class _FakeClient:
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> _FakeClient:
return self
async def __aexit__(self, *a: Any) -> None:
return None
async def get(self, url: str, *a: Any, **k: Any) -> _FakeResp:
captured["url"] = url
return _FakeResp()
with (
patch.object(admin_module.httpx, "AsyncClient", _FakeClient),
patch.object(admin_module.settings, "avito_proxy_rotate_url", "http://ch/changeip?key=x"),
):
r = client.post("/api/v1/admin/scraper/avito/rotate-ip")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["new_ip"] == "9.9.9.9"
assert "format=json" in captured["url"]
# уже был '?' в URL → должен использовать '&'
assert captured["url"].endswith("&format=json")
def test_rotate_ip_no_rotate_url(client: TestClient) -> None:
"""rotate_url не задан → ok=False + reason про auto-rotate, changeip НЕ дёргается."""
from app.api.v1 import admin as admin_module
with patch.object(admin_module.settings, "cian_proxy_rotate_url", None):
r = client.post("/api/v1/admin/scraper/cian/rotate-ip")
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert body["new_ip"] is None
assert "auto-rotate" in body["reason"]
def test_rotate_ip_invalid_source_422(client: TestClient) -> None:
"""source вне {avito,cian,yandex} → 422 (Literal-валидация path)."""
r = client.post("/api/v1/admin/scraper/domclick/rotate-ip")
assert r.status_code == 422
def test_rotate_ip_changeip_error(client: TestClient) -> None:
"""httpx-ошибка changeip → ok=False + reason."""
from app.api.v1 import admin as admin_module
class _BoomClient:
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> _BoomClient:
return self
async def __aexit__(self, *a: Any) -> None:
return None
async def get(self, *a: Any, **k: Any) -> Any:
raise RuntimeError("changeip 500")
with (
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
patch.object(admin_module.settings, "yandex_proxy_rotate_url", "http://ch/changeip"),
):
r = client.post("/api/v1/admin/scraper/yandex/rotate-ip")
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
# security-audit: нейтральный reason, БЕЗ текста исходного исключения
# (str(exc) httpx мог нести rotate_url с proxy-ключом в query — см. тест ниже).
assert body["reason"] == "changeip request failed"
def test_rotate_ip_changeip_error_does_not_leak_proxy_key(client: TestClient) -> None:
"""security-audit: секретный API-ключ провайдера в rotate_url НЕ должен попасть
в HTTP-ответ клиенту через текст httpx-исключения (раньше
`reason=f"changeip error: {exc}"` отдавал str(exc) с полным URL, включая
query-параметр ключа, наружу)."""
from app.api.v1 import admin as admin_module
secret_url = "http://ch/changeip?proxy_key=TOP-SECRET-KEY-1234"
class _BoomClient:
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> _BoomClient:
return self
async def __aexit__(self, *a: Any) -> None:
return None
async def get(self, *a: Any, **k: Any) -> Any:
raise RuntimeError(f"All connection attempts failed for {secret_url}&format=json")
with (
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
patch.object(admin_module.settings, "avito_proxy_rotate_url", secret_url),
):
r = client.post("/api/v1/admin/scraper/avito/rotate-ip")
assert r.status_code == 200
assert "TOP-SECRET-KEY-1234" not in r.text
body = r.json()
assert body["ok"] is False
assert "TOP-SECRET-KEY-1234" not in (body["reason"] or "")
# ── API 4: GET /scraper/pacing ─────────────────────────────────────────────── # ── API 4: GET /scraper/pacing ───────────────────────────────────────────────

View file

@ -141,13 +141,19 @@ async def test_avito_fetch_detail_with_config_reconnects_on_403_like_legacy() ->
# ── AvitoScraper: kit constructor now requires ScraperConfig (positional) ──────── # ── AvitoScraper: kit constructor now requires ScraperConfig (positional) ────────
def test_avito_scraper_rotate_ip_reads_injected_config() -> None: def test_avito_scraper_reads_injected_config() -> None:
"""kit AvitoScraper(config) is a required positional arg (legacy AvitoScraper() """kit AvitoScraper(config) is a required positional arg (legacy AvitoScraper()
took none, reading settings.avito_proxy_rotate_url directly at call time inside took none, reading settings.* directly at call time). avito_detail_backfill.py
_rotate_ip). avito_detail_backfill.py now constructs AvitoScraper(RealScraperConfig()) now constructs AvitoScraper(RealScraperConfig()) this proves the injected
this proves the injected config is what _rotate_ip actually reads.""" config (not the settings singleton) is what the scraper actually reads.
#2616 шаг 2: previously asserted on avito_proxy_rotate_url (changeip mobileproxy,
removed dead account #2613, _rotate_ip is now a no-op stub reading no config).
scraper_proxy_url (still config-injected, drives _build_cffi_session) proves the
same DI invariant.
"""
scraper = KitAvitoScraper(RealScraperConfig()) scraper = KitAvitoScraper(RealScraperConfig())
assert scraper._config.avito_proxy_rotate_url == settings.avito_proxy_rotate_url assert scraper._config.scraper_proxy_url == settings.scraper_proxy_url
# ── BrowserFetcher(endpoint=) wiring — house_imv_backfill.py / cian_history_backfill.py ── # ── BrowserFetcher(endpoint=) wiring — house_imv_backfill.py / cian_history_backfill.py ──

View file

@ -115,15 +115,12 @@ class _Scenario:
scraper_fetch_mode="curl_cffi", scraper_fetch_mode="curl_cffi",
browser_http_endpoint="http://browser.test/fetch", browser_http_endpoint="http://browser.test/fetch",
scraper_proxy_url=None, scraper_proxy_url=None,
avito_proxy_rotate_url=None,
avito_proxy_max_rotations=self.avito_proxy_max_rotations, avito_proxy_max_rotations=self.avito_proxy_max_rotations,
avito_serp_ok_not_banned=self.avito_serp_ok_not_banned, avito_serp_ok_not_banned=self.avito_serp_ok_not_banned,
avito_proxy_rotate_settle_s=0.0, avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1, proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0, proxy_rotate_attempt_timeout_s=1.0,
cian_proxy_rotate_url=None,
cian_proxy_max_rotations=0, cian_proxy_max_rotations=0,
yandex_proxy_rotate_url=None,
yandex_proxy_max_rotations=0, yandex_proxy_max_rotations=0,
scraper_skip_seen_today=False, scraper_skip_seen_today=False,
) )

View file

@ -83,15 +83,12 @@ def _config() -> SimpleNamespace:
scraper_fetch_mode="curl_cffi", scraper_fetch_mode="curl_cffi",
browser_http_endpoint="http://browser.test/fetch", browser_http_endpoint="http://browser.test/fetch",
scraper_proxy_url=None, scraper_proxy_url=None,
avito_proxy_rotate_url=None,
avito_proxy_max_rotations=0, avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True, avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0, avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1, proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0, proxy_rotate_attempt_timeout_s=1.0,
cian_proxy_rotate_url=None,
cian_proxy_max_rotations=0, cian_proxy_max_rotations=0,
yandex_proxy_rotate_url=None,
yandex_proxy_max_rotations=0, yandex_proxy_max_rotations=0,
scraper_skip_seen_today=False, scraper_skip_seen_today=False,
cian_full_load_per_fetch_timeout_s=0.0, cian_full_load_per_fetch_timeout_s=0.0,

View file

@ -1,8 +1,9 @@
"""Tests for scraper proxy wiring (#806 + follow-up). """Tests for scraper proxy wiring (#806 + #2616 шаг 2 cleanup).
Covers: Covers:
- scraper_proxy_url property: SCRAPER_PROXY_URL takes precedence over AVITO_PROXY_URL - scraper_proxy_url property: reads SCRAPER_PROXY_URL only. Legacy per-provider
- AVITO_PROXY_URL fallback works when SCRAPER_PROXY_URL is absent AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL fallback removed (#2616 шаг 2 —
dead mobileproxy accounts, 407/connection refused, #2613).
- None/empty direct connection (no proxies dict) - None/empty direct connection (no proxies dict)
- _avito_proxies() helper returns correct dict shape - _avito_proxies() helper returns correct dict shape
- per-scraper proxy wiring: YandexRealty, YandexValuation (kit), CianValuation, - per-scraper proxy wiring: YandexRealty, YandexValuation (kit), CianValuation,
@ -46,12 +47,10 @@ def _mock_settings(
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__.""" """Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
return SimpleNamespace( return SimpleNamespace(
scraper_proxy_url=scraper_proxy_url, scraper_proxy_url=scraper_proxy_url,
# Cian scrapers read settings.cian_proxy_url (property: CIAN_PROXY_URL or # cian_proxy_url / yandex_proxy_url properties now mirror scraper_proxy_url
# scraper_proxy_url fallback). Mirror scraper_proxy_url here so the existing # 1:1 (#2616 шаг 2: per-provider CIAN_PROXY_URL/YANDEX_PROXY_URL override
# cian proxy-wiring assertions keep passing (#923 follow-up). # removed — dead mobileproxy accounts). Mirrored here for test compat.
cian_proxy_url=scraper_proxy_url, cian_proxy_url=scraper_proxy_url,
# Yandex scrapers read settings.yandex_proxy_url (property: YANDEX_PROXY_URL or
# scraper_proxy_url fallback). Mirror scraper_proxy_url here for test compat.
yandex_proxy_url=scraper_proxy_url, yandex_proxy_url=scraper_proxy_url,
yandex_cookies_file=yandex_cookies_file, yandex_cookies_file=yandex_cookies_file,
) )
@ -62,44 +61,23 @@ def _mock_settings(
# pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule. # pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule.
def _resolve_scraper_proxy_url( def _resolve_scraper_proxy_url(scraper_proxy_url_env: str | None) -> str | None:
scraper_proxy_url_env: str | None, avito_proxy_url: str | None """Inline replica of Settings.scraper_proxy_url property logic (#2616 шаг 2:
) -> str | None: legacy AVITO_PROXY_URL fallback removed SCRAPER_PROXY_URL is now the sole
"""Inline replica of Settings.scraper_proxy_url property logic.""" source, projected as-is)."""
return scraper_proxy_url_env or avito_proxy_url return scraper_proxy_url_env
def test_scraper_proxy_url_uses_scraper_env_when_set(): def test_scraper_proxy_url_uses_scraper_env_when_set():
result = _resolve_scraper_proxy_url( result = _resolve_scraper_proxy_url(scraper_proxy_url_env="http://scraper-proxy:1234")
scraper_proxy_url_env="http://scraper-proxy:1234",
avito_proxy_url="http://avito-proxy:5678",
)
assert result == "http://scraper-proxy:1234" assert result == "http://scraper-proxy:1234"
def test_scraper_proxy_url_falls_back_to_avito_proxy_url(): def test_scraper_proxy_url_none_when_absent():
"""AVITO_PROXY_URL used when SCRAPER_PROXY_URL absent — zero env change on prod.""" result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None)
result = _resolve_scraper_proxy_url(
scraper_proxy_url_env=None,
avito_proxy_url="http://avito-proxy:5678",
)
assert result == "http://avito-proxy:5678"
def test_scraper_proxy_url_none_when_both_absent():
result = _resolve_scraper_proxy_url(scraper_proxy_url_env=None, avito_proxy_url=None)
assert result is None assert result is None
def test_scraper_proxy_url_empty_string_falls_through_to_avito():
"""Empty string is falsy — falls through to avito_proxy_url."""
result = _resolve_scraper_proxy_url(
scraper_proxy_url_env="",
avito_proxy_url="http://avito-proxy:5678",
)
assert result == "http://avito-proxy:5678"
# ── _avito_proxies() dict shape ─────────────────────────────────────────────── # ── _avito_proxies() dict shape ───────────────────────────────────────────────
@ -121,22 +99,8 @@ def test_avito_proxies_returns_none_when_no_proxy():
assert result is None assert result is None
def test_avito_proxies_fallback_via_avito_proxy_url():
"""_avito_proxies(config) uses AVITO_PROXY_URL via scraper_proxy_url fallback.
When config.scraper_proxy_url resolves to the avito URL (fallback path),
the dict shape must be {"http": url, "https": url}.
"""
from scraper_kit.orchestration.pipeline import _avito_proxies
avito_url = "http://mobile-proxy.space:9090"
result = _avito_proxies(_mock_settings(avito_url))
assert result == {"http": avito_url, "https": avito_url}
# ── Real pydantic env-binding (regression for #806 fixup) ───────────────────── # ── Real pydantic env-binding (regression for #806 fixup) ─────────────────────
# The inline-replica tests above prove the OR-logic but NOT that the field # The inline-replica tests above prove the property logic but NOT that the field
# actually binds to env SCRAPER_PROXY_URL. `validation_alias` is what makes that # actually binds to env SCRAPER_PROXY_URL. `validation_alias` is what makes that
# work — without it pydantic-settings reads SCRAPER_PROXY_URL_ENV (the field # work — without it pydantic-settings reads SCRAPER_PROXY_URL_ENV (the field
# name), so setting SCRAPER_PROXY_URL would be a silent no-op. These tests # name), so setting SCRAPER_PROXY_URL would be a silent no-op. These tests
@ -151,26 +115,17 @@ def _fresh_settings():
def test_settings_binds_scraper_proxy_url_env(monkeypatch): def test_settings_binds_scraper_proxy_url_env(monkeypatch):
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234") monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234")
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234" assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234"
def test_settings_scraper_env_overrides_avito(monkeypatch): def test_settings_scraper_proxy_url_none_when_unset(monkeypatch):
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://scraper-proxy:1234")
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678")
assert _fresh_settings().scraper_proxy_url == "http://scraper-proxy:1234"
def test_settings_falls_back_to_avito_env(monkeypatch):
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678") assert _fresh_settings().scraper_proxy_url is None
assert _fresh_settings().scraper_proxy_url == "http://avito-proxy:5678"
def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch): def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch):
"""Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind.""" """Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind."""
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111") monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111")
assert _fresh_settings().scraper_proxy_url is None assert _fresh_settings().scraper_proxy_url is None
@ -378,60 +333,27 @@ async def test_avito_detail_shared_session_not_patched():
# ── Settings.cian_proxy_url property ───────────────────────────────────────── # ── Settings.cian_proxy_url property ─────────────────────────────────────────
# cian_proxy_url: CIAN_PROXY_URL > scraper_proxy_url (fallback) > None. # #2616 шаг 2: dedicated CIAN_PROXY_URL removed (dead mobileproxy account, #2613) —
# cian_proxy_url now mirrors scraper_proxy_url 1:1. Property kept (not inlined at
# call sites) for scraper_kit.contracts.ScraperConfig Protocol compatibility.
def _resolve_cian_proxy_url( def _resolve_cian_proxy_url(scraper_proxy_url: str | None) -> str | None:
cian_proxy_url_env: str | None, scraper_proxy_url: str | None """Inline replica of Settings.cian_proxy_url property logic (post #2616 шаг 2)."""
) -> str | None: return scraper_proxy_url
"""Inline replica of Settings.cian_proxy_url property logic."""
return cian_proxy_url_env or scraper_proxy_url
def test_cian_proxy_url_uses_cian_env_when_set(): def test_cian_proxy_url_mirrors_scraper_proxy_url():
"""CIAN_PROXY_URL takes precedence over shared scraper_proxy_url.""" assert _resolve_cian_proxy_url("http://shared-proxy:1111") == "http://shared-proxy:1111"
result = _resolve_cian_proxy_url(
cian_proxy_url_env="http://cian-proxy:2222",
scraper_proxy_url="http://shared-proxy:1111",
)
assert result == "http://cian-proxy:2222"
def test_cian_proxy_url_falls_back_to_scraper_proxy_url(): def test_cian_proxy_url_none_when_absent():
"""Falls back to shared scraper_proxy_url when CIAN_PROXY_URL is absent.""" assert _resolve_cian_proxy_url(None) is None
result = _resolve_cian_proxy_url(
cian_proxy_url_env=None,
scraper_proxy_url="http://shared-proxy:1111",
)
assert result == "http://shared-proxy:1111"
def test_cian_proxy_url_none_when_both_absent(): def test_settings_cian_proxy_url_mirrors_scraper_proxy_url(monkeypatch):
result = _resolve_cian_proxy_url(cian_proxy_url_env=None, scraper_proxy_url=None) """cian_proxy_url tracks scraper_proxy_url 1:1 — no more independent CIAN_PROXY_URL."""
assert result is None
def test_settings_binds_cian_proxy_url_env(monkeypatch):
"""CIAN_PROXY_URL env binds to cian_proxy_url_env via validation_alias."""
monkeypatch.setenv("CIAN_PROXY_URL", "http://cian-proxy:2222")
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
assert _fresh_settings().cian_proxy_url == "http://cian-proxy:2222"
def test_settings_cian_env_overrides_scraper_proxy(monkeypatch):
"""CIAN_PROXY_URL overrides shared SCRAPER_PROXY_URL for Cian scrapers."""
monkeypatch.setenv("CIAN_PROXY_URL", "http://cian-proxy:2222")
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111") monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111")
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
s = _fresh_settings() s = _fresh_settings()
assert s.cian_proxy_url == "http://cian-proxy:2222" assert s.cian_proxy_url == "http://shared-proxy:1111"
assert s.scraper_proxy_url == "http://shared-proxy:1111" assert s.cian_proxy_url == s.scraper_proxy_url
def test_settings_cian_proxy_falls_back_to_scraper(monkeypatch):
"""Without CIAN_PROXY_URL, cian_proxy_url falls back to scraper_proxy_url."""
monkeypatch.delenv("CIAN_PROXY_URL", raising=False)
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111")
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
assert _fresh_settings().cian_proxy_url == "http://shared-proxy:1111"

View file

@ -54,15 +54,12 @@ def _config() -> SimpleNamespace:
scraper_fetch_mode="curl_cffi", scraper_fetch_mode="curl_cffi",
browser_http_endpoint="http://browser.test/fetch", browser_http_endpoint="http://browser.test/fetch",
scraper_proxy_url=None, scraper_proxy_url=None,
avito_proxy_rotate_url=None,
avito_proxy_max_rotations=0, avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True, avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0, avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1, proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0, proxy_rotate_attempt_timeout_s=1.0,
cian_proxy_rotate_url=None,
cian_proxy_max_rotations=0, cian_proxy_max_rotations=0,
yandex_proxy_rotate_url=None,
yandex_proxy_max_rotations=0, yandex_proxy_max_rotations=0,
scraper_skip_seen_today=False, scraper_skip_seen_today=False,
) )

View file

@ -20,8 +20,6 @@ from scraper_kit.providers.yandex.valuation import YandexValuationScraper
_KIT_CONFIG = SimpleNamespace( _KIT_CONFIG = SimpleNamespace(
scraper_proxy_url=None, scraper_proxy_url=None,
yandex_proxy_rotate_url=None,
avito_proxy_rotate_url=None,
) )

View file

@ -25,8 +25,6 @@ from scraper_kit.providers.yandex.valuation import (
_KIT_CONFIG = SimpleNamespace( _KIT_CONFIG = SimpleNamespace(
scraper_proxy_url=None, scraper_proxy_url=None,
yandex_proxy_rotate_url=None,
avito_proxy_rotate_url=None,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -19,9 +19,10 @@ Per-provider модель (#1793):
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет инстанс имеет geoip=True фингерпринт привязан к гео прокси, поэтому смешивать
geoip=True фингерпринт привязан к гео конкретного прокси, поэтому смешивать источники в одном браузере нельзя. Egress-прокси сейчас общий для всех
источники в одном браузере нельзя. поставщиков (SCRAPER_PROXY_URL, #2616 шаг 2) — раздельные instance'ы остаются
ради geoip-изоляции fingerprint'а и per-provider concurrency (ниже), не прокси.
Конкурентность: Конкурентность:
* МЕЖДУ поставщиками параллельно (avito/cian/yandex гоняются одновременно); * МЕЖДУ поставщиками параллельно (avito/cian/yandex гоняются одновременно);
@ -59,23 +60,19 @@ Per-provider модель (#1793):
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat: глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
при незаданных per-provider env поведение идентично при незаданных per-provider env поведение идентично
предыдущему (чисто аддитивный override). предыдущему (чисто аддитивный override).
BROWSER_PROXY_AVITO http-прокси для avito (fallback AVITO_PROXY_URL). SCRAPER_PROXY_URL http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/
BROWSER_PROXY_CIAN http-прокси для cian (fallback CIAN_PROXY_URL). yandex/generic). #2616 шаг 2: per-provider
BROWSER_PROXY_YANDEX http-прокси для yandex (fallback YANDEX_PROXY_URL). BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
SCRAPER_PROXY_URL http-прокси для generic (fallback AVITO_PROXY_URL). AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты
все указывали на закрытые mobileproxy-аккаунты
ВАЖНО: для браузера используем именно BROWSER_PROXY_* (http-прокси). Legacy (407/connection refused, проверено вживую #2613).
CIAN_PROXY_URL/YANDEX_PROXY_URL могут быть socks5 с авторизацией playwright
их не умеет, поэтому они лишь fallback (если кто-то положил http туда же).
ENVIRONMENT "production" в прод-контейнерах, иначе "dev" (дефолт). ENVIRONMENT "production" в прод-контейнерах, иначе "dev" (дефолт).
#2616 шаг 1: прод + нет НИ override в теле, НИ ЛЮБОГО #2616 шаг 1: прод + нет НИ override в теле, НИ
env-прокси для provider'а → отказ (503, явная причина), SCRAPER_PROXY_URL отказ (503, явная причина), а НЕ
а НЕ launch camoufox без proxy (= прямое подключение с IP launch camoufox без proxy (= прямое подключение с IP
сервера). В dev отсутствие прокси легитимно (см. сервера). В dev отсутствие прокси легитимно (см.
_no_live_proxy). Env-переменная, которая ЗАДАНА, но мертва _no_live_proxy).
(407/connection refused #2613) — этот guard НЕ триггерит,
удаление мёртвых переменных отдельным шагом ПОСЛЕ #2616.
Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS): Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
pre_click_selectors список селекторов для последовательного клика до формы; pre_click_selectors список селекторов для последовательного клика до формы;
@ -226,32 +223,28 @@ def _provider_for_url(url: str) -> str:
def _provider_proxy(provider: str) -> str | None: def _provider_proxy(provider: str) -> str | None:
"""Возвращает http-прокси для поставщика из env (с фолбэком), иначе None. """Возвращает http-прокси для поставщика из env, иначе None.
Используем BROWSER_PROXY_* как primary это http-прокси для браузера. #2616 шаг 2: per-provider BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
Legacy *_PROXY_URL лишь fallback: playwright НЕ умеет socks5 с авторизацией, AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты все указывали на
поэтому socks5-варианты CIAN/YANDEX как primary не годятся. закрытые mobileproxy-аккаунты (#2613). SCRAPER_PROXY_URL (ASocks, http-прокси,
playwright-совместимый) единственный источник, общий для ВСЕХ поставщиков
(включая generic). `provider` оставлен параметром сигнатуры (вызывающие
передают его) на случай возврата к per-provider egress в будущем.
""" """
env_map: dict[str, tuple[str, str]] = { return os.environ.get("SCRAPER_PROXY_URL")
"avito": ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"),
"cian": ("BROWSER_PROXY_CIAN", "CIAN_PROXY_URL"),
"yandex": ("BROWSER_PROXY_YANDEX", "YANDEX_PROXY_URL"),
"generic": ("SCRAPER_PROXY_URL", "AVITO_PROXY_URL"),
}
primary, fallback = env_map.get(provider, env_map["generic"])
return os.environ.get(primary) or os.environ.get(fallback)
def _no_live_proxy(provider: str, proxy_override: str | None) -> bool: def _no_live_proxy(provider: str, proxy_override: str | None) -> bool:
"""True если нет НИ explicit proxy в теле запроса, НИ env-прокси для поставщика. """True если нет НИ explicit proxy в теле запроса, НИ SCRAPER_PROXY_URL.
#2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси #2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси
легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue
пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси" пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси"
здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) единственный здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) единственный
случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана
но мертва (407/connection refused проверено вживую #2613), эту функцию не но мертва (407/connection refused), этот guard не триггерит за живость значения
триггерит: удаление мёртвых переменных отдельный шаг #2616 ПОСЛЕ этой правки. отвечает мониторинг прокси-пула, не этот код.
""" """
return proxy_override is None and _provider_proxy(provider) is None return proxy_override is None and _provider_proxy(provider) is None
@ -577,9 +570,9 @@ def _resolve_provider(body: dict, url: str) -> str:
берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS; берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS;
иначе host-detect; иначе generic. иначе host-detect; иначе generic.
# domclick (source='domklik') не имеет выделенного BROWSER_PROXY_DOMCLICK в prod # domclick (source='domklik') нет в PROVIDERS → fallthrough на host-detect
# → generic. Намеренно. ('domklik' нет в PROVIDERS → fallthrough на host-detect # domclick.ru → generic. Намеренно (все поставщики делят один SCRAPER_PROXY_URL
# domclick.ru → generic.) # с #2616 шаг 2, generic-путь эквивалентен).
""" """
explicit = body.get("provider") or body.get("source") explicit = body.get("provider") or body.get("source")
if isinstance(explicit, str) and explicit in PROVIDERS: if isinstance(explicit, str) and explicit in PROVIDERS:
@ -593,7 +586,7 @@ def _resolve_proxy_override(body: dict, provider: str) -> str | None:
Scraper-сторона (BrowserFetcher за флагом use_proxy_pool_browser) кладёт в тело Scraper-сторона (BrowserFetcher за флагом use_proxy_pool_browser) кладёт в тело
``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` прокси, взятый из ``{"proxy": "http://user:pass@host:port", "proxy_kind": "http"}`` прокси, взятый из
пула через acquire(source). Если поля нет / пусто None инстанс поставщика берёт пула через acquire(source). Если поля нет / пусто None инстанс поставщика берёт
свой env-прокси (BROWSER_PROXY_*), поведение не меняется (ship-dark parity). общий SCRAPER_PROXY_URL (#2616 шаг 2), поведение не меняется (ship-dark parity).
camoufox НЕ умеет socks5 с авторизацией принимаем ТОЛЬКО http/https-прокси; иной camoufox НЕ умеет socks5 с авторизацией принимаем ТОЛЬКО http/https-прокси; иной
proxy_kind игнорируем ( None, env-fallback) с предупреждением. Отсутствие proxy_kind proxy_kind игнорируем ( None, env-fallback) с предупреждением. Отсутствие proxy_kind

View file

@ -2,7 +2,8 @@
Проверяет ядро per-provider модели: Проверяет ядро per-provider модели:
1. _provider_for_url корректный маппинг host provider (avito/cian/yandex/generic); 1. _provider_for_url корректный маппинг host provider (avito/cian/yandex/generic);
2. _provider_proxy BROWSER_PROXY_* primary с фолбэком на legacy *_PROXY_URL; 2. _provider_proxy SCRAPER_PROXY_URL, общий для всех поставщиков (#2616 шаг 2:
per-provider BROWSER_PROXY_*/legacy *_PROXY_URL сняты);
3. _resolve_provider явный body["provider"] перебивает host, иначе host; 3. _resolve_provider явный body["provider"] перебивает host, иначе host;
4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи), 4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
два к ОДНОМУ сериализованы (один лок). Через _do_fetch с asyncio.Event и два к ОДНОМУ сериализованы (один лок). Через _do_fetch с asyncio.Event и
@ -79,57 +80,21 @@ def test_provider_for_url_generic_fallback() -> None:
# ── _provider_proxy ────────────────────────────────────────────────────────────── # ── _provider_proxy ──────────────────────────────────────────────────────────────
def test_provider_proxy_uses_browser_proxy_primary(monkeypatch: pytest.MonkeyPatch) -> None: def test_provider_proxy_shared_across_providers(monkeypatch: pytest.MonkeyPatch) -> None:
"""BROWSER_PROXY_* — primary источник для каждого поставщика.""" """#2616 шаг 2: SCRAPER_PROXY_URL — единственный источник, общий для всех."""
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://u:p@avito-proxy:8080") monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@shared-proxy:8080")
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@cian-proxy:8080") assert server._provider_proxy("avito") == "http://u:p@shared-proxy:8080"
monkeypatch.setenv("BROWSER_PROXY_YANDEX", "http://u:p@yandex-proxy:8080") assert server._provider_proxy("cian") == "http://u:p@shared-proxy:8080"
assert server._provider_proxy("avito") == "http://u:p@avito-proxy:8080" assert server._provider_proxy("yandex") == "http://u:p@shared-proxy:8080"
assert server._provider_proxy("cian") == "http://u:p@cian-proxy:8080" assert server._provider_proxy("generic") == "http://u:p@shared-proxy:8080"
assert server._provider_proxy("yandex") == "http://u:p@yandex-proxy:8080"
def test_provider_proxy_falls_back_to_legacy(monkeypatch: pytest.MonkeyPatch) -> None:
"""Без BROWSER_PROXY_* берётся legacy *_PROXY_URL фолбэк."""
monkeypatch.delenv("BROWSER_PROXY_AVITO", raising=False)
monkeypatch.delenv("BROWSER_PROXY_CIAN", raising=False)
monkeypatch.delenv("BROWSER_PROXY_YANDEX", raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@legacy-avito:8080")
monkeypatch.setenv("CIAN_PROXY_URL", "http://u:p@legacy-cian:8080")
monkeypatch.setenv("YANDEX_PROXY_URL", "http://u:p@legacy-yandex:8080")
assert server._provider_proxy("avito") == "http://u:p@legacy-avito:8080"
assert server._provider_proxy("cian") == "http://u:p@legacy-cian:8080"
assert server._provider_proxy("yandex") == "http://u:p@legacy-yandex:8080"
def test_provider_proxy_generic(monkeypatch: pytest.MonkeyPatch) -> None:
"""generic: SCRAPER_PROXY_URL primary, AVITO_PROXY_URL фолбэк."""
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://u:p@avito:8080")
assert server._provider_proxy("generic") == "http://u:p@avito:8080"
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@scraper:8080")
assert server._provider_proxy("generic") == "http://u:p@scraper:8080"
def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной env → None (direct, как одиночный браузер без прокси).""" """SCRAPER_PROXY_URL не задан → None (direct, как одиночный браузер без прокси)."""
for var in ( monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
"BROWSER_PROXY_CIAN",
"CIAN_PROXY_URL",
):
monkeypatch.delenv(var, raising=False)
assert server._provider_proxy("cian") is None assert server._provider_proxy("cian") is None
def test_provider_proxy_browser_proxy_wins_over_legacy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""BROWSER_PROXY_* перебивает legacy *_PROXY_URL (http vs возможный socks5)."""
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@http-cian:8080")
monkeypatch.setenv("CIAN_PROXY_URL", "socks5://u:p@socks-cian:1080")
assert server._provider_proxy("cian") == "http://u:p@http-cian:8080"
# ── _resolve_provider ──────────────────────────────────────────────────────────── # ── _resolve_provider ────────────────────────────────────────────────────────────
@ -158,8 +123,8 @@ def test_resolve_provider_uses_source_field() -> None:
def test_resolve_provider_domclick_source_to_generic() -> None: def test_resolve_provider_domclick_source_to_generic() -> None:
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru. """domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
'domklik' не валиден host-detect domclick.ru generic. Намеренно: в prod 'domklik' не валиден host-detect domclick.ru generic. Намеренно: 'domklik'
нет выделенного BROWSER_PROXY_DOMCLICK. не входит в PROVIDERS (нет выделенного instance).
""" """
assert ( assert (
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic" server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"

View file

@ -1,13 +1,12 @@
"""test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера. """test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера.
tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело
запроса, ни ЛЮБАЯ env-переменная не несли proxy, лениво запускал camoufox БЕЗ запроса, ни SCRAPER_PROXY_URL не несли proxy, лениво запускал camoufox БЕЗ
proxy прямое подключение с IP сервера. Мертвые env-переменные (407/connection proxy прямое подключение с IP сервера. #2616 шаг 2 удалил мёртвые per-provider
refused, #2613) сегодня этого не триггерят (запуск падает раньше, на invalid-proxy переменные (AVITO_PROXY_URL/BROWSER_PROXY_* и т.д., 407/connection refused,
проверке camoufox) но как только шаг 2 issue #2616 их удалит, сценарий станет #2613) — SCRAPER_PROXY_URL остался единственным источником. Этот файл фиксирует
реальным. Этот файл фиксирует контракт ЗАРАНЕЕ (`_no_live_proxy` + `IS_PROD` guard контракт (`_no_live_proxy` + `IS_PROD` guard в
в fetch_handler/fetch_json_handler/login_handler), до того как #2616 шаг 2 сделает fetch_handler/fetch_json_handler/login_handler).
его наблюдаемым в проде.
Проверяет: Проверяет:
1. `_no_live_proxy` pure-function поведение (override / env приоритеты). 1. `_no_live_proxy` pure-function поведение (override / env приоритеты).
@ -65,8 +64,9 @@ def _make_request(path: str, body: dict[str, Any]) -> Any:
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера. """Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера.
Явно чистим все env-переменные, которые `_provider_proxy` умеет читать тест Явно чистим SCRAPER_PROXY_URL (единственная переменная, которую `_provider_proxy`
не должен зависеть от того, что реально задано в shell/CI-раннере. читает, #2616 шаг 2) — тест не должен зависеть от того, что реально задано в
shell/CI-раннере.
""" """
monkeypatch.setattr(server, "_browsers", {}) monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {}) monkeypatch.setattr(server, "_browser_cms", {})
@ -76,16 +76,7 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "_last_goto_at", {}) monkeypatch.setattr(server, "_last_goto_at", {})
monkeypatch.setattr(server, "_launched_proxy", {}) monkeypatch.setattr(server, "_launched_proxy", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
for var in ( monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
"BROWSER_PROXY_AVITO",
"AVITO_PROXY_URL",
"BROWSER_PROXY_CIAN",
"CIAN_PROXY_URL",
"BROWSER_PROXY_YANDEX",
"YANDEX_PROXY_URL",
"SCRAPER_PROXY_URL",
):
monkeypatch.delenv(var, raising=False)
def _forbid_ensure_browser(monkeypatch: pytest.MonkeyPatch) -> list[str]: def _forbid_ensure_browser(monkeypatch: pytest.MonkeyPatch) -> list[str]:
@ -115,7 +106,7 @@ def test_no_live_proxy_false_with_override() -> None:
def test_no_live_proxy_false_with_env(monkeypatch: pytest.MonkeyPatch) -> None: def test_no_live_proxy_false_with_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://env:8080") monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
assert server._no_live_proxy("avito", None) is False assert server._no_live_proxy("avito", None) is False
@ -220,7 +211,7 @@ def test_fetch_handler_dev_still_attempts_launch(monkeypatch: pytest.MonkeyPatch
def test_fetch_handler_prod_with_env_proxy_unaffected(monkeypatch: pytest.MonkeyPatch) -> None: def test_fetch_handler_prod_with_env_proxy_unaffected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "IS_PROD", True) monkeypatch.setattr(server, "IS_PROD", True)
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://env:8080") # "живой" с т.з. guard'а monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") # "живой" с т.з. guard'а
calls: list[tuple[str, str | None]] = [] calls: list[tuple[str, str | None]] = []
async def _ensure(provider: str, proxy_override: str | None = None) -> bool: async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
@ -294,7 +285,7 @@ def test_refusal_message_distinguishable_from_launch_failure(
no_proxy_text = _json_body(no_proxy_resp)["error"] no_proxy_text = _json_body(no_proxy_resp)["error"]
# launch failure (дохлый-но-заданный env) — существующий, другой путь. # launch failure (дохлый-но-заданный env) — существующий, другой путь.
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://dead:8080") monkeypatch.setenv("SCRAPER_PROXY_URL", "http://dead:8080")
async def _fails_launch(provider: str, proxy_override: str | None = None) -> bool: async def _fails_launch(provider: str, proxy_override: str | None = None) -> bool:
return False # camoufox launch упал (InvalidProxy) — существующая семантика return False # camoufox launch упал (InvalidProxy) — существующая семантика

View file

@ -126,8 +126,7 @@ def test_ensure_browser_changed_proxy_relaunches(monkeypatch: pytest.MonkeyPatch
def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.MonkeyPatch) -> None: def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.MonkeyPatch) -> None:
"""proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity).""" """proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity)."""
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"): monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
monkeypatch.setenv(var, "http://env:8080")
calls = _install_fake_launch(monkeypatch) calls = _install_fake_launch(monkeypatch)
async def _run() -> None: async def _run() -> None:
@ -141,8 +140,7 @@ def test_ensure_browser_none_override_env_no_relaunch(monkeypatch: pytest.Monkey
def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch) -> None: def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch) -> None:
"""env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена).""" """env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена)."""
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"): monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
monkeypatch.setenv(var, "http://env:8080")
calls = _install_fake_launch(monkeypatch) calls = _install_fake_launch(monkeypatch)
async def _run() -> None: async def _run() -> None:

View file

@ -1,11 +1,12 @@
"use client"; "use client";
import { import { useScraperHealth } from "@/components/scrapers/ProxyHealthCard";
useScraperHealth,
useRotateIp,
} from "@/components/scrapers/ProxyHealthCard";
// ── Per-provider proxy section (inside provider tab) ─────────────────────── // ── Per-provider proxy section (inside provider tab) ───────────────────────
// #2616 шаг 3: ручная ротация IP (changeip mobileproxy) удалена вместе с
// мёртвой подпиской (#2613) — секция теперь read-only (прокси + текущий IP).
// Живая ротация — ASOCKS proxy /admin/proxies/{id}/rotate (#2611), отдельная
// от per-provider UI здесь.
interface ProviderProxySectionProps { interface ProviderProxySectionProps {
source: string; source: string;
@ -13,7 +14,6 @@ interface ProviderProxySectionProps {
export function ProviderProxySection({ source }: ProviderProxySectionProps) { export function ProviderProxySection({ source }: ProviderProxySectionProps) {
const healthQ = useScraperHealth(); const healthQ = useScraperHealth();
const rotateMut = useRotateIp(source);
const provider = healthQ.data?.providers.find((p) => p.source === source); const provider = healthQ.data?.providers.find((p) => p.source === source);
@ -68,70 +68,18 @@ export function ProviderProxySection({ source }: ProviderProxySectionProps) {
<span className="schedule-status__label">Текущий IP:</span> <span className="schedule-status__label">Текущий IP:</span>
<span> <span>
{provider.current_ip ? ( {provider.current_ip ? (
<code style={{ fontSize: "0.85rem" }}> <code style={{ fontSize: "0.85rem" }}>{provider.current_ip}</code>
{provider.current_ip}
</code>
) : ( ) : (
<span style={{ color: "var(--fg-tertiary, #73767e)" }}></span> <span style={{ color: "var(--fg-tertiary, #73767e)" }}></span>
)} )}
</span> </span>
</div> </div>
<div className="schedule-status__row">
<span className="schedule-status__label">Режим:</span>
<span
style={{
fontSize: "0.85rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{provider.rotate_supported ? "rotate-поддержка" : "статический"}
</span>
</div>
</div> </div>
{provider.rotate_supported && ( <p className="scraper-hint" style={{ margin: 0 }}>
<div> Ручная ротация IP для этого провайдера недоступна резидентный прокси
<button ротируется через ASOCKS-пул (см. страницу «Прокси»).
type="button" </p>
disabled={rotateMut.isPending}
onClick={() => rotateMut.mutate()}
>
{rotateMut.isPending ? "Ротируем…" : "Сменить IP"}
</button>
{rotateMut.isSuccess && (
<div
className="scraper-result"
style={{ marginTop: 8 }}
>
{rotateMut.data.ok ? (
<>
Новый IP: <strong>{rotateMut.data.new_ip ?? "обновлён"}</strong>
</>
) : (
<span style={{ color: "var(--danger, #b3261e)" }}>
{rotateMut.data.reason ?? "ошибка ротации"}
</span>
)}
</div>
)}
{rotateMut.isError && (
<div className="scraper-result scraper-result--error" style={{ marginTop: 8 }}>
Ошибка: {rotateMut.error.message}
</div>
)}
</div>
)}
{!provider.rotate_supported && (
<p
className="scraper-hint"
style={{ margin: 0 }}
>
Ротация IP не поддерживается для этого провайдера.
</p>
)}
</section> </section>
); );
} }

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
@ -9,6 +9,10 @@ export interface ProviderHealth {
source: string; source: string;
proxy_host: string | null; proxy_host: string | null;
proxy_port: number | null; proxy_port: number | null;
// #2616 шаг 3: всегда false — changeip mobileproxy-ротация (POST
// /scraper/{source}/rotate-ip) снята вместе с мёртвой подпиской. Живая
// ротация — ASOCKS proxy /admin/proxies/{id}/rotate (#2611), без per-provider
// UI-кнопки здесь.
rotate_supported: boolean; rotate_supported: boolean;
current_ip: string | null; current_ip: string | null;
} }
@ -24,12 +28,6 @@ export interface ScraperHealthResp {
providers: ProviderHealth[]; providers: ProviderHealth[];
} }
interface RotateIpResp {
ok: boolean;
new_ip: string | null;
reason: string | null;
}
// ── Hooks ────────────────────────────────────────────────────────────────── // ── Hooks ──────────────────────────────────────────────────────────────────
export function useScraperHealth() { export function useScraperHealth() {
@ -42,20 +40,6 @@ export function useScraperHealth() {
}); });
} }
export function useRotateIp(source: string) {
const qc = useQueryClient();
return useMutation<RotateIpResp, Error>({
mutationFn: () =>
apiFetch<RotateIpResp>(
`/api/v1/admin/scraper/${encodeURIComponent(source)}/rotate-ip`,
{ method: "POST" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scraper-health"] });
},
});
}
// ── SystemSection ────────────────────────────────────────────────────────── // ── SystemSection ──────────────────────────────────────────────────────────
function FetchModeBadge({ mode }: { mode: string }) { function FetchModeBadge({ mode }: { mode: string }) {
@ -71,9 +55,7 @@ function FetchModeBadge({ mode }: { mode: string }) {
background: isBrowser background: isBrowser
? "var(--accent-soft, #dbeafe)" ? "var(--accent-soft, #dbeafe)"
: "var(--warn-soft, #fef3c7)", : "var(--warn-soft, #fef3c7)",
color: isBrowser color: isBrowser ? "var(--accent, #1d4ed8)" : "var(--warn, #9a6700)",
? "var(--accent, #1d4ed8)"
: "var(--warn, #9a6700)",
fontFamily: "ui-monospace, monospace", fontFamily: "ui-monospace, monospace",
}} }}
> >
@ -94,9 +76,7 @@ function BrowserBadge({ reachable }: { reachable: boolean }) {
background: reachable background: reachable
? "var(--success-soft, #dcfce7)" ? "var(--success-soft, #dcfce7)"
: "var(--danger-soft, #fee2e2)", : "var(--danger-soft, #fee2e2)",
color: reachable color: reachable ? "var(--success, #0a7a3a)" : "var(--danger, #b3261e)",
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}} }}
> >
{reachable ? "доступен" : "недоступен"} {reachable ? "доступен" : "недоступен"}
@ -109,7 +89,6 @@ interface ProviderRowProps {
} }
function ProviderProxyRow({ provider }: ProviderRowProps) { function ProviderProxyRow({ provider }: ProviderRowProps) {
const rotateMut = useRotateIp(provider.source);
const proxyStr = const proxyStr =
provider.proxy_host && provider.proxy_port provider.proxy_host && provider.proxy_port
? `${provider.proxy_host}:${provider.proxy_port}` ? `${provider.proxy_host}:${provider.proxy_port}`
@ -124,74 +103,7 @@ function ProviderProxyRow({ provider }: ProviderRowProps) {
<code style={{ fontSize: "0.8rem" }}>{proxyStr}</code> <code style={{ fontSize: "0.8rem" }}>{proxyStr}</code>
</td> </td>
<td> <td>
<code style={{ fontSize: "0.8rem" }}> <code style={{ fontSize: "0.8rem" }}>{provider.current_ip ?? "—"}</code>
{provider.current_ip ?? "—"}
</code>
</td>
<td>
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{provider.rotate_supported ? "rotate" : "статический"}
</span>
</td>
<td>
{provider.rotate_supported ? (
<button
type="button"
className="cancel-btn"
disabled={rotateMut.isPending}
onClick={() => rotateMut.mutate()}
style={{ fontSize: "0.8rem", padding: "3px 10px" }}
>
{rotateMut.isPending ? "…" : "Сменить IP"}
</button>
) : (
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-tertiary, #73767e)",
}}
>
</span>
)}
{rotateMut.isSuccess && rotateMut.data.ok && (
<span
style={{
marginLeft: 8,
fontSize: "0.75rem",
color: "var(--success, #0a7a3a)",
}}
>
{rotateMut.data.new_ip ?? "обновлён"}
</span>
)}
{rotateMut.isSuccess && !rotateMut.data.ok && (
<span
style={{
marginLeft: 8,
fontSize: "0.75rem",
color: "var(--danger, #b3261e)",
}}
>
{rotateMut.data.reason ?? "ошибка"}
</span>
)}
{rotateMut.isError && (
<span
style={{
marginLeft: 8,
fontSize: "0.75rem",
color: "var(--danger, #b3261e)",
}}
>
{rotateMut.error.message}
</span>
)}
</td> </td>
</tr> </tr>
); );
@ -236,9 +148,7 @@ export function SystemHealthSection() {
flexWrap: "wrap", flexWrap: "wrap",
}} }}
> >
<div <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span <span
style={{ style={{
fontSize: "0.85rem", fontSize: "0.85rem",
@ -249,9 +159,7 @@ export function SystemHealthSection() {
</span> </span>
<FetchModeBadge mode={healthQ.data.fetch_mode} /> <FetchModeBadge mode={healthQ.data.fetch_mode} />
</div> </div>
<div <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span <span
style={{ style={{
fontSize: "0.85rem", fontSize: "0.85rem",
@ -260,40 +168,36 @@ export function SystemHealthSection() {
> >
Browser-сервис: Browser-сервис:
</span> </span>
<BrowserBadge <BrowserBadge reachable={healthQ.data.browser.reachable} />
reachable={healthQ.data.browser.reachable}
/>
</div> </div>
{Object.entries(healthQ.data.browser.browsers).map( {Object.entries(healthQ.data.browser.browsers).map(([name, ok]) => (
([name, ok]) => ( <div
<div key={name}
key={name} style={{ display: "flex", alignItems: "center", gap: 4 }}
style={{ display: "flex", alignItems: "center", gap: 4 }} >
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
> >
<span {name}:
style={{ </span>
fontSize: "0.8rem", <span
color: "var(--fg-secondary, #5b6066)", role="img"
}} aria-label={ok ? "статус: доступен" : "статус: недоступен"}
> style={{
{name}: width: 8,
</span> height: 8,
<span borderRadius: "50%",
role="img" display: "inline-block",
aria-label={ok ? "статус: доступен" : "статус: недоступен"} background: ok
style={{ ? "var(--success, #0a7a3a)"
width: 8, : "var(--danger, #b3261e)",
height: 8, }}
borderRadius: "50%", />
display: "inline-block", </div>
background: ok ))}
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
/>
</div>
),
)}
</div> </div>
{/* Per-provider proxy table */} {/* Per-provider proxy table */}
@ -304,8 +208,6 @@ export function SystemHealthSection() {
<th scope="col">Провайдер</th> <th scope="col">Провайдер</th>
<th scope="col">Прокси (host:port)</th> <th scope="col">Прокси (host:port)</th>
<th scope="col">Текущий IP</th> <th scope="col">Текущий IP</th>
<th scope="col">Режим</th>
<th scope="col">Действие</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View file

@ -115,16 +115,14 @@ class ScraperConfig(Protocol):
scraper_fetch_mode: str scraper_fetch_mode: str
# HTTP-эндпоинт headless-браузера (tradein-browser). # HTTP-эндпоинт headless-браузера (tradein-browser).
browser_http_endpoint: str browser_http_endpoint: str
# Backconnect-прокси общий (Avito/Cian fallback). # Backconnect-прокси общий (Avito/Cian/Yandex, #2616 шаг 2: единственный источник).
scraper_proxy_url: str | None scraper_proxy_url: str | None
# Avito rotate-прокси + лимит ротаций на прогон. # Лимит IP-ротаций на прогон (changeip-ссылка снята #2616 шаг 2 — сама ротация
avito_proxy_rotate_url: str | None # no-op, поле гейтит budget accounting в ban-rotation state machine).
avito_proxy_max_rotations: int avito_proxy_max_rotations: int
# Ограничивать Avito SERP только ЕКБ. # Ограничивать Avito SERP только ЕКБ.
avito_serp_ekb_only: bool avito_serp_ekb_only: bool
# Yandex rotate-прокси. # Cian-прокси (#2616 шаг 2: = scraper_proxy_url, per-provider override снят).
yandex_proxy_rotate_url: str | None
# Cian-прокси (CIAN_PROXY_URL > scraper_proxy_url fallback).
cian_proxy_url: str | None cian_proxy_url: str | None
# Границы валидности cian-оценки, руб. # Границы валидности cian-оценки, руб.
cian_valuation_min_rub: float cian_valuation_min_rub: float
@ -144,12 +142,14 @@ class ScraperConfig(Protocol):
# changeip settle-пауза (секунды) после ротации IP. # changeip settle-пауза (секунды) после ротации IP.
avito_proxy_rotate_settle_s: float avito_proxy_rotate_settle_s: float
# Ретраи changeip-GET: N попыток по proxy_rotate_attempt_timeout_s каждая (#1950). # Ретраи changeip-GET: N попыток по proxy_rotate_attempt_timeout_s каждая (#1950).
# changeip-ссылка снята (#2616 шаг 2) — эти два поля сейчас не читаются
# ban-rotation кодом (см. avito_proxy_max_rotations выше), оставлены как
# budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for.
proxy_rotate_attempts: int proxy_rotate_attempts: int
proxy_rotate_attempt_timeout_s: float proxy_rotate_attempt_timeout_s: float
# Cian rotate-прокси + лимит ротаций. # Cian лимит ротаций (rotate_url снят #2616 шаг 2, см. avito_proxy_max_rotations).
cian_proxy_rotate_url: str | None
cian_proxy_max_rotations: int cian_proxy_max_rotations: int
# Yandex лимит ротаций (rotate_url уже выше). # Yandex лимит ротаций (аналогично).
yandex_proxy_max_rotations: int yandex_proxy_max_rotations: int
# Пропускать листинги, уже обновлённые сегодня по МСК (full-load дедуп). # Пропускать листинги, уже обновлённые сегодня по МСК (full-load дедуп).
scraper_skip_seen_today: bool scraper_skip_seen_today: bool

View file

@ -103,77 +103,25 @@ async def _rotate_proxy_ip(
rotations_done: int, rotations_done: int,
source: str = "avito", source: str = "avito",
) -> bool: ) -> bool:
"""Вызвать changeip-ссылку mobileproxy и подождать settle (#1790/#1848). """changeip-ротация mobileproxy снята (#2616 шаг 2) — все три changeip-ссылки
(avito/cian/yandex `*_PROXY_ROTATE_URL`) указывали на закрытые аккаунты
(407/connection refused, проверено вживую #2613) и удалены из ScraperConfig.
Provider-aware: выбирает rotate_url и max_rotations по параметру `source` IP-ротация здесь физически невозможна без changeip-ссылки всегда False.
(avito / cian / yandex). Fallback-цепочка для rotate_url: Аналог AvitoScraper._rotate_ip (см. providers/avito/serp.py) используется в
avito: avito_proxy_rotate_url enrichment-фазах где нет доступа к экземпляру scraper'а. Единственный живой
cian: cian_proxy_rotate_url avito_proxy_rotate_url прокси (SCRAPER_PROXY_URL, ASocks) не имеет per-request changeip его ротация
yandex: yandex_proxy_rotate_url avito_proxy_rotate_url POST /api/v1/admin/proxies/{id}/rotate (app.services.proxy_rotation, #2611),
отдельный от sweep-оркестрации путь.
Аналог AvitoScraper._rotate_ip, но на уровне pipeline используется в Оставлена как stub (не удалена целиком): `_try_rotate_within_budget` продолжает
enrichment-фазах где нет доступа к экземпляру scraper'а. считать бюджет ротаций точно так же, как раньше при мёртвом changeip (та же
False-ветка) вызывающие сохраняют abort-поведение без изменений, без
Returns True при успешной смене IP, False если rotate_url не задан или ошибка. сетевого похода в никуда.
Логирует каждую ротацию (причина, порядковый номер, source, остаток лимита).
""" """
if source == "cian": logger.debug(
rotate_url = config.cian_proxy_rotate_url or config.avito_proxy_rotate_url "pipeline: IP rotation unavailable (changeip retired #2616) — "
max_rot = config.cian_proxy_max_rotations "source=%s reason=%s rotation=#%d",
elif source == "yandex":
rotate_url = config.yandex_proxy_rotate_url or config.avito_proxy_rotate_url
max_rot = config.yandex_proxy_max_rotations
else:
# avito (default) — backward compat
rotate_url = config.avito_proxy_rotate_url
max_rot = config.avito_proxy_max_rotations
if not rotate_url:
return False
sep = "&" if "?" in rotate_url else "?"
# #1950: retry changeip-GET короткими попытками вместо одношотного 30s-timeout.
# Если changeip-сервер завис на 30s → весь прогон ждёт зря + ротация считается
# успешной (False возвращался). Теперь: proxy_rotate_attempts попыток по
# proxy_rotate_attempt_timeout_s каждая; возвращаем True при первом успехе.
_attempts = config.proxy_rotate_attempts
_attempt_timeout = config.proxy_rotate_attempt_timeout_s
for attempt in range(_attempts):
try:
async with AsyncSession(timeout=_attempt_timeout) as rot:
resp = await rot.get(f"{rotate_url}{sep}format=json")
new_ip: str = ""
try:
data = resp.json()
new_ip = str(data.get("new_ip", ""))
except Exception:
pass
await asyncio.sleep(config.avito_proxy_rotate_settle_s)
logger.info(
"pipeline: IP rotated via changeip — source=%s reason=%s "
"rotation=#%d attempt=%d/%d remaining=%d new_ip=%s",
source,
reason,
rotations_done + 1,
attempt + 1,
_attempts,
max_rot - rotations_done - 1,
new_ip or "unknown",
)
return True
except Exception:
logger.warning(
"pipeline: IP rotation attempt %d/%d failed " "(source=%s reason=%s rotation=#%d)",
attempt + 1,
_attempts,
source,
reason,
rotations_done + 1,
exc_info=True,
)
if attempt < _attempts - 1:
await asyncio.sleep(1.0)
logger.error(
"pipeline: all %d changeip attempts failed " "(source=%s reason=%s rotation=#%d)",
_attempts,
source, source,
reason, reason,
rotations_done + 1, rotations_done + 1,
@ -464,7 +412,8 @@ def _avito_proxies(config: ScraperConfig) -> dict[str, str] | None:
detail-страницы летят с datacenter-IP HTTP 429 весь run mark_banned. detail-страницы летят с datacenter-IP HTTP 429 весь run mark_banned.
Пусто (env не задан) прямое подключение (dev/no-op). Пусто (env не задан) прямое подключение (dev/no-op).
Читает config.scraper_proxy_url (SCRAPER_PROXY_URL > AVITO_PROXY_URL fallback). Читает config.scraper_proxy_url (= SCRAPER_PROXY_URL, #2616 шаг 2: legacy
AVITO_PROXY_URL fallback снят).
""" """
url = config.scraper_proxy_url url = config.scraper_proxy_url
return {"http": url, "https": url} if url else None return {"http": url, "https": url} if url else None

View file

@ -376,22 +376,9 @@ class AvitoScraper(BaseScraper):
self._target_city_slug = target_city_slug self._target_city_slug = target_city_slug
async def __aenter__(self) -> AvitoScraper: async def __aenter__(self) -> AvitoScraper:
# Проактивная ротация IP в начале sweep (#1731): apw-IP «протухает» по # Проактивная changeip-ротация в начале sweep (#1731) снята #2616 шаг 2 —
# Datadome между суточными прогонами, поэтому первые якоря блокируются и # changeip-ссылка (мёртвый mobileproxy-аккаунт) удалена из ScraperConfig,
# реактивного бюджета max_rotations не хватает (3 подряд блока → # _rotate_ip() ниже теперь всегда no-op (см. её docstring).
# mark_banned). Один changeip-вызов до первого SERP-fetch даёт свежий IP.
# _rotate_ip() сам ждёт ~9с (settle) и логирует/глотает ошибку, поэтому
# тут только дополнительный guard: сбой ротации НЕ должен ронять sweep.
if self._config.avito_proxy_rotate_url:
try:
if await self._rotate_ip():
logger.info("avito: proactive IP rotate at sweep start (#1731)")
else:
logger.warning("avito: proactive IP rotate at sweep start failed — proceeding")
except Exception:
logger.warning(
"avito: proactive IP rotate at sweep start raised — proceeding", exc_info=True
)
if self._config.scraper_fetch_mode == "browser": if self._config.scraper_fetch_mode == "browser":
# Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider # Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider
# + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как # + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как
@ -442,24 +429,14 @@ class AvitoScraper(BaseScraper):
# ── Anti-block (#623) ───────────────────────────────────────────────────── # ── Anti-block (#623) ─────────────────────────────────────────────────────
async def _rotate_ip(self) -> bool: async def _rotate_ip(self) -> bool:
"""Сменить мобильный IP через changeip-ссылку mobileproxy (#623). """changeip mobileproxy-ротация снята (#2616 шаг 2) — аккаунт закрыт
(407/connection refused, проверено вживую #2613), changeip-ссылка удалена
Дёргается напрямую (без прокси) это API провайдера, не Avito. Ждём ~9с: из ScraperConfig. Метод оставлен (returns False) вызывающие (proактивная
мобильному модему нужно время поднять новый IP. Returns True при успехе. ротация at sweep start, soft-ban retry в _fetch_serp_html_*) продолжают
работать через тот же no-op путь, что и раньше при незаданном rotate_url.
Живая ротация ASOCKS proxy_rotation (#2611), отдельный от SERP-цикла путь.
""" """
rotate_url = self._config.avito_proxy_rotate_url return False
if not rotate_url:
return False
sep = "&" if "?" in rotate_url else "?"
try:
async with AsyncSession(timeout=30) as rot:
await rot.get(f"{rotate_url}{sep}format=json")
await asyncio.sleep(9)
logger.info("avito proxy: IP rotated via changeip")
return True
except Exception:
logger.warning("avito proxy: IP rotation failed", exc_info=True)
return False
async def _fetch_serp_html(self, url: str, page: int) -> str | None: async def _fetch_serp_html(self, url: str, page: int) -> str | None:
"""GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805). """GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805).
@ -508,7 +485,9 @@ class AvitoScraper(BaseScraper):
исчерпаны AvitoRateLimitedError (graceful mark_banned, partial preserved). исчерпаны AvitoRateLimitedError (graceful mark_banned, partial preserved).
""" """
assert self._browser is not None assert self._browser is not None
max_rot = self._config.avito_proxy_max_rotations if self._config.avito_proxy_rotate_url else 0 # changeip снят (#2616 шаг 2) — _rotate_ip() всегда False, бюджет 0 (было
# условным на avito_proxy_rotate_url, который больше не существует).
max_rot = 0
rot_done = 0 rot_done = 0
# Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/ # Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/
# crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient) # crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient)
@ -641,7 +620,9 @@ class AvitoScraper(BaseScraper):
AvitoBlockedError / AvitoRateLimitedError если бан не снят. AvitoBlockedError / AvitoRateLimitedError если бан не снят.
""" """
assert self._cffi is not None assert self._cffi is not None
max_rot = self._config.avito_proxy_max_rotations if self._config.avito_proxy_rotate_url else 0 # changeip снят (#2616 шаг 2) — _rotate_ip() всегда False, бюджет 0 (было
# условным на avito_proxy_rotate_url, который больше не существует).
max_rot = 0
# curl_cffi-сессия всегда egress через self._config.scraper_proxy_url (backconnect # curl_cffi-сессия всегда egress через self._config.scraper_proxy_url (backconnect
# mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости # mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости
# от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому # от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому

View file

@ -24,8 +24,9 @@ Studio roomsTotal:
Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention). Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention).
Strangler-копия `app.services.scrapers.yandex_realty` (#2133). Развязка от `app.*`: Strangler-копия `app.services.scrapers.yandex_realty` (#2133). Развязка от `app.*`:
- `app.core.config.settings` инжектируемый `ScraperConfig` (yandex_proxy_rotate_url, - `app.core.config.settings` инжектируемый `ScraperConfig` (scraper_proxy_url).
avito_proxy_rotate_url) `_rotate_ip()` ниже changeip-stub (#2616 шаг 2, ссылка снята вместе с мёртвой
mobileproxy-подпиской)
- `app.services.scraper_settings.get_scraper_delay` инжектируемый `delay_provider` - `app.services.scraper_settings.get_scraper_delay` инжектируемый `delay_provider`
- `app.services.scrapers.{base,browser_fetcher,price_brackets}` `scraper_kit.*` - `app.services.scrapers.{base,browser_fetcher,price_brackets}` `scraper_kit.*`
- `app.services.scrapers.house_type_normalizer` `scraper_kit.providers.yandex.shared` - `app.services.scrapers.house_type_normalizer` `scraper_kit.providers.yandex.shared`
@ -498,7 +499,10 @@ class YandexRealtyScraper(BaseScraper):
# #12 (oblast rollout): rgid города-цели SERP-запроса (gate-API). None → # #12 (oblast rollout): rgid города-цели SERP-запроса (gate-API). None →
# ЕКБ-дефолт (_EKB_RGID). # ЕКБ-дефолт (_EKB_RGID).
self._rgid = city_rgid or _EKB_RGID self._rgid = city_rgid or _EKB_RGID
# _cffi_session retained only for _rotate_ip (changeip call). # _cffi_session: was created lazily inside _rotate_ip() for the changeip
# call — that call is a no-op stub since #2616 шаг 2, so this now stays
# None for the lifetime of the scraper. Attribute + __aexit__ cleanup kept
# (harmless, avoids touching the close-lifecycle contract for a doc-only fix).
self._cffi_session: _CurlCffiSession | None = None self._cffi_session: _CurlCffiSession | None = None
self._cookies: dict[str, str] = {} self._cookies: dict[str, str] = {}
# #2625: счётчики gate-API "структура извлечена" за время жизни этого # #2625: счётчики gate-API "структура извлечена" за время жизни этого
@ -586,20 +590,14 @@ class YandexRealtyScraper(BaseScraper):
return _CurlResponse(status_code=200, text=json_text) return _CurlResponse(status_code=200, text=json_text)
async def _rotate_ip(self) -> bool: async def _rotate_ip(self) -> bool:
"""Rotate mobile proxy IP via changeip URL. Returns True on success.""" """changeip mobileproxy-ротация снята (#2616 шаг 2) — YANDEX_PROXY_ROTATE_URL /
rotate_url = self._config.yandex_proxy_rotate_url or self._config.avito_proxy_rotate_url AVITO_PROXY_ROTATE_URL указывали на закрытые аккаунты (407/connection refused,
if not rotate_url: #2613), удалены из ScraperConfig. Метод оставлен (returns False) — вызывающие
return False (soft-ban retry в SERP-цикле) продолжают работать через тот же no-op путь, что
sep = "&" if "?" in rotate_url else "?" и раньше при незаданном rotate_url. Живая ротация ASOCKS proxy_rotation
try: (#2611), отдельный от SERP-цикла путь.
async with _CurlCffiSession(timeout=30) as rot: """
await rot.get(f"{rotate_url}{sep}format=json") return False
await asyncio.sleep(9)
logger.info("yandex proxy: IP rotated via changeip")
return True
except Exception:
logger.warning("yandex proxy: IP rotation failed", exc_info=True)
return False
def _build_url( def _build_url(
self, self,