fix(tradein/scrapers): убрать оставшиеся обходы пула прокси (#2830) (#2833)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m13s
Deploy Trade-In / build-backend (push) Successful in 1m46s
Deploy Trade-In / deploy (push) Successful in 1m48s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m13s
Deploy Trade-In / build-backend (push) Successful in 1m46s
Deploy Trade-In / deploy (push) Successful in 1m48s
This commit is contained in:
parent
f2cbd76ae0
commit
e17687aed7
6 changed files with 520 additions and 141 deletions
|
|
@ -74,6 +74,7 @@ from app.services import proxy_rotation as proxy_rotation_svc
|
|||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
from app.services.geocoder import geocode, known_city_hint
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
from app.services.proxy_pool import clear_source_bans
|
||||
from app.services.scheduler import has_running_run
|
||||
from app.services.scraper_adapters import (
|
||||
|
|
@ -2370,18 +2371,33 @@ class ScraperHealthResponse(BaseModel):
|
|||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||
|
||||
|
||||
def _provider_proxy_url(source: str) -> str | None:
|
||||
"""Effective proxy URL для source (учитывает property-fallback в settings).
|
||||
def _provider_proxy_url(db: Session, source: str) -> str | None:
|
||||
"""Узел, который РЕАЛЬНО получит трафик этого источника (#2830).
|
||||
|
||||
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
|
||||
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая
|
||||
mobileproxy-подписка, #2613).
|
||||
Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная
|
||||
переменная для всех трёх источников. После #2825/#2831 egress выбирается из
|
||||
`scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть
|
||||
страница показывала один узел, а трафик шёл через другой — слепое пятно ровно
|
||||
того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел,
|
||||
забаненный и Avito, и Cian), только теперь на диагностической странице.
|
||||
|
||||
Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`,
|
||||
`*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт
|
||||
расходиться с трафиком.
|
||||
|
||||
Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only,
|
||||
lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не
|
||||
его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит
|
||||
выдавать ему чужой бан.
|
||||
"""
|
||||
return {
|
||||
"avito": settings.scraper_proxy_url,
|
||||
"cian": settings.cian_proxy_url,
|
||||
"yandex": settings.yandex_proxy_url,
|
||||
}.get(source)
|
||||
try:
|
||||
return resolve_proxy_url(db, source)
|
||||
except ProxyPoolExhaustedError:
|
||||
# Пул не пуст, но для source не осталось ни одного здорового небаненного узла.
|
||||
# resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть
|
||||
# страница покажет «—», а не статичный env-узел (зелёная строка на месте
|
||||
# отказа хуже пустой).
|
||||
return None
|
||||
|
||||
|
||||
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
||||
|
|
@ -2497,19 +2513,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None:
|
|||
|
||||
|
||||
@router.get("/scraper/health", response_model=ScraperHealthResponse)
|
||||
async def scraper_health() -> ScraperHealthResponse:
|
||||
async def scraper_health(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> ScraperHealthResponse:
|
||||
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
|
||||
|
||||
- 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
|
||||
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
|
||||
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
|
||||
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
|
||||
- providers: для avito/cian/yandex — узел, который пул отдаст ЭТОМУ источнику
|
||||
сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного
|
||||
здорового узла), 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с.
|
||||
"""
|
||||
proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES}
|
||||
proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES}
|
||||
|
||||
browser, *ips = await asyncio.gather(
|
||||
_probe_browser_health(),
|
||||
|
|
|
|||
|
|
@ -19,19 +19,40 @@ from dataclasses import dataclass, field
|
|||
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
|
||||
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
|
||||
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
|
||||
# RealScraperConfig — тот же read-only адаптер над settings, что и остальные
|
||||
# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url)
|
||||
# идентичным прежнему прямому импорту settings.
|
||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
||||
from app.services.scraper_adapters import (
|
||||
RealMatcherAdapter,
|
||||
RealProxyProvider,
|
||||
RealScraperConfig,
|
||||
)
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PoolCurlConfig(RealScraperConfig):
|
||||
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
|
||||
|
||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
|
||||
services.scraper.environment), а этот бэкфилл запускается ручкой
|
||||
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
|
||||
→ `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
|
||||
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
|
||||
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
|
||||
|
||||
Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути
|
||||
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
|
||||
"""
|
||||
|
||||
@property
|
||||
def use_proxy_pool_curl(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CianPriceHistoryResult:
|
||||
checked: int = 0
|
||||
|
|
@ -60,6 +81,11 @@ async def backfill_cian_price_history(
|
|||
result = CianPriceHistoryResult()
|
||||
t0 = time.time()
|
||||
delay = get_scraper_delay("cian") # default 5.0s
|
||||
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
|
||||
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
|
||||
# (mark_banned на CianBlockedError / mark_health / release).
|
||||
scraper_config = _PoolCurlConfig()
|
||||
proxy_provider = RealProxyProvider()
|
||||
|
||||
if listing_id is not None:
|
||||
rows = (
|
||||
|
|
@ -107,9 +133,27 @@ async def backfill_cian_price_history(
|
|||
url: str = row["source_url"]
|
||||
|
||||
try:
|
||||
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
|
||||
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
|
||||
enrichment = await fetch_detail(url, config=RealScraperConfig())
|
||||
# config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси
|
||||
# datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула
|
||||
# (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про
|
||||
# `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался.
|
||||
enrichment = await fetch_detail(
|
||||
url, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except NoProxyAvailableError as exc:
|
||||
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
||||
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
||||
# логом, который читается как «Циан нас блокирует».
|
||||
logger.error(
|
||||
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
||||
"на listing_id=%s (обработано %d из %d)",
|
||||
exc,
|
||||
lid,
|
||||
i,
|
||||
len(rows),
|
||||
)
|
||||
result.errors += 1
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
||||
|
|
|
|||
|
|
@ -414,6 +414,9 @@ async def backfill_newbuilding_enrichment(
|
|||
# уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
|
||||
# «включить пул»: реально пул задействуется, только если включён
|
||||
# config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
|
||||
# #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг
|
||||
# use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на
|
||||
# статичном cian_proxy_url, то есть на второй ноге той же цепочки.
|
||||
proxy_provider = RealProxyProvider()
|
||||
|
||||
result = NewbuildingEnrichBackfillResult()
|
||||
|
|
@ -491,7 +494,9 @@ async def backfill_newbuilding_enrichment(
|
|||
continue
|
||||
|
||||
try:
|
||||
resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config)
|
||||
resolved = await resolve_cian_zhk_url_via_search(
|
||||
nb_id, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except Exception as exc: # defensive — resolver already catches internally
|
||||
logger.warning(
|
||||
"zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc
|
||||
|
|
|
|||
340
tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
Normal file
340
tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси.
|
||||
|
||||
Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел,
|
||||
общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места
|
||||
продолжали брать egress именно оттуда:
|
||||
|
||||
1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history);
|
||||
2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный
|
||||
узел, пока трафик уже выбирался пулом (#2825/#2831);
|
||||
3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул
|
||||
только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`.
|
||||
|
||||
Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие
|
||||
kwarg'а в вызове. Красные на старом коде:
|
||||
|
||||
* (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не
|
||||
сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»:
|
||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`,
|
||||
где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`).
|
||||
* (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел
|
||||
параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
|
||||
выражения не имел вообще.
|
||||
* (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал
|
||||
`mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.cian_exceptions import CianBlockedError
|
||||
from scraper_kit.contracts import ProxyLease
|
||||
from scraper_kit.providers.cian import detail as cian_detail
|
||||
from scraper_kit.providers.cian import newbuilding as cian_newbuilding
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
|
||||
# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный
|
||||
# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1).
|
||||
_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None)
|
||||
|
||||
|
||||
class _SpyProvider:
|
||||
"""ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу."""
|
||||
|
||||
def __init__(self, lease: ProxyLease | None = _LEASE) -> None:
|
||||
self._lease = lease
|
||||
self.acquire_calls: list[str] = []
|
||||
self.mark_health_calls: list[tuple[int, bool]] = []
|
||||
self.mark_banned_calls: list[tuple[int, str]] = []
|
||||
self.release_calls: list[int] = []
|
||||
|
||||
def acquire(self, provider: str) -> ProxyLease | None:
|
||||
self.acquire_calls.append(provider)
|
||||
return self._lease
|
||||
|
||||
def release(self, lease: ProxyLease) -> None:
|
||||
self.release_calls.append(lease.id)
|
||||
|
||||
def mark_health(
|
||||
self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None
|
||||
) -> None:
|
||||
self.mark_health_calls.append((lease.id, ok))
|
||||
|
||||
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||||
self.mark_banned_calls.append((lease.id, source))
|
||||
|
||||
|
||||
class _SpyPool:
|
||||
"""Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`.
|
||||
|
||||
Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового
|
||||
имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит.
|
||||
"""
|
||||
|
||||
def __init__(self, lease: Any = _LEASE) -> None:
|
||||
self._lease = lease
|
||||
self.acquire_calls: list[str] = []
|
||||
self.mark_health_calls: list[tuple[int, bool]] = []
|
||||
self.mark_banned_calls: list[tuple[int, str]] = []
|
||||
self.release_calls: list[int] = []
|
||||
|
||||
def acquire(self, db: Any, provider: str, **kw: Any) -> Any:
|
||||
self.acquire_calls.append(provider)
|
||||
return self._lease
|
||||
|
||||
def release(self, db: Any, lease_id: int) -> None:
|
||||
self.release_calls.append(lease_id)
|
||||
|
||||
def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None:
|
||||
self.mark_health_calls.append((lease_id, ok))
|
||||
|
||||
def mark_banned(self, db: Any, lease_id: int, *, source: str) -> None:
|
||||
self.mark_banned_calls.append((lease_id, source))
|
||||
|
||||
|
||||
def _session_returning(status_code: int, text: str = "") -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text))
|
||||
session.close = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
# ── 1. cian_price_history: узел из пула + вердикт обратно ────────────────────
|
||||
|
||||
|
||||
def _price_history_db(n_listings: int) -> MagicMock:
|
||||
"""Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт)."""
|
||||
rows = [
|
||||
{"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"}
|
||||
for i in range(n_listings)
|
||||
]
|
||||
listings_result = MagicMock()
|
||||
listings_result.mappings.return_value.all.return_value = rows
|
||||
db = MagicMock()
|
||||
db.execute.return_value = listings_result
|
||||
return db
|
||||
|
||||
|
||||
async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any:
|
||||
from app.services import scraper_adapters
|
||||
from app.services.cian_price_history import backfill_cian_price_history
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
|
||||
),
|
||||
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
|
||||
patch.object(scraper_adapters, "_proxy_pool", pool),
|
||||
patch.object(scraper_adapters, "_SessionLocal", MagicMock()),
|
||||
):
|
||||
return await backfill_cian_price_history(_price_history_db(n), batch_size=n)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_takes_pool_node_despite_flag_off() -> None:
|
||||
"""Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend).
|
||||
|
||||
Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного
|
||||
pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
assert (
|
||||
settings.use_proxy_pool_curl is False
|
||||
), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
|
||||
pool = _SpyPool()
|
||||
await _run_price_history(pool, status_code=200)
|
||||
assert pool.acquire_calls == ["cian"]
|
||||
assert pool.release_calls == [9] # lease не течёт
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_403_bans_the_node_for_cian() -> None:
|
||||
"""403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""
|
||||
pool = _SpyPool()
|
||||
result = await _run_price_history(pool, status_code=403)
|
||||
assert pool.mark_banned_calls == [(9, "cian")]
|
||||
assert pool.mark_health_calls == [(9, False)]
|
||||
assert result.errors == 1 # прогон честен: отказ посчитан
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_404_does_not_ban_the_node() -> None:
|
||||
"""Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805)."""
|
||||
pool = _SpyPool()
|
||||
await _run_price_history(pool, status_code=404)
|
||||
assert pool.mark_banned_calls == []
|
||||
assert pool.mark_health_calls == [(9, True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_history_aborts_batch_when_pool_empty() -> None:
|
||||
"""Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд."""
|
||||
from app.core.config import settings
|
||||
|
||||
pool = _SpyPool(lease=None)
|
||||
# Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1):
|
||||
# только в проде пустой пул = отказ, в dev это легитимный env/direct-путь.
|
||||
with patch.object(settings, "environment", "production"):
|
||||
result = await _run_price_history(pool, status_code=200, n=3)
|
||||
assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен"
|
||||
assert result.errors == 1
|
||||
assert result.checked == 3 # выбрали 3, но оборвались на первом
|
||||
|
||||
|
||||
def test_no_proxy_available_is_not_a_platform_ban() -> None:
|
||||
"""Наш отказ инфраструктуры не должен читаться как бан площадки (#2616)."""
|
||||
from scraper_kit.proxy_errors import ProxyBanError
|
||||
|
||||
assert not issubclass(NoProxyAvailableError, ProxyBanError)
|
||||
|
||||
|
||||
# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ──
|
||||
|
||||
|
||||
def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock:
|
||||
"""Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин."""
|
||||
|
||||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||
row = diag if "count(*)" in str(stmt).lower() else candidate
|
||||
res = MagicMock()
|
||||
res.mappings.return_value.fetchone.return_value = row
|
||||
return res
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
return db
|
||||
|
||||
|
||||
def _get_health(db: MagicMock) -> dict[str, Any]:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import admin as admin_module
|
||||
from app.core.db import get_db
|
||||
|
||||
def _fake_db() -> Any:
|
||||
yield db
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||||
app.dependency_overrides[get_db] = _fake_db
|
||||
|
||||
async def _no_browser() -> Any:
|
||||
return admin_module.BrowserHealth(reachable=False, browsers={})
|
||||
|
||||
async def _no_ip(proxy_url: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.object(admin_module, "_probe_browser_health", _no_browser),
|
||||
patch.object(admin_module, "_probe_current_ip", _no_ip),
|
||||
# Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники.
|
||||
patch.object(
|
||||
admin_module.settings.__class__,
|
||||
"scraper_proxy_url",
|
||||
property(lambda _self: "http://u:p@static-env-node:10423"),
|
||||
),
|
||||
):
|
||||
r = TestClient(app).get("/api/v1/admin/scraper/health")
|
||||
assert r.status_code == 200
|
||||
return {p["source"]: p for p in r.json()["providers"]}
|
||||
|
||||
|
||||
def test_health_shows_pool_node_not_static_env() -> None:
|
||||
"""Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL.
|
||||
|
||||
Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то
|
||||
расхождение «панель показывает один узел, трафик идёт через другой», из-за
|
||||
которого инцидент 2026-08-10 месяц оставался невидимым.
|
||||
"""
|
||||
db = _health_db(
|
||||
candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"},
|
||||
diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0},
|
||||
)
|
||||
by_source = _get_health(db)
|
||||
assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"}
|
||||
assert by_source["cian"]["proxy_port"] == 10313
|
||||
|
||||
|
||||
def test_health_shows_nothing_when_pool_exhausted_for_source() -> None:
|
||||
"""Все узлы забанены для источника → «—», а не статичный env-узел.
|
||||
|
||||
Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть».
|
||||
"""
|
||||
db = _health_db(
|
||||
candidate=None,
|
||||
diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0},
|
||||
)
|
||||
by_source = _get_health(db)
|
||||
assert by_source["cian"]["proxy_host"] is None
|
||||
assert by_source["cian"]["proxy_port"] is None
|
||||
|
||||
|
||||
# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ─────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeConfig:
|
||||
use_proxy_pool_curl: bool = True
|
||||
cian_proxy_url: str | None = "http://static-env-node:10423"
|
||||
environment: str = "production"
|
||||
|
||||
|
||||
async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any:
|
||||
with patch.object(
|
||||
cian_newbuilding,
|
||||
"build_curl_cffi_session",
|
||||
return_value=_session_returning(status_code, html),
|
||||
):
|
||||
return await cian_newbuilding.resolve_cian_zhk_url_via_search(
|
||||
48853, config=_FakeConfig(), proxy_provider=spy
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_uses_pool_node() -> None:
|
||||
"""Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url."""
|
||||
spy = _SpyProvider()
|
||||
html = '<h1 data-name="Title"><a href="https://zhk-parkovyy-kvartal-ekb-i.cian.ru">ЖК</a></h1>'
|
||||
assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
||||
assert spy.acquire_calls == ["cian"]
|
||||
assert spy.mark_health_calls == [(9, True)]
|
||||
assert spy.release_calls == [9]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_403_reaches_the_pool() -> None:
|
||||
"""Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче."""
|
||||
spy = _SpyProvider()
|
||||
with pytest.raises(CianBlockedError):
|
||||
await _resolve(403, spy)
|
||||
assert spy.mark_banned_calls == [(9, "cian")]
|
||||
assert spy.mark_health_calls == [(9, False)]
|
||||
assert spy.release_calls == [9]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None:
|
||||
"""404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем."""
|
||||
spy = _SpyProvider()
|
||||
assert await _resolve(404, spy) is None
|
||||
assert spy.mark_banned_calls == []
|
||||
assert spy.mark_health_calls == [(9, True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None:
|
||||
"""200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел."""
|
||||
spy = _SpyProvider()
|
||||
assert await _resolve(200, spy, "<html><body>ничего</body></html>") is None
|
||||
assert spy.mark_banned_calls == []
|
||||
assert spy.mark_health_calls == [(9, True)]
|
||||
|
|
@ -156,7 +156,8 @@ def test_health_assembles_browser_and_providers(client: TestClient) -> 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]),
|
||||
# #2830: узел резолвится из пула по (db, source), а не из статичного env
|
||||
patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]),
|
||||
patch.object(admin_module.settings, "scraper_fetch_mode", "browser"),
|
||||
):
|
||||
r = client.get("/api/v1/admin/scraper/health")
|
||||
|
|
@ -207,7 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None:
|
|||
with (
|
||||
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_proxy_url", lambda _db, _s: None),
|
||||
):
|
||||
r = client.get("/api/v1/admin/scraper/health")
|
||||
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ from dataclasses import dataclass, field
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
from scraper_kit.cian_exceptions import CianBlockedError
|
||||
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
||||
from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
|
||||
from scraper_kit.providers._proxy import curl_proxy_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
||||
|
|
@ -801,70 +803,11 @@ def save_newbuilding_enrichment(
|
|||
)
|
||||
|
||||
|
||||
async def resolve_cian_zhk_url(
|
||||
cian_internal_house_id: int,
|
||||
*,
|
||||
config: ScraperConfig | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve canonical ZHK slug URL from a Cian internal house ID (LEGACY — BROKEN).
|
||||
|
||||
.. deprecated::
|
||||
The redirect path this relies on — ``https://cian.ru/zhk/<id>/`` → canonical
|
||||
slug — NO LONGER EXISTS. Cian now serves HTTP **404** for ``/zhk/<id>/``
|
||||
(verified 8/8 on prod, #972), so this returns None for every real id. Use
|
||||
:func:`resolve_cian_zhk_url_via_search` instead: it fetches the cat.php
|
||||
newbuilding-SERP and extracts the canonical ``zhk-*.cian.ru`` slug from its
|
||||
markup (the WORKING path). Kept only for backward-compat / reference; do not
|
||||
wire new callers to it.
|
||||
|
||||
Args:
|
||||
cian_internal_house_id: Cian's numeric ЖК identifier.
|
||||
session: optional shared curl_cffi AsyncSession (caller owns lifecycle).
|
||||
|
||||
Returns:
|
||||
Canonical ZHK URL string, or None if request failed / redirect not followed.
|
||||
|
||||
Note:
|
||||
This function makes a real HTTP request — do NOT call it without rate limiting.
|
||||
Caller must enforce per-request sleep matching scraper_settings 'cian' delay.
|
||||
"""
|
||||
close_session = False
|
||||
if session is None:
|
||||
# Mobile proxy wiring (#806 follow-up): resolve ЖК URL через мобильный прокси.
|
||||
_proxy_url = config.cian_proxy_url if config is not None else None
|
||||
session = build_curl_cffi_session(
|
||||
proxy_url=_proxy_url,
|
||||
timeout=15.0,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
},
|
||||
)
|
||||
close_session = True
|
||||
|
||||
fallback_url = f"https://cian.ru/zhk/{cian_internal_house_id}/"
|
||||
try:
|
||||
resp = await session.get(fallback_url, allow_redirects=True)
|
||||
final_url = str(resp.url)
|
||||
if final_url and final_url != fallback_url:
|
||||
logger.debug("resolve_cian_zhk_url id=%s → %s", cian_internal_house_id, final_url)
|
||||
return final_url
|
||||
# Redirect not followed or same URL — return fallback as canonical
|
||||
if resp.status_code == 200:
|
||||
return fallback_url
|
||||
logger.warning(
|
||||
"resolve_cian_zhk_url id=%s: HTTP %d, no usable URL",
|
||||
cian_internal_house_id,
|
||||
resp.status_code,
|
||||
)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("resolve_cian_zhk_url id=%s failed: %s", cian_internal_house_id, exc)
|
||||
return None
|
||||
finally:
|
||||
if close_session:
|
||||
await session.close()
|
||||
# Legacy `resolve_cian_zhk_url` (nb_id → https://cian.ru/zhk/<id>/ → редирект на слаг)
|
||||
# удалён в #2830: путь редиректа не существует с #972 (Циан отдаёт 404, проверено 8/8 на
|
||||
# проде), вызывающих у функции не было ни одного, а собственную curl-сессию она строила
|
||||
# на статичном `config.cian_proxy_url` — то есть мимо пула. Чинить egress мёртвого пути
|
||||
# незачем; удалить честнее. Рабочий резолвер — `resolve_cian_zhk_url_via_search` ниже.
|
||||
|
||||
|
||||
def _extract_zhk_url_from_serp(html: str) -> str | None:
|
||||
|
|
@ -897,59 +840,19 @@ def _extract_zhk_url_from_serp(html: str) -> str | None:
|
|||
return match.group(0) if match else None
|
||||
|
||||
|
||||
async def resolve_cian_zhk_url_via_search(
|
||||
nb_id: int,
|
||||
*,
|
||||
config: ScraperConfig | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve the canonical ЖК-slug URL for a Cian newbuilding id (the WORKING path).
|
||||
async def _fetch_zhk_slug(session: AsyncSession, nb_id: int) -> str | None:
|
||||
"""Один GET cat.php-SERP → канонический zhk-слаг. None на любом мягком отказе.
|
||||
|
||||
Fetches the cat.php newbuilding-SERP for a single ``newobject[0]=<nb_id>`` and
|
||||
extracts the canonical ``https://zhk-<slug>.cian.ru`` URL from its markup. This
|
||||
replaces the legacy :func:`resolve_cian_zhk_url`, whose ``/zhk/<id>/`` redirect path
|
||||
now 404s (verified on prod, #972). The returned slug URL is exactly what
|
||||
:func:`fetch_newbuilding` parses, so the enrichment chain is
|
||||
``nb_id → cat.php SERP → zhk-slug-url → fetch_newbuilding → enrich``.
|
||||
|
||||
Verified live (HTTP 200, slug extracted):
|
||||
nb 48853 → https://zhk-parkovyy-kvartal-ekb-i.cian.ru (ЖК «Парковый квартал»)
|
||||
nb 102791 → https://zhk-izumrudnyy-bor-ekb-i.cian.ru
|
||||
nb 24991 → https://zhk-baltym-park-ekb-i.cian.ru
|
||||
|
||||
Args:
|
||||
nb_id: Cian newbuilding id (``house_sources.ext_id`` for cian houses).
|
||||
session: optional shared curl_cffi AsyncSession (caller owns lifecycle). When
|
||||
None, an own session is created using the same mobile-proxy wiring as the
|
||||
rest of the Cian scrapers (``config.cian_proxy_url`` when set, else direct).
|
||||
|
||||
Returns:
|
||||
The canonical ЖК-slug URL string, or None on non-200 / empty SERP / no match /
|
||||
request failure (each logs a warning).
|
||||
|
||||
Note:
|
||||
Makes ONE real HTTP request and does NOT sleep — the CALLER enforces the
|
||||
anti-bot delay (matching scraper_settings 'cian'). At scale this needs the
|
||||
mobile proxy; low-volume direct fetches work for the bounded proof.
|
||||
Raises:
|
||||
CianBlockedError: HTTP 403 — WAF Циана отбил узел, с которого мы пришли
|
||||
(#2700). Мягкие отказы (404, пустой SERP, дрейф вёрстки) остаются None:
|
||||
наказывать за них здоровый узел нельзя.
|
||||
"""
|
||||
close_session = False
|
||||
if session is None:
|
||||
# Mobile proxy wiring (#806): Cian блокирует datacenter-IP. proxy=None → прямое
|
||||
# подключение (dev / proxy-down fallback — single fetches survive direct).
|
||||
_proxy_url = config.cian_proxy_url if config is not None else None
|
||||
session = build_curl_cffi_session(
|
||||
proxy_url=_proxy_url,
|
||||
timeout=30.0,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
},
|
||||
)
|
||||
close_session = True
|
||||
|
||||
serp_url = _CATPH_NEWBUILDING_SERP.format(nb_id=nb_id)
|
||||
try:
|
||||
resp = await session.get(serp_url, allow_redirects=True)
|
||||
if resp.status_code == 403:
|
||||
raise CianBlockedError(f"Cian cat.php nb_id={nb_id} → HTTP 403 (WAF-блок узла)")
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"resolve_cian_zhk_url_via_search nb_id=%s: cat.php → HTTP %d",
|
||||
|
|
@ -967,9 +870,75 @@ async def resolve_cian_zhk_url_via_search(
|
|||
return None
|
||||
logger.info("resolve_cian_zhk_url_via_search nb_id=%s → %s", nb_id, zhk_url)
|
||||
return zhk_url
|
||||
except CianBlockedError:
|
||||
# НЕ гасим: снаружи это `with curl_proxy_url(...)`, и только оттуда бан доходит
|
||||
# до пула (mark_banned на пару «узел × cian»). Проглоченный 403 здесь означал бы
|
||||
# mark_health(ok=True) на отбитый узел — механика #2700/#2821.
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("resolve_cian_zhk_url_via_search nb_id=%s failed: %s", nb_id, exc)
|
||||
return None
|
||||
finally:
|
||||
if close_session:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def resolve_cian_zhk_url_via_search(
|
||||
nb_id: int,
|
||||
*,
|
||||
config: ScraperConfig | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve the canonical ЖК-slug URL for a Cian newbuilding id (the WORKING path).
|
||||
|
||||
Fetches the cat.php newbuilding-SERP for a single ``newobject[0]=<nb_id>`` and
|
||||
extracts the canonical ``https://zhk-<slug>.cian.ru`` URL from its markup (the
|
||||
legacy ``/zhk/<id>/`` redirect resolver 404s since #972 and is gone, #2830). The
|
||||
returned slug URL is exactly what :func:`fetch_newbuilding` parses, so the
|
||||
enrichment chain is ``nb_id → cat.php SERP → zhk-slug-url → fetch_newbuilding``.
|
||||
|
||||
Verified live (HTTP 200, slug extracted):
|
||||
nb 48853 → https://zhk-parkovyy-kvartal-ekb-i.cian.ru (ЖК «Парковый квартал»)
|
||||
nb 102791 → https://zhk-izumrudnyy-bor-ekb-i.cian.ru
|
||||
nb 24991 → https://zhk-baltym-park-ekb-i.cian.ru
|
||||
|
||||
Args:
|
||||
nb_id: Cian newbuilding id (``house_sources.ext_id`` for cian houses).
|
||||
session: optional shared curl_cffi AsyncSession (caller owns lifecycle; прокси
|
||||
уже применён вызывающим — пул тут не трогаем).
|
||||
proxy_provider: пул прокси (#2830). Без него собственная сессия строилась на
|
||||
статичном ``config.cian_proxy_url`` — мимо ``scrape_proxy_source_bans``,
|
||||
то есть вторая нога обогащения ЖК ходила ровно тем путём, который #2767
|
||||
починил у первой (``fetch_newbuilding``).
|
||||
|
||||
Returns:
|
||||
The canonical ЖК-slug URL string, or None on non-200 / empty SERP / no match /
|
||||
request failure (each logs a warning).
|
||||
|
||||
Raises:
|
||||
CianBlockedError: HTTP 403 на own-session-пути — узел снимается с выдачи Циану
|
||||
через ``curl_proxy_url``. Вызывающий (``newbuilding_enrich_backfill``) уже
|
||||
считает исключение в ``failed_resolve``.
|
||||
|
||||
Note:
|
||||
Makes ONE real HTTP request and does NOT sleep — the CALLER enforces the
|
||||
anti-bot delay (matching scraper_settings 'cian').
|
||||
"""
|
||||
if session is not None:
|
||||
return await _fetch_zhk_slug(session, nb_id)
|
||||
|
||||
# Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
||||
# cian_proxy_url; пусто → прямое подключение (dev/no-op).
|
||||
# curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе.
|
||||
_env = config.cian_proxy_url if config is not None else None
|
||||
with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
|
||||
own_session = build_curl_cffi_session(
|
||||
proxy_url=_proxy_url,
|
||||
timeout=30.0,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||
},
|
||||
)
|
||||
try:
|
||||
return await _fetch_zhk_slug(own_session, nb_id)
|
||||
finally:
|
||||
await own_session.close()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue