fix(tradein/scrapers): убрать оставшиеся обходы пула прокси (#2830) #2833

Merged
bot-backend merged 1 commit from fix/2830-remaining-pool-bypass into main 2026-08-12 10:53:37 +00:00
6 changed files with 520 additions and 141 deletions

View file

@ -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(),

View file

@ -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",

View file

@ -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

View 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)]

View file

@ -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")

View file

@ -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()