fix(tradein/cian): cian_history_backfill идёт через пул прокси + стоп на пустом пуле (#3197, точка 1 из 3) #3382

Merged
bot-backend merged 2 commits from fix/3197-cian-history-backfill-proxy-pool into main 2026-09-05 22:13:09 +00:00
6 changed files with 333 additions and 5 deletions

View file

@ -161,6 +161,28 @@ async def _execute_cian_backfill(
# #3196: отказ detail-фетча теперь несёт диагноз (HTTP-статус последнего ответа
# сайдкара). В 'banned' переводим ТОЛЬКО прогон, который отказы видел и не
# обогатил НИЧЕГО, — частичный успех остаётся 'done', как и был.
if result.no_proxy_stop:
# #3197 (как #3288 у avito / #3283 у домклика): остановка из-за пустого пула —
# НЕ блок, поэтому и не mark_banned: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
counters["no_proxy_stop"] = 1
runs_mod.mark_failed(
db, run_id, "пул прокси пуст — к площадке не ходили (#3197)", counters
)
# INFO, как у соседей (avito_detail_backfill.py:1050, domclick:626): причина
# уже записана в mark_failed + counters.no_proxy_stop, а ERROR на финальной
# строке ставил в один разряд с падением задачи (logger.exception ниже).
logger.info(
"scheduler: cian_history_backfill run_id=%d СТОП (пул пуст) — "
"listings=%d/%d houses=%d/%d %.1fs",
run_id,
result.listings_succeeded,
result.listings_total,
result.houses_succeeded,
result.houses_total,
result.duration_sec,
)
return
if result.ban_kinds and (result.listings_succeeded + result.houses_succeeded) == 0:
counters["blocked"] = result.listings_blocked
# Полная перепись диагнозов, а не только доминирующий вид (#3196) — иначе

View file

@ -40,6 +40,7 @@ from dataclasses import dataclass, field
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -77,6 +78,10 @@ class CianBackfillResult:
# у него не проставлялся вовсе.
listings_blocked: int = 0
ban_kinds: Counter[str] = field(default_factory=Counter)
# #3197: прогон оборван, потому что пул прокси пуст — к площадке не ходили вовсе.
# Не блок и не отказ Циана: caller (scheduler) обязан пометить прогон failed, а не
# banned, иначе запись утверждает про площадку то, чего не было.
no_proxy_stop: bool = False
@property
def ban_kind(self) -> str:
@ -203,7 +208,20 @@ async def backfill_cian_history(
else:
# One BrowserFetcher instance shared across all listings in this batch.
# priceChanges requires JS rendering — curl_cffi returns empty list (#1574).
async with BrowserFetcher(source="cian", endpoint=settings.browser_http_endpoint) as bf:
# proxy_provider/use_pool/environment (#3197, шаг A2): без этих трёх сайдкар
# берёт свой env-прокси (SCRAPER_PROXY_URL) — прогон шёл мимо пула из 4 узлов
# целиком (ни выбора узла, ни scrape_proxy_source_bans, ни ротации), а
# прод-отказ «пул пуст → не ходить на env/direct» (#2616) на этом пути был
# мёртв: он смотрит на environment, который сюда не доезжал. Образец —
# domclick_detail_backfill.py:403 и house_imv_backfill.py:749 (#2698/#3197).
_cfg = RealScraperConfig()
async with BrowserFetcher(
source="cian",
endpoint=settings.browser_http_endpoint,
proxy_provider=RealProxyProvider(),
use_pool=_cfg.use_proxy_pool_browser,
environment=_cfg.environment,
) as bf:
for row in rows:
listing_id: int = row["id"]
source_url: str = row["source_url"]
@ -215,6 +233,21 @@ async def backfill_cian_history(
try:
enrichment = await fetch_detail(source_url, browser_fetcher=bf)
except Exception as exc:
# #3197: пустой пул — не отказ площадки: запрос не уходил вовсе,
# следующее объявление упрётся ровно в то же самое (иначе батч
# крутит впустую весь список). Опора — тип в цепочке причин, а не
# текст: fetch_detail заворачивает сбой фетча в своё исключение.
if caused_by_no_proxy(exc):
result.no_proxy_stop = True
result.listings_failed_fetch += 1
logger.error(
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
"не ходили. listing_id=%s processed=%d succeeded=%d",
listing_id,
result.listings_processed,
result.listings_succeeded,
)
break
kind = _note_refusal(result, bf.last_response_status)
logger.warning(
"cian_detail fetch failed for listing_id=%s url=%s: %s "
@ -272,7 +305,9 @@ async def backfill_cian_history(
await asyncio.sleep(delay)
# ── 2. Houses: missing houses_price_dynamics ──────────────────────────────
if do_houses:
# no_proxy_stop (#3197): пул пуст — дома идут через тот же пул (fetch_newbuilding с
# RealProxyProvider ниже), крутить их незачем.
if do_houses and not result.no_proxy_stop:
# Kit's fetch_newbuilding() now accepts config= (issue #2322 fixed) — pass
# RealScraperConfig() at the call site below so BrowserFetcher gets a real
# endpoint instead of degrading to endpoint=None (#2397 Part D2).
@ -363,7 +398,7 @@ async def backfill_cian_history(
await asyncio.sleep(delay)
# ── 3. Cian listings без external_valuations (price prediction backfill) ──
if do_valuations:
if do_valuations and not result.no_proxy_stop: # #3197: пул пуст — см. блок домов
rows = (
db.execute(
text("""

View file

@ -0,0 +1,228 @@
"""#3197 (часть 1, Циан) — суточный бэкфилл ходил в сайдкар мимо прокси-пула.
`BrowserFetcher(source="cian", endpoint=...)` конструировался БЕЗ
`proxy_provider`/`use_pool`/`environment` единственных трёх аргументов, которые
кладут "proxy" в тело POST /fetch (см. `scraper_kit.browser_fetcher`). Без них сайдкар
брал свой env-прокси (`SCRAPER_PROXY_URL`): ни выбора узла из пула, ни
`scrape_proxy_source_bans`, ни ротации, а прод-отказ «пул пуст не ходить на
env/direct» (#2616) на этом пути был мёртв, потому что смотрит на `environment`, который
до конструктора не доезжал. Соседи уже починены: domclick (#3197 ч.1, см.
test_3197_domclick_proxy_pool_wiring.py) и house_imv/avito (#2698).
Остальные тесты про то, чем оживший отказ оборачивается в прогоне: пустой пул
поднимается ДО запроса, поэтому следующее объявление упрётся ровно в то же самое, и
батч обязан оборваться на первом, а не крутить весь список. Подделка у них на уровне
`BrowserFetcher.fetch()`, НАСТОЯЩИЙ `fetch_detail`: его браузерная ветка глотала любое
исключение и возвращала None, поэтому стоп в задаче не срабатывал ни разу, а прогон,
не ходивший к площадке, финализировался как 'done' (#3197, ревью PR #3382).
Сеть/БД/камуфокс замоканы; в сеть тест не ходит.
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from types import SimpleNamespace
from typing import Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import pytest
from scraper_kit.providers.cian.detail import fetch_detail
from scraper_kit.proxy_errors import NoProxyAvailableError
from app.tasks import cian_history_backfill as chb
_FETCH = "app.tasks.cian_history_backfill.fetch_detail"
_SAVE = "app.tasks.cian_history_backfill.save_detail_enrichment"
_SLEEP = "app.tasks.cian_history_backfill.asyncio.sleep"
_DELAY = "app.tasks.cian_history_backfill.get_scraper_delay"
class _CapturingFetcher:
"""Зеркалит _CapturingFetcher из test_3197_domclick_proxy_pool_wiring.py."""
captured: ClassVar[dict[str, Any]] = {}
def __init__(self, **kwargs: Any) -> None:
_CapturingFetcher.captured = kwargs
self.last_response_status: int | None = None
async def __aenter__(self) -> _CapturingFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
class _EmptyPoolFetcher:
"""BrowserFetcher, у которого пул опустел: `fetch()` поднимает `NoProxyAvailableError`.
Ровно как в проде (`browser_fetcher.py:682`): отказ поднимается ДО POST в сайдкар,
запроса к площадке не было. Подделка кончается здесь `fetch_detail` настоящий.
"""
calls: ClassVar[list[str]] = []
def __init__(self, **_kwargs: Any) -> None:
self.last_response_status: int | None = None
async def __aenter__(self) -> _EmptyPoolFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def fetch(self, url: str, **_kwargs: Any) -> str:
_EmptyPoolFetcher.calls.append(url)
raise NoProxyAvailableError("cian")
class _RaisingFetcher:
"""Фетчер с заданным исключением — для прямых тестов контракта `fetch_detail`."""
def __init__(self, exc: BaseException) -> None:
self.exc = exc
self.last_response_status: int | None = None
async def fetch(self, _url: str, **_kwargs: Any) -> str:
raise self.exc
def _mock_db(n_rows: int) -> MagicMock:
rows = [
{"id": i + 1, "source_url": f"https://ekb.cian.ru/sale/flat/{i + 1}/"}
for i in range(n_rows)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = rows
db.execute.return_value = sel
return db
async def _run_listings(
db: MagicMock, fetch: Any = None, fetcher: type = _CapturingFetcher
) -> chb.CianBackfillResult:
"""Прогон listings-блока. `fetch=None` ⇒ `fetch_detail` НЕ подменяется (рабочий тракт)."""
patches = [
patch.object(chb, "BrowserFetcher", fetcher),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
patch(_DELAY, return_value=0.0),
]
if fetch is not None:
patches.append(patch(_FETCH, fetch))
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return await chb.backfill_cian_history(
db, batch_size=10, do_listings=True, do_houses=False, do_valuations=False
)
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
async def test_browser_fetcher_gets_proxy_pool_wiring(
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
) -> None:
"""use_pool/proxy_provider/environment доезжают до BrowserFetcher ИЗ КОНФИГА.
Оба значения флага проверяются одним телом: `use_pool` обязан следовать конфигу, а не
быть зашитой константой, а `proxy_provider` передаётся в любом случае при
`use_pool=False` он игнорируется фетчером, но call-site у dev и прода один и тот же.
"""
_CapturingFetcher.captured = {}
monkeypatch.setattr(chb.settings, "use_proxy_pool_browser", use_pool)
monkeypatch.setattr(chb.settings, "environment", environment)
fetch = AsyncMock(return_value=SimpleNamespace(price_changes=[]))
await _run_listings(_mock_db(1), fetch)
captured = _CapturingFetcher.captured
assert captured["source"] == "cian"
assert captured["endpoint"] == chb.settings.browser_http_endpoint
# .get(), а не [] — красное должно читаться как «значение не то», а не как KeyError.
assert captured.get("proxy_provider") is not None, "без провайдера пул не подключится"
assert captured.get("use_pool") is use_pool, "флаг пула должен доезжать до фетчера из конфига"
# #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв.
assert captured.get("environment") == environment
async def test_empty_pool_stops_the_batch_on_first_listing() -> None:
"""«Пул пуст» на первом объявлении обрывает батч, а не крутит весь список.
Замокано: БД (`_mock_db`), `save_detail_enrichment`, `asyncio.sleep`, задержка и
сам `BrowserFetcher` подделка кончается на `fetch()`, который поднимает
`NoProxyAvailableError` вместо HTTP-запроса в сайдкар.
НЕ замокан `fetch_detail` прогон идёт через настоящую браузерную ветку
`providers/cian/detail.py`. Прежняя редакция подменяла `fetch_detail` моком,
который сам поднимал исключение, и была зелёной по построению: живая функция
исключение ГЛОТАЛА и возвращала None, поэтому стоп не срабатывал ни разу
(ревью PR #3382 — прогон, не ходивший к площадке, уходил в 'done').
"""
_EmptyPoolFetcher.calls = []
result = await _run_listings(_mock_db(3), fetcher=_EmptyPoolFetcher)
# Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате
# должно означать «прошли 3 строки вместо 1», а не «поля нет».
assert _EmptyPoolFetcher.calls == ["https://ekb.cian.ru/sale/flat/1/"], (
"к сайдкару ходили ровно один раз — пул пуст с первого объявления"
)
assert result.listings_processed == 1, "батч обязан оборваться, а не пройти все 3 строки"
assert getattr(result, "no_proxy_stop", False) is True
# Отказ НАШЕЙ стороны не должен маскироваться под бан площадки (иначе прогон уйдёт
# в 'banned' и запись соврёт про Циан).
assert result.ban_kinds == {}
assert result.listings_blocked == 0
async def test_fetch_detail_propagates_empty_pool() -> None:
"""`fetch_detail` пробрасывает `NoProxyAvailableError`, а не гасит его в None.
Прямой контракт общей функции: на None вызывающий отвечает «не разобрали» и идёт
к следующему объявлению то есть глотание здесь отменяет стоп у ВСЕХ вызывающих
браузерного пути. Соседний `app/services/cian_price_history.py:143` того же типа
ждёт отдельным `except`.
"""
fetcher = _RaisingFetcher(NoProxyAvailableError("cian"))
with pytest.raises(NoProxyAvailableError):
await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type]
async def test_fetch_detail_still_returns_none_on_transport_error() -> None:
"""Прежний контракт цел: обычный сбой фетча по-прежнему None, а не исключение.
Иначе «пробрасываем пустой пул» превратилось бы в «пробрасываем всё», и каждый
таймаут сайдкара рвал бы батч вместо одной пропущенной карточки.
"""
fetcher = _RaisingFetcher(httpx.ConnectTimeout("sidecar timeout"))
got = await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type]
assert got is None
async def test_run_marked_failed_with_no_proxy_stop_counter() -> None:
"""Прогон с пустым пулом финализируется как failed + counters.no_proxy_stop=1."""
from app.services import scheduler as sched
runs = MagicMock()
result = chb.CianBackfillResult(
listings_total=3, listings_processed=1, listings_failed_fetch=1, no_proxy_stop=True
)
with (
patch.object(sched, "runs_mod", runs),
patch.object(chb, "backfill_cian_history", AsyncMock(return_value=result)),
):
await sched._execute_cian_backfill(MagicMock(), run_id=3197, params={"batch_size": 3})
runs.mark_done.assert_not_called()
runs.mark_banned.assert_not_called()
runs.mark_failed.assert_called_once()
_db, run_id, reason, counters = runs.mark_failed.call_args.args
assert run_id == 3197
assert "пул" in reason
assert counters["no_proxy_stop"] == 1

View file

@ -221,7 +221,17 @@ async def test_cian_history_backfill_browser_fetcher_uses_settings_endpoint() ->
db, do_listings=True, do_houses=False, do_valuations=False
)
assert captured == {"source": "cian", "endpoint": settings.browser_http_endpoint}
# #3197: к endpoint= добавилась проводка пула — без неё сайдкар брал env-прокси, и
# суточный прогон шёл мимо пула из 4 узлов (детали — test_3197_cian_history_proxy_
# pool_wiring.py).
assert captured == {
"source": "cian",
"endpoint": settings.browser_http_endpoint,
"proxy_provider": captured.get("proxy_provider"),
"use_pool": settings.use_proxy_pool_browser,
"environment": settings.environment,
}
assert captured["proxy_provider"] is not None
# ── config= threading — houses / valuations blocks (#2397 Part D2) ───────────────

View file

@ -28,6 +28,7 @@ from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.offer_price_history import validate_diff_percent
from scraper_kit.providers._base import build_curl_cffi_session
from scraper_kit.providers._proxy import curl_proxy_url
from scraper_kit.proxy_errors import caused_by_no_proxy
from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text,
normalize_repair_state,
@ -123,12 +124,22 @@ async def fetch_detail(
пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают
исключение в `errors_count`, а на own-session-пути оно дополнительно снимает
узел с выдачи Циану через `curl_proxy_url`.
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое,
поэтому решение «оборвать батч» принимает вызывающий (#3197).
"""
if browser_fetcher is not None:
# Browser path: get fully JS-rendered HTML; same parse path follows.
try:
html = await browser_fetcher.fetch(offer_url)
except Exception as exc:
# «Пул пуст» — не отказ страницы: запрос не уходил вовсе, и вызывающий обязан
# оборвать батч (#3197). Проглоченный здесь, он приезжал наверх как None, то
# есть неотличимо от «разобрать не смогли»: стоп в cian_history_backfill.py
# никогда не срабатывал, и прогон, не ходивший к площадке, финализировался
# как 'done'. Опора — тип в цепочке причин, а не текст (#3272).
if caused_by_no_proxy(exc):
raise
logger.warning("Cian detail browser fetch failed %s: %s", offer_url, exc)
return None
elif session is not None:

View file

@ -65,4 +65,26 @@ class ProxyBanError(Exception):
"""
__all__ = ["NoProxyAvailableError", "ProxyBanError"]
def caused_by_no_proxy(exc: BaseException) -> bool:
"""Прячется ли за этим исключением пустой пул прокси (#3197).
Опора ТИП в цепочке `__cause__`/`__context__`, а не подстрока «no proxy available»
в тексте: провайдеры заворачивают любой сбой фетча в свои Blocked/Unavailable-
исключения, и «пул пуст» приезжает наверх под видом блокировки площадки, хотя
запрос не уходил вовсе. По тексту такое уже один раз объявили баном чужую строку
(#3272), поэтому здесь только isinstance.
Те же две копии живут приватно в `app/tasks/avito_detail_backfill.py` (#3288) и
`domclick_detail_backfill.py` (#3283); их схлопывание сюда — отдельная правка.
"""
seen: set[int] = set()
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
if isinstance(cur, NoProxyAvailableError):
return True
seen.add(id(cur))
cur = cur.__cause__ or cur.__context__
return False
__all__ = ["NoProxyAvailableError", "ProxyBanError", "caused_by_no_proxy"]