chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) #2650

Merged
bot-backend merged 1 commit from chore/tradein-remove-dead-proxy-vars into main 2026-08-05 09:35:55 +00:00
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):
@ -2219,33 +2220,23 @@ class ScraperHealthResponse(BaseModel):
providers: list[ProviderHealth]
class RotateIpResponse(BaseModel):
ok: bool
new_ip: str | None = None
reason: str | None = None
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
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 {
"avito": settings.avito_proxy_url,
"avito": settings.scraper_proxy_url,
"cian": settings.cian_proxy_url,
"yandex": settings.yandex_proxy_url,
}.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]:
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
if not proxy_url:
@ -2344,8 +2335,10 @@ async def scraper_health() -> ScraperHealthResponse:
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
- providers: для avito/cian/yandex proxy host/port, rotate_supported,
best-effort current_ip (параллельный пробинг через прокси на ipify).
- providers: для avito/cian/yandex proxy host/port, rotate_supported
(#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с.
"""
@ -2365,7 +2358,7 @@ async def scraper_health() -> ScraperHealthResponse:
source=source,
proxy_host=host,
proxy_port=port,
rotate_supported=bool(_provider_rotate_url(source)),
rotate_supported=False,
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) ──────────────────────────
@ -2896,10 +2849,10 @@ def patch_proxy(
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
#
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
# Раньше отдельно от /scraper/{source}/rotate-ip (env-прокси mobileproxy,
# changeip-ссылка) — тот эндпоинт удалён вместе с мёртвой подпиской (#2616 шаг 3).
# Этот эндпоинт — единственная живая ручная ротация, по proxy_id из пула
# scrape_proxies (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
# app.services.proxy_rotation.rotate_proxy.

View file

@ -651,45 +651,41 @@ class Settings(BaseSettings):
"www.domclick.ru",
}
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
# ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
# Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
# Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
# Пусто = прямое подключение (dev/staging без прокси).
#
# Приоритет ENV-переменных (precedence):
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
# property `scraper_proxy_url` реализует эту логику; используй его везде.
# #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
# YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
# на закрытые аккаунты (407/connection refused, проверено вживую #2613).
# SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
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
def scraper_proxy_url(self) -> str | None:
"""Единый прокси URL для всех scraper-сессий (Avito + Cian).
"""Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
fallback снят мёртвая mobileproxy-переменная).
"""
return self.scraper_proxy_url_env or self.avito_proxy_url
return self.scraper_proxy_url_env
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
# ENV: AVITO_PROXY_ROTATE_URL.
avito_proxy_rotate_url: str | None = None
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
# ротации на старте (Datadome ban recovery).
# ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
# Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
# (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
# (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
# _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
# `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
# 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
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
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_attempts: int = 3
@ -710,16 +706,12 @@ class Settings(BaseSettings):
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
avito_serp_ok_not_banned: bool = True
# ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
# за единственный egress → взаимные таймауты/баны при параллельных прогонах.
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
# 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.
# ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
# Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
# общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
# cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
# остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
# (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
# ENV: CIAN_PROXY_MAX_ROTATIONS.
cian_proxy_max_rotations: int = 4
@ -739,25 +731,21 @@ class Settings(BaseSettings):
@property
def cian_proxy_url(self) -> str | None:
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
return self.cian_proxy_url_env or self.scraper_proxy_url
"""Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
override снят свойство оставлено для scraper_kit.contracts.ScraperConfig
совместимости)."""
return self.scraper_proxy_url
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
# Если не задан — fallback на общий scraper_proxy_url (backward-compat).
# 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.
# ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
# Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
# yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
yandex_proxy_max_rotations: int = 4
@property
def yandex_proxy_url(self) -> str | None:
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
return self.yandex_proxy_url_env or self.scraper_proxy_url
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
return self.scraper_proxy_url
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
# (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")
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
# исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
# admin.rotate_proxy_ip) несла провайдерский API-ключ в query
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
# будущий query-секрет с распространённым именем параметра тоже покрыт).
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.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 не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
# когда-нибудь включат.
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
# от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
# открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
# admin.rotate_proxy_ip).
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
# завязано на конкретного провайдера — покрывает любой query-параметр из
# общеупотребимого набора секретных имён (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(
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"

View file

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

View file

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

View file

@ -173,7 +173,7 @@ async def run_avito_detail_backfill(
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
# аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()).
# аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()).
scraper = AvitoScraper(RealScraperConfig())
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_failed.assert_not_called()
# #2310 regression guard: kit fetch_detail silently drops the backconnect-
# on-403 retry (and kit AvitoScraper can't read avito_proxy_rotate_url at
# all) unless config=RealScraperConfig() is passed/injected at the call
# on-403 retry (and kit AvitoScraper can't read scraper_proxy_url at all)
# unless config=RealScraperConfig() is passed/injected at the call
# site — assert_called()/call_count alone wouldn't catch someone dropping
# that kwarg later (mirrors #2306's test_backfill_wave2.py:282-286 pattern).
_, 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",
use_proxy_pool_browser=use_pool,
scraper_fetch_mode="browser",
avito_proxy_rotate_url=None,
scraper_proxy_url=None,
)

View file

@ -1,9 +1,11 @@
"""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/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
@ -147,13 +149,11 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> None:
"cian": None,
"yandex": "http://u:p@host.y:10051",
}
rotate_urls = {"avito": "http://ch/changeip", "cian": None, "yandex": None}
with (
patch.object(admin_module, "_probe_browser_health", fake_browser_health),
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_rotate_url", lambda s: rotate_urls[s]),
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
):
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 by_source["avito"]["proxy_host"] == "host.a"
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"
# 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"]["current_ip"] is None
assert by_source["cian"]["rotate_supported"] is False
# yandex имеет proxy но нет rotate_url
assert by_source["yandex"]["proxy_host"] == "host.y"
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, "_probe_current_ip", fake_current_ip),
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")
@ -214,137 +214,6 @@ def test_health_browser_unreachable(client: TestClient) -> None:
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 ───────────────────────────────────────────────

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) ────────
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()
took none, reading settings.avito_proxy_rotate_url directly at call time inside
_rotate_ip). avito_detail_backfill.py now constructs AvitoScraper(RealScraperConfig())
this proves the injected config is what _rotate_ip actually reads."""
took none, reading settings.* directly at call time). avito_detail_backfill.py
now constructs AvitoScraper(RealScraperConfig()) this proves the injected
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())
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 ──

View file

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

View file

@ -83,15 +83,12 @@ def _config() -> SimpleNamespace:
scraper_fetch_mode="curl_cffi",
browser_http_endpoint="http://browser.test/fetch",
scraper_proxy_url=None,
avito_proxy_rotate_url=None,
avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
cian_proxy_rotate_url=None,
cian_proxy_max_rotations=0,
yandex_proxy_rotate_url=None,
yandex_proxy_max_rotations=0,
scraper_skip_seen_today=False,
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:
- scraper_proxy_url property: SCRAPER_PROXY_URL takes precedence over AVITO_PROXY_URL
- AVITO_PROXY_URL fallback works when SCRAPER_PROXY_URL is absent
- scraper_proxy_url property: reads SCRAPER_PROXY_URL only. Legacy per-provider
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)
- _avito_proxies() helper returns correct dict shape
- 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__."""
return SimpleNamespace(
scraper_proxy_url=scraper_proxy_url,
# Cian scrapers read settings.cian_proxy_url (property: CIAN_PROXY_URL or
# scraper_proxy_url fallback). Mirror scraper_proxy_url here so the existing
# cian proxy-wiring assertions keep passing (#923 follow-up).
# cian_proxy_url / yandex_proxy_url properties now mirror scraper_proxy_url
# 1:1 (#2616 шаг 2: per-provider CIAN_PROXY_URL/YANDEX_PROXY_URL override
# removed — dead mobileproxy accounts). Mirrored here for test compat.
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_cookies_file=yandex_cookies_file,
)
@ -62,44 +61,23 @@ def _mock_settings(
# pydantic-settings (requires DATABASE_URL) for pure unit tests of the rule.
def _resolve_scraper_proxy_url(
scraper_proxy_url_env: str | None, avito_proxy_url: str | None
) -> str | None:
"""Inline replica of Settings.scraper_proxy_url property logic."""
return scraper_proxy_url_env or avito_proxy_url
def _resolve_scraper_proxy_url(scraper_proxy_url_env: str | None) -> str | None:
"""Inline replica of Settings.scraper_proxy_url property logic (#2616 шаг 2:
legacy AVITO_PROXY_URL fallback removed SCRAPER_PROXY_URL is now the sole
source, projected as-is)."""
return scraper_proxy_url_env
def test_scraper_proxy_url_uses_scraper_env_when_set():
result = _resolve_scraper_proxy_url(
scraper_proxy_url_env="http://scraper-proxy:1234",
avito_proxy_url="http://avito-proxy:5678",
)
result = _resolve_scraper_proxy_url(scraper_proxy_url_env="http://scraper-proxy:1234")
assert result == "http://scraper-proxy:1234"
def test_scraper_proxy_url_falls_back_to_avito_proxy_url():
"""AVITO_PROXY_URL used when SCRAPER_PROXY_URL absent — zero env change on prod."""
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)
def test_scraper_proxy_url_none_when_absent():
result = _resolve_scraper_proxy_url(scraper_proxy_url_env=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 ───────────────────────────────────────────────
@ -121,22 +99,8 @@ def test_avito_proxies_returns_none_when_no_proxy():
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) ─────────────────────
# 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
# 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
@ -151,26 +115,17 @@ def _fresh_settings():
def test_settings_binds_scraper_proxy_url_env(monkeypatch):
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"
def test_settings_scraper_env_overrides_avito(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):
def test_settings_scraper_proxy_url_none_when_unset(monkeypatch):
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.setenv("AVITO_PROXY_URL", "http://avito-proxy:5678")
assert _fresh_settings().scraper_proxy_url == "http://avito-proxy:5678"
assert _fresh_settings().scraper_proxy_url is None
def test_settings_ignores_misnamed_scraper_proxy_url_env(monkeypatch):
"""Regression: the old wrong env name SCRAPER_PROXY_URL_ENV must NOT bind."""
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
monkeypatch.setenv("SCRAPER_PROXY_URL_ENV", "http://wrong-name:1111")
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 ─────────────────────────────────────────
# 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(
cian_proxy_url_env: str | None, scraper_proxy_url: str | None
) -> str | None:
"""Inline replica of Settings.cian_proxy_url property logic."""
return cian_proxy_url_env or scraper_proxy_url
def _resolve_cian_proxy_url(scraper_proxy_url: str | None) -> str | None:
"""Inline replica of Settings.cian_proxy_url property logic (post #2616 шаг 2)."""
return scraper_proxy_url
def test_cian_proxy_url_uses_cian_env_when_set():
"""CIAN_PROXY_URL takes precedence over shared scraper_proxy_url."""
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_mirrors_scraper_proxy_url():
assert _resolve_cian_proxy_url("http://shared-proxy:1111") == "http://shared-proxy:1111"
def test_cian_proxy_url_falls_back_to_scraper_proxy_url():
"""Falls back to shared scraper_proxy_url when CIAN_PROXY_URL is absent."""
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_absent():
assert _resolve_cian_proxy_url(None) is None
def test_cian_proxy_url_none_when_both_absent():
result = _resolve_cian_proxy_url(cian_proxy_url_env=None, scraper_proxy_url=None)
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")
def test_settings_cian_proxy_url_mirrors_scraper_proxy_url(monkeypatch):
"""cian_proxy_url tracks scraper_proxy_url 1:1 — no more independent CIAN_PROXY_URL."""
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://shared-proxy:1111")
monkeypatch.delenv("AVITO_PROXY_URL", raising=False)
s = _fresh_settings()
assert s.cian_proxy_url == "http://cian-proxy:2222"
assert s.scraper_proxy_url == "http://shared-proxy:1111"
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"
assert s.cian_proxy_url == "http://shared-proxy:1111"
assert s.cian_proxy_url == s.scraper_proxy_url

View file

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

View file

@ -20,8 +20,6 @@ from scraper_kit.providers.yandex.valuation import YandexValuationScraper
_KIT_CONFIG = SimpleNamespace(
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(
scraper_proxy_url=None,
yandex_proxy_rotate_url=None,
avito_proxy_rotate_url=None,
)
# ---------------------------------------------------------------------------

View file

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

View file

@ -2,7 +2,8 @@
Проверяет ядро per-provider модели:
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;
4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
два к ОДНОМУ сериализованы (один лок). Через _do_fetch с asyncio.Event и
@ -79,57 +80,21 @@ def test_provider_for_url_generic_fallback() -> None:
# ── _provider_proxy ──────────────────────────────────────────────────────────────
def test_provider_proxy_uses_browser_proxy_primary(monkeypatch: pytest.MonkeyPatch) -> None:
"""BROWSER_PROXY_* — primary источник для каждого поставщика."""
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://u:p@avito-proxy:8080")
monkeypatch.setenv("BROWSER_PROXY_CIAN", "http://u:p@cian-proxy:8080")
monkeypatch.setenv("BROWSER_PROXY_YANDEX", "http://u:p@yandex-proxy:8080")
assert server._provider_proxy("avito") == "http://u:p@avito-proxy:8080"
assert server._provider_proxy("cian") == "http://u:p@cian-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_shared_across_providers(monkeypatch: pytest.MonkeyPatch) -> None:
"""#2616 шаг 2: SCRAPER_PROXY_URL — единственный источник, общий для всех."""
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@shared-proxy:8080")
assert server._provider_proxy("avito") == "http://u:p@shared-proxy:8080"
assert server._provider_proxy("cian") == "http://u:p@shared-proxy:8080"
assert server._provider_proxy("yandex") == "http://u:p@shared-proxy:8080"
assert server._provider_proxy("generic") == "http://u:p@shared-proxy:8080"
def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной env → None (direct, как одиночный браузер без прокси)."""
for var in (
"BROWSER_PROXY_CIAN",
"CIAN_PROXY_URL",
):
monkeypatch.delenv(var, raising=False)
"""SCRAPER_PROXY_URL не задан → None (direct, как одиночный браузер без прокси)."""
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
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 ────────────────────────────────────────────────────────────
@ -158,8 +123,8 @@ def test_resolve_provider_uses_source_field() -> None:
def test_resolve_provider_domclick_source_to_generic() -> None:
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
'domklik' не валиден host-detect domclick.ru generic. Намеренно: в prod
нет выделенного BROWSER_PROXY_DOMCLICK.
'domklik' не валиден host-detect domclick.ru generic. Намеренно: 'domklik'
не входит в PROVIDERS (нет выделенного instance).
"""
assert (
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 сервера.
tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело
запроса, ни ЛЮБАЯ env-переменная не несли proxy, лениво запускал camoufox БЕЗ
proxy прямое подключение с IP сервера. Мертвые env-переменные (407/connection
refused, #2613) сегодня этого не триггерят (запуск падает раньше, на invalid-proxy
проверке camoufox) но как только шаг 2 issue #2616 их удалит, сценарий станет
реальным. Этот файл фиксирует контракт ЗАРАНЕЕ (`_no_live_proxy` + `IS_PROD` guard
в fetch_handler/fetch_json_handler/login_handler), до того как #2616 шаг 2 сделает
его наблюдаемым в проде.
запроса, ни SCRAPER_PROXY_URL не несли proxy, лениво запускал camoufox БЕЗ
proxy прямое подключение с IP сервера. #2616 шаг 2 удалил мёртвые per-provider
переменные (AVITO_PROXY_URL/BROWSER_PROXY_* и т.д., 407/connection refused,
#2613) — SCRAPER_PROXY_URL остался единственным источником. Этот файл фиксирует
контракт (`_no_live_proxy` + `IS_PROD` guard в
fetch_handler/fetch_json_handler/login_handler).
Проверяет:
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:
"""Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера.
Явно чистим все env-переменные, которые `_provider_proxy` умеет читать тест
не должен зависеть от того, что реально задано в shell/CI-раннере.
Явно чистим SCRAPER_PROXY_URL (единственная переменная, которую `_provider_proxy`
читает, #2616 шаг 2) — тест не должен зависеть от того, что реально задано в
shell/CI-раннере.
"""
monkeypatch.setattr(server, "_browsers", {})
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, "_launched_proxy", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
for var in (
"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)
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
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:
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
@ -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:
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]] = []
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"]
# 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:
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:
"""proxy_override=None (флаг off) + инстанс на env → НЕ релончим (golden-parity)."""
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"):
monkeypatch.setenv(var, "http://env:8080")
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
calls = _install_fake_launch(monkeypatch)
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:
"""env-launched инстанс + пришёл pool-proxy → relaunch (реальная смена)."""
for var in ("BROWSER_PROXY_AVITO", "AVITO_PROXY_URL"):
monkeypatch.setenv(var, "http://env:8080")
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
calls = _install_fake_launch(monkeypatch)
async def _run() -> None:

View file

@ -1,11 +1,12 @@
"use client";
import {
useScraperHealth,
useRotateIp,
} from "@/components/scrapers/ProxyHealthCard";
import { useScraperHealth } from "@/components/scrapers/ProxyHealthCard";
// ── 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 {
source: string;
@ -13,7 +14,6 @@ interface ProviderProxySectionProps {
export function ProviderProxySection({ source }: ProviderProxySectionProps) {
const healthQ = useScraperHealth();
const rotateMut = useRotateIp(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>
{provider.current_ip ? (
<code style={{ fontSize: "0.85rem" }}>
{provider.current_ip}
</code>
<code style={{ fontSize: "0.85rem" }}>{provider.current_ip}</code>
) : (
<span style={{ color: "var(--fg-tertiary, #73767e)" }}></span>
)}
</span>
</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>
{provider.rotate_supported && (
<div>
<button
type="button"
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>
)}
<p className="scraper-hint" style={{ margin: 0 }}>
Ручная ротация IP для этого провайдера недоступна резидентный прокси
ротируется через ASOCKS-пул (см. страницу «Прокси»).
</p>
</section>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
// ── Types ──────────────────────────────────────────────────────────────────
@ -9,6 +9,10 @@ export interface ProviderHealth {
source: string;
proxy_host: string | 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;
current_ip: string | null;
}
@ -24,12 +28,6 @@ export interface ScraperHealthResp {
providers: ProviderHealth[];
}
interface RotateIpResp {
ok: boolean;
new_ip: string | null;
reason: string | null;
}
// ── Hooks ──────────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────────
function FetchModeBadge({ mode }: { mode: string }) {
@ -71,9 +55,7 @@ function FetchModeBadge({ mode }: { mode: string }) {
background: isBrowser
? "var(--accent-soft, #dbeafe)"
: "var(--warn-soft, #fef3c7)",
color: isBrowser
? "var(--accent, #1d4ed8)"
: "var(--warn, #9a6700)",
color: isBrowser ? "var(--accent, #1d4ed8)" : "var(--warn, #9a6700)",
fontFamily: "ui-monospace, monospace",
}}
>
@ -94,9 +76,7 @@ function BrowserBadge({ reachable }: { reachable: boolean }) {
background: reachable
? "var(--success-soft, #dcfce7)"
: "var(--danger-soft, #fee2e2)",
color: reachable
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
color: reachable ? "var(--success, #0a7a3a)" : "var(--danger, #b3261e)",
}}
>
{reachable ? "доступен" : "недоступен"}
@ -109,7 +89,6 @@ interface ProviderRowProps {
}
function ProviderProxyRow({ provider }: ProviderRowProps) {
const rotateMut = useRotateIp(provider.source);
const proxyStr =
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>
</td>
<td>
<code style={{ fontSize: "0.8rem" }}>
{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>
)}
<code style={{ fontSize: "0.8rem" }}>{provider.current_ip ?? "—"}</code>
</td>
</tr>
);
@ -236,9 +148,7 @@ export function SystemHealthSection() {
flexWrap: "wrap",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontSize: "0.85rem",
@ -249,9 +159,7 @@ export function SystemHealthSection() {
</span>
<FetchModeBadge mode={healthQ.data.fetch_mode} />
</div>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontSize: "0.85rem",
@ -260,40 +168,36 @@ export function SystemHealthSection() {
>
Browser-сервис:
</span>
<BrowserBadge
reachable={healthQ.data.browser.reachable}
/>
<BrowserBadge reachable={healthQ.data.browser.reachable} />
</div>
{Object.entries(healthQ.data.browser.browsers).map(
([name, ok]) => (
<div
key={name}
style={{ display: "flex", alignItems: "center", gap: 4 }}
{Object.entries(healthQ.data.browser.browsers).map(([name, ok]) => (
<div
key={name}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{name}:
</span>
<span
role="img"
aria-label={ok ? "статус: доступен" : "статус: недоступен"}
style={{
width: 8,
height: 8,
borderRadius: "50%",
display: "inline-block",
background: ok
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
/>
</div>
),
)}
{name}:
</span>
<span
role="img"
aria-label={ok ? "статус: доступен" : "статус: недоступен"}
style={{
width: 8,
height: 8,
borderRadius: "50%",
display: "inline-block",
background: ok
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
/>
</div>
))}
</div>
{/* Per-provider proxy table */}
@ -304,8 +208,6 @@ export function SystemHealthSection() {
<th scope="col">Провайдер</th>
<th scope="col">Прокси (host:port)</th>
<th scope="col">Текущий IP</th>
<th scope="col">Режим</th>
<th scope="col">Действие</th>
</tr>
</thead>
<tbody>

View file

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

View file

@ -103,77 +103,25 @@ async def _rotate_proxy_ip(
rotations_done: int,
source: str = "avito",
) -> 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`
(avito / cian / yandex). Fallback-цепочка для rotate_url:
avito: avito_proxy_rotate_url
cian: cian_proxy_rotate_url avito_proxy_rotate_url
yandex: yandex_proxy_rotate_url avito_proxy_rotate_url
IP-ротация здесь физически невозможна без changeip-ссылки всегда False.
Аналог AvitoScraper._rotate_ip (см. providers/avito/serp.py) используется в
enrichment-фазах где нет доступа к экземпляру scraper'а. Единственный живой
прокси (SCRAPER_PROXY_URL, ASocks) не имеет per-request changeip его ротация
POST /api/v1/admin/proxies/{id}/rotate (app.services.proxy_rotation, #2611),
отдельный от sweep-оркестрации путь.
Аналог AvitoScraper._rotate_ip, но на уровне pipeline используется в
enrichment-фазах где нет доступа к экземпляру scraper'а.
Returns True при успешной смене IP, False если rotate_url не задан или ошибка.
Логирует каждую ротацию (причина, порядковый номер, source, остаток лимита).
Оставлена как stub (не удалена целиком): `_try_rotate_within_budget` продолжает
считать бюджет ротаций точно так же, как раньше при мёртвом changeip (та же
False-ветка) вызывающие сохраняют abort-поведение без изменений, без
сетевого похода в никуда.
"""
if source == "cian":
rotate_url = config.cian_proxy_rotate_url or config.avito_proxy_rotate_url
max_rot = config.cian_proxy_max_rotations
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,
logger.debug(
"pipeline: IP rotation unavailable (changeip retired #2616) — "
"source=%s reason=%s rotation=#%d",
source,
reason,
rotations_done + 1,
@ -464,7 +412,8 @@ def _avito_proxies(config: ScraperConfig) -> dict[str, str] | None:
detail-страницы летят с datacenter-IP HTTP 429 весь run mark_banned.
Пусто (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
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
async def __aenter__(self) -> AvitoScraper:
# Проактивная ротация IP в начале sweep (#1731): apw-IP «протухает» по
# Datadome между суточными прогонами, поэтому первые якоря блокируются и
# реактивного бюджета max_rotations не хватает (3 подряд блока →
# 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
)
# Проактивная changeip-ротация в начале sweep (#1731) снята #2616 шаг 2 —
# changeip-ссылка (мёртвый mobileproxy-аккаунт) удалена из ScraperConfig,
# _rotate_ip() ниже теперь всегда no-op (см. её docstring).
if self._config.scraper_fetch_mode == "browser":
# Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider
# + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как
@ -442,24 +429,14 @@ class AvitoScraper(BaseScraper):
# ── Anti-block (#623) ─────────────────────────────────────────────────────
async def _rotate_ip(self) -> bool:
"""Сменить мобильный IP через changeip-ссылку mobileproxy (#623).
Дёргается напрямую (без прокси) это API провайдера, не Avito. Ждём ~9с:
мобильному модему нужно время поднять новый IP. Returns True при успехе.
"""changeip mobileproxy-ротация снята (#2616 шаг 2) — аккаунт закрыт
(407/connection refused, проверено вживую #2613), changeip-ссылка удалена
из ScraperConfig. Метод оставлен (returns False) вызывающие (proактивная
ротация 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
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
return False
async def _fetch_serp_html(self, url: str, page: int) -> str | None:
"""GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805).
@ -508,7 +485,9 @@ class AvitoScraper(BaseScraper):
исчерпаны AvitoRateLimitedError (graceful mark_banned, partial preserved).
"""
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
# Независимый бюджет коротких retry на transient sidecar-ошибки (timeout/
# crash) — НЕ тратит бюджет ротации. Каждая ветка ниже (ротация / transient)
@ -641,7 +620,9 @@ class AvitoScraper(BaseScraper):
AvitoBlockedError / AvitoRateLimitedError если бан не снят.
"""
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
# mproxy). Пересоздание сессии (_reset_cffi) даёт свежий exit-IP вне зависимости
# от наличия changeip-URL (тот относится к ДРУГОМУ, browser/auv-прокси). Поэтому

View file

@ -24,8 +24,9 @@ Studio roomsTotal:
Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention).
Strangler-копия `app.services.scrapers.yandex_realty` (#2133). Развязка от `app.*`:
- `app.core.config.settings` инжектируемый `ScraperConfig` (yandex_proxy_rotate_url,
avito_proxy_rotate_url)
- `app.core.config.settings` инжектируемый `ScraperConfig` (scraper_proxy_url).
`_rotate_ip()` ниже changeip-stub (#2616 шаг 2, ссылка снята вместе с мёртвой
mobileproxy-подпиской)
- `app.services.scraper_settings.get_scraper_delay` инжектируемый `delay_provider`
- `app.services.scrapers.{base,browser_fetcher,price_brackets}` `scraper_kit.*`
- `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 →
# ЕКБ-дефолт (_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._cookies: dict[str, str] = {}
# #2625: счётчики gate-API "структура извлечена" за время жизни этого
@ -586,20 +590,14 @@ class YandexRealtyScraper(BaseScraper):
return _CurlResponse(status_code=200, text=json_text)
async def _rotate_ip(self) -> bool:
"""Rotate mobile proxy IP via changeip URL. Returns True on success."""
rotate_url = self._config.yandex_proxy_rotate_url or self._config.avito_proxy_rotate_url
if not rotate_url:
return False
sep = "&" if "?" in rotate_url else "?"
try:
async with _CurlCffiSession(timeout=30) as rot:
await rot.get(f"{rotate_url}{sep}format=json")
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
"""changeip mobileproxy-ротация снята (#2616 шаг 2) — YANDEX_PROXY_ROTATE_URL /
AVITO_PROXY_ROTATE_URL указывали на закрытые аккаунты (407/connection refused,
#2613), удалены из ScraperConfig. Метод оставлен (returns False) — вызывающие
(soft-ban retry в SERP-цикле) продолжают работать через тот же no-op путь, что
и раньше при незаданном rotate_url. Живая ротация ASOCKS proxy_rotation
(#2611), отдельный от SERP-цикла путь.
"""
return False
def _build_url(
self,