All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 3m10s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 2m4s
208 lines
8.9 KiB
Python
208 lines
8.9 KiB
Python
"""#2700: 403 Циана перестаёт умирать внутри провайдера — узел снимается, прогон честен.
|
||
|
||
Живая различающая проба на проде 2026-08-10 (один и тот же detail-URL, один и тот же
|
||
код, менялся ТОЛЬКО прокси-узел):
|
||
|
||
узел 1 (asocks-residential-1, 46.8.110.92) → HTTP 403, 21 564 б, `cian_waf_block`
|
||
узел 9 (asocks-mobile-1, 5.227.16.0) → HTTP 200, 617 352 б, state ok
|
||
узел 10 (asocks-mobile-2, 95.104.183.29) → HTTP 200, 617 355 б, state ok
|
||
узел 11 (asocks-mobile-3, 95.55.49.98) → HTTP 200, 617 407 б, state ok
|
||
|
||
То есть отбита была ПАРА «узел × Циан», а не площадка (и не наши заголовки: те же 200
|
||
пришли без единой куки — detail-страница авторизации не требует). Пятнадцать суток
|
||
подряд это выглядело как «Циан нас забанил» ровно потому, что 403 гасился в
|
||
`return None`: пул получал `mark_health(ok=True)` на отбитый узел и продолжал выдавать
|
||
его Циану, а прогон писал `detail_failed=50` при `errors_count=0` и статусе `done`.
|
||
|
||
Тесты красные на старом коде:
|
||
* `fetch_detail` возвращал None и НЕ звал `mark_banned` → узел оставался в выдаче;
|
||
* `mark_done` писал `status='done'` прогону, у которого отказали все 50 попыток.
|
||
"""
|
||
|
||
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.orchestration import runs as kit_runs
|
||
from scraper_kit.providers.cian import detail as cian_detail
|
||
from scraper_kit.proxy_errors import ProxyBanError
|
||
|
||
from app.services import scrape_runs as app_runs
|
||
|
||
_MODULES = {"kit": kit_runs, "app": app_runs}
|
||
_LEASE = ProxyLease(id=1, url="http://user:pass@node-1:10423", kind="http", rotate_url=None)
|
||
|
||
|
||
@dataclass
|
||
class _FakeConfig:
|
||
use_proxy_pool_curl: bool = True
|
||
cian_proxy_url: str | None = None
|
||
environment: str = "production"
|
||
|
||
|
||
class _SpyProvider:
|
||
"""ProxyProvider-заглушка (тот же контракт, что в test_proxy_pool_curl_paths)."""
|
||
|
||
def __init__(self) -> None:
|
||
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:
|
||
return _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))
|
||
|
||
|
||
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
|
||
|
||
|
||
async def _fetch(status_code: int, spy: _SpyProvider) -> Any:
|
||
with patch.object(
|
||
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
|
||
):
|
||
return await cian_detail.fetch_detail(
|
||
"https://ekb.cian.ru/sale/flat/332775238/",
|
||
config=_FakeConfig(),
|
||
proxy_provider=spy,
|
||
)
|
||
|
||
|
||
# ── 1. 403 доходит до пула ────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_403_bans_the_node_for_cian_only() -> None:
|
||
"""Красный на старом коде: было `return None`, бана узла не происходило.
|
||
|
||
Проверяется ПОВЕДЕНИЕ пула (`mark_banned` на паре «узел × cian»), а не наличие
|
||
нового имени в коде.
|
||
"""
|
||
spy = _SpyProvider()
|
||
with pytest.raises(CianBlockedError):
|
||
await _fetch(403, spy)
|
||
assert spy.mark_banned_calls == [(1, "cian")]
|
||
assert spy.mark_health_calls == [(1, False)]
|
||
assert spy.release_calls == [1] # lease не течёт даже на бане
|
||
|
||
|
||
def test_blocked_error_is_recognised_by_generic_proxy_layer() -> None:
|
||
"""Generic curl-слой узнаёт бан по `ProxyBanError`, не зная про Циан."""
|
||
assert issubclass(CianBlockedError, ProxyBanError)
|
||
|
||
|
||
# ── 2. Не-бан остаётся не-баном ───────────────────────────────────────────────
|
||
|
||
|
||
async def test_404_does_not_ban_the_node() -> None:
|
||
"""Снятое объявление — не бан: наказывать за него здоровый узел нельзя."""
|
||
spy = _SpyProvider()
|
||
assert await _fetch(404, spy) is None
|
||
assert spy.mark_banned_calls == []
|
||
assert spy.mark_health_calls == [(1, True)]
|
||
|
||
|
||
# ── 3. Прогон с полностью отказавшей фазой перестаёт быть 'done' ──────────────
|
||
|
||
|
||
def _capture_status(mod: Any, counters: dict[str, int]) -> list[str]:
|
||
"""Статусы всех UPDATE'ов, которые сделал mark_done на фейковой сессии.
|
||
|
||
Читаем СТАТУС В SQL (как в test_2625_run_that_did_nothing), а не имя вызванной
|
||
функции: тест обязан краснеть на поведении финализатора.
|
||
"""
|
||
statuses: list[str] = []
|
||
|
||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||
sql = str(stmt)
|
||
for status in ("done", "failed", "banned"):
|
||
if f"status = '{status}'" in sql:
|
||
statuses.append(status)
|
||
return MagicMock()
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = _execute
|
||
with patch.object(mod, "sentry_sdk", MagicMock()):
|
||
mod.mark_done(db, 3258, dict(counters))
|
||
return statuses
|
||
|
||
|
||
# Реальные counters с прода, не выдуманные.
|
||
# Прогон 3258 (cian_city_sweep, 06.08): 50 из 50 detail'ов отказали, статус 'done'.
|
||
PROD_3258_ALL_DETAIL_FAILED = {
|
||
"anchors_done": 5,
|
||
"anchors_total": 5,
|
||
"errors_count": 0,
|
||
"lots_fetched": 1680,
|
||
"lots_inserted": 59,
|
||
"lots_updated": 626,
|
||
"detail_attempted": 50,
|
||
"detail_failed": 50,
|
||
"detail_enriched": 0,
|
||
"houses_attempted": 40,
|
||
"houses_failed": 40,
|
||
"houses_enriched": 0,
|
||
}
|
||
# Прогон 3597 (cian_city_sweep, 10.08, уже после того как 403 ушёл): 10 из 11 удачны.
|
||
PROD_3597_MOSTLY_OK = {
|
||
**PROD_3258_ALL_DETAIL_FAILED,
|
||
"errors_count": 1,
|
||
"detail_attempted": 11,
|
||
"detail_failed": 1,
|
||
"detail_enriched": 10,
|
||
"houses_attempted": 0,
|
||
"houses_failed": 0,
|
||
}
|
||
# Одиночная попытка, и та неудачна — шум, а не диагноз: прогон остаётся 'done'.
|
||
SINGLE_ATTEMPT_FAILED = {
|
||
**PROD_3597_MOSTLY_OK,
|
||
"detail_attempted": 1,
|
||
"detail_failed": 1,
|
||
"detail_enriched": 0,
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize("name", list(_MODULES))
|
||
def test_phase_failed_100_percent_is_not_done(name: str) -> None:
|
||
"""Прод-прогон 3258: detail 50/50 отказ → 'failed'. Красный на старом коде."""
|
||
assert _capture_status(_MODULES[name], PROD_3258_ALL_DETAIL_FAILED) == ["failed"]
|
||
|
||
|
||
@pytest.mark.parametrize("name", list(_MODULES))
|
||
def test_partial_phase_failure_stays_done(name: str) -> None:
|
||
"""Прод-прогон 3597: 1 отказ из 11 → остаётся 'done' (частичный отказ — не отказ)."""
|
||
assert _capture_status(_MODULES[name], PROD_3597_MOSTLY_OK) == ["done"]
|
||
|
||
|
||
@pytest.mark.parametrize("name", list(_MODULES))
|
||
def test_single_failed_attempt_stays_done(name: str) -> None:
|
||
"""Порог _PHASE_MIN_ATTEMPTS: одна неудачная попытка прогон не роняет."""
|
||
assert _capture_status(_MODULES[name], SINGLE_ATTEMPT_FAILED) == ["done"]
|
||
|
||
|
||
@pytest.mark.parametrize("name", list(_MODULES))
|
||
def test_zero_attempts_stays_done(name: str) -> None:
|
||
"""Фаза не запускалась (0 попыток) — 0 == 0 не должно читаться как отказ."""
|
||
counters = {**PROD_3597_MOSTLY_OK, "detail_attempted": 0, "detail_failed": 0}
|
||
assert _capture_status(_MODULES[name], counters) == ["done"]
|