diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 277981f5..b717267c 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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. diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1350b38d..e8d888ca 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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 = всегда diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 9d68d457..51c01e94 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -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|" diff --git a/tradein-mvp/backend/app/services/proxy_rotation.py b/tradein-mvp/backend/app/services/proxy_rotation.py index ad711655..016ef45a 100644 --- a/tradein-mvp/backend/app/services/proxy_rotation.py +++ b/tradein-mvp/backend/app/services/proxy_rotation.py @@ -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 ` ушёл бы на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно, реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 927d0783..fae6613d 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -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 diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 55336a29..1c38ee2d 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -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() diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 84e58798..1e16d3e0 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py index 7d6e038c..91442669 100644 --- a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py @@ -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, ) diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ecfd4578..6e472637 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -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 ─────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py index fc51ffd6..bb924d09 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_group_c_backfill_kit_parity.py @@ -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 ── diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py index 0f211154..18b796cb 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py @@ -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, ) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index d05198ec..5dae8cb1 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -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, diff --git a/tradein-mvp/backend/tests/test_scraper_proxy.py b/tradein-mvp/backend/tests/test_scraper_proxy.py index eb05c6aa..165539d8 100644 --- a/tradein-mvp/backend/tests/test_scraper_proxy.py +++ b/tradein-mvp/backend/tests/test_scraper_proxy.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_sweep_imv_phase.py b/tradein-mvp/backend/tests/test_sweep_imv_phase.py index 3e7417ea..38c56305 100644 --- a/tradein-mvp/backend/tests/test_sweep_imv_phase.py +++ b/tradein-mvp/backend/tests/test_sweep_imv_phase.py @@ -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, ) diff --git a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py index 72e3d07c..f5e413fc 100644 --- a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py +++ b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py @@ -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, ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index 1e34b15d..21d6f605 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -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, ) # --------------------------------------------------------------------------- diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 7fcec8f4..bc97d927 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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 diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 6c5d5642..39f5eacf 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -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" diff --git a/tradein-mvp/browser/test_server_no_proxy_refusal.py b/tradein-mvp/browser/test_server_no_proxy_refusal.py index 2d14aefb..b74321ec 100644 --- a/tradein-mvp/browser/test_server_no_proxy_refusal.py +++ b/tradein-mvp/browser/test_server_no_proxy_refusal.py @@ -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) — существующая семантика diff --git a/tradein-mvp/browser/test_server_proxy_override.py b/tradein-mvp/browser/test_server_proxy_override.py index 301402ab..2ec4fdd4 100644 --- a/tradein-mvp/browser/test_server_proxy_override.py +++ b/tradein-mvp/browser/test_server_proxy_override.py @@ -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: diff --git a/tradein-mvp/frontend/src/components/scrapers/ProviderProxySection.tsx b/tradein-mvp/frontend/src/components/scrapers/ProviderProxySection.tsx index 8666bb3d..f6005d16 100644 --- a/tradein-mvp/frontend/src/components/scrapers/ProviderProxySection.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/ProviderProxySection.tsx @@ -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) { Текущий IP: {provider.current_ip ? ( - - {provider.current_ip} - + {provider.current_ip} ) : ( )} -
- Режим: - - {provider.rotate_supported ? "rotate-поддержка" : "статический"} - -
- {provider.rotate_supported && ( -
- - - {rotateMut.isSuccess && ( -
- {rotateMut.data.ok ? ( - <> - Новый IP: {rotateMut.data.new_ip ?? "обновлён"} - - ) : ( - - {rotateMut.data.reason ?? "ошибка ротации"} - - )} -
- )} - - {rotateMut.isError && ( -
- Ошибка: {rotateMut.error.message} -
- )} -
- )} - - {!provider.rotate_supported && ( -

- Ротация IP не поддерживается для этого провайдера. -

- )} +

+ Ручная ротация IP для этого провайдера недоступна — резидентный прокси + ротируется через ASOCKS-пул (см. страницу «Прокси»). +

); } diff --git a/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx b/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx index 9b5c52dd..818b3ce7 100644 --- a/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx @@ -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({ - mutationFn: () => - apiFetch( - `/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) { {proxyStr} - - {provider.current_ip ?? "—"} - - - - - {provider.rotate_supported ? "rotate" : "статический"} - - - - {provider.rotate_supported ? ( - - ) : ( - - — - - )} - {rotateMut.isSuccess && rotateMut.data.ok && ( - - {rotateMut.data.new_ip ?? "обновлён"} - - )} - {rotateMut.isSuccess && !rotateMut.data.ok && ( - - {rotateMut.data.reason ?? "ошибка"} - - )} - {rotateMut.isError && ( - - {rotateMut.error.message} - - )} + {provider.current_ip ?? "—"} ); @@ -236,9 +148,7 @@ export function SystemHealthSection() { flexWrap: "wrap", }} > -
+
-
+
Browser-сервис: - +
- {Object.entries(healthQ.data.browser.browsers).map( - ([name, ok]) => ( -
( +
+ - - {name}: - - -
- ), - )} + {name}: + + +
+ ))}
{/* Per-provider proxy table */} @@ -304,8 +208,6 @@ export function SystemHealthSection() { Провайдер Прокси (host:port) Текущий IP - Режим - Действие diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py index ec3e38d0..76f95ba3 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 42ff74ed..3150a503 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 7d8edd90..9fb3480c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -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-прокси). Поэтому diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py index 080beb15..9e99098b 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py @@ -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,