From ade1a065d6e3457872d6601701df458107829821 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 28 Aug 2026 00:54:48 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20=D0=B8=D1=81=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=BF=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B8=D0=BF=D0=B0=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20inf?= =?UTF-8?q?ra-=D0=B1=D0=B0=D0=BD,=20=D0=BD=D0=B5=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=20=D1=81=D0=B1=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoProxyAvailableError поднимается из BrowserFetcher.__aenter__ (_acquire_lease) ДО первого HTTP-запроса, когда пул прокси пуст — это НАША инфраструктура, не блокировка площадкой. У run_avito_full_load/run_cian_full_load/run_yandex_full_load уже есть выделенный except NoProxyAvailableError -> mark_banned(ban_kind='infra'), у run_domclick_city_sweep его не было: исключение проваливалось в общий except Exception внутри SERP-фазы, _scraper_ref оставался пустым, и честный статус ниже видел "0 лотов + errors>0" -> mark_failed("fetch errors — 0 listings") с ban_kind=NULL. Прод-факт: run 5023 (27.08) умер за 51 мс, errors_count=1, ban_kind=NULL — неотличимо от честного отказа сбора площадкой. Добавлен except NoProxyAvailableError перед generic except Exception (порядок важен: класс — подкласс RuntimeError). Обработчик зеркалит avito/cian/yandex: mark_banned + ban_kind_of_exception(exc) (даёт BAN_KIND_INFRA), и сохраняет унаследованный чекпоинт (skip_buckets) вместо потери его на нашем же отказе. Тест test_3118_domclick_no_proxy.py проверен на обратимость: без обработчика падает (mark_failed вместо mark_banned), с обработчиком — проходит. --- .../tests/test_3118_domclick_no_proxy.py | 107 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 25 ++++ 2 files changed, 132 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_3118_domclick_no_proxy.py diff --git a/tradein-mvp/backend/tests/test_3118_domclick_no_proxy.py b/tradein-mvp/backend/tests/test_3118_domclick_no_proxy.py new file mode 100644 index 00000000..8cb942c8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3118_domclick_no_proxy.py @@ -0,0 +1,107 @@ +"""#3118: run_domclick_city_sweep не отличал исчерпание пула прокси от отказа сбора. + +`NoProxyAvailableError` поднимается из `BrowserFetcher.__aenter__` (`_acquire_lease`) +ДО первого HTTP-запроса, когда в пуле не осталось свободных узлов — это НАША +инфраструктура, не блокировка площадкой. У run_avito_full_load/run_cian_full_load/ +run_yandex_full_load есть выделенный `except NoProxyAvailableError` → mark_banned с +ban_kind_of_exception() (даёт BAN_KIND_INFRA). У домклик-свипа его не было: исключение +проваливалось в общий `except Exception` внутри SERP-фазы, `_scraper_ref` оставался +пустым (scraper не успел создаться), и честный статус ниже видел «0 лотов + errors>0» +→ `mark_failed("fetch errors — 0 listings")` с `ban_kind=NULL`. + +Прод-факт: run 5023 (27.08) умер за 51 мс, errors_count=1, ban_kind=NULL — неотличимо +от честного отказа сбора, хотя площадка вообще не была затронута. + +На старом коде (до правки) этот тест падает: mark_failed вместо mark_banned, +ban_kind остаётся None. См. `test_2687_domclick_ban_kind.py` — тот же _BanKindRecorder +паттерн, для соседнего исхода (QRATOR-блок → platform). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.pipeline import run_domclick_city_sweep +from scraper_kit.orchestration.runs import BAN_KIND_INFRA +from scraper_kit.proxy_errors import NoProxyAvailableError + +PFX = "scraper_kit.orchestration.pipeline" + + +class _BanKindRecorder: + """Ловит исход (done/failed/banned) и ban_kind, переданный в mark_banned.""" + + def __init__(self) -> None: + self.ban_kind: str | None = None + self.marked: list[str] = [] + self.errors: list[str] = [] + + def is_cancelled(self, db: Any, run_id: int) -> bool: + return False + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + return None + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.marked.append("done") + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.marked.append("failed") + self.errors.append(error) + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any + ) -> None: + self.marked.append("banned") + self.errors.append(error) + self.ban_kind = kw.get("ban_kind") + + +async def _drive_no_proxy() -> _BanKindRecorder: + """DomClickScraper.__aenter__ поднимает NoProxyAvailableError ДО первого фетча — + пул опустел. fetch_city не вызывается вовсе (async with не входит в блок).""" + recorder = _BanKindRecorder() + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(side_effect=NoProxyAvailableError("domclick")) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)]) + with ( + patch(f"{PFX}.DomClickScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))), + patch(f"{PFX}.runs", recorder), + ): + await run_domclick_city_sweep( + MagicMock(), + config=SimpleNamespace(browser_http_endpoint="http://x:9000"), + matcher=MagicMock(), + run_id=5023, + city_id=4, + pages=1, + request_delay_sec=0.0, + ) + return recorder + + +async def test_no_proxy_marks_infra_ban_not_failed() -> None: + """Пул прокси пуст → banned/infra, НЕ failed. fetch_city не должен звонить площадке.""" + recorder = await _drive_no_proxy() + + assert recorder.marked == ["banned"], ( + f"ожидали ровно один исход 'banned', получили {recorder.marked} — " + "исчерпание пула прокси провалилось в общий except и было объявлено " + "отказом сбора (mark_failed), а не инфраструктурным баном" + ) + assert recorder.ban_kind == BAN_KIND_INFRA, ( + f"ban_kind={recorder.ban_kind!r}, ожидали {BAN_KIND_INFRA!r} — " + "отказ пула прокси должен классифицироваться как НАША инфраструктура, " + "не как блокировка площадкой и не как NULL/unknown" + ) + assert recorder.errors and "no proxy" in recorder.errors[-1].lower(), ( + f"error-сообщение {recorder.errors!r} не упоминает отказ пула — " + "диагностика в scrape_runs.error должна называть причину, а не общий текст" + ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 97e70169..a2d6ffca 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -4420,6 +4420,31 @@ async def run_domclick_city_sweep( _sweep_timeout, ) counters.errors_count += 1 + except NoProxyAvailableError as exc: + # #3118: пул опустел ДО первого HTTP-запроса — исключение поднимается из + # BrowserFetcher.__aenter__ (_acquire_lease) внутри async with DomClickScraper(...), + # то есть ДО _scraper_ref.append(_scraper). Без этой ветки NoProxyAvailableError + # (подкласс Exception) проваливался в generic except Exception ниже: errors_count + # растёт, но _scraper_ref остаётся пустым (blocked=0, buckets_completed=0/0) — и + # честный статус ниже видел «0 лотов + errors>0» и звал mark_failed("fetch errors — + # 0 listings") с ban_kind=NULL. Прод-след (run 5023, 27.08): умер за 51 мс, + # errors_count=1, ban_kind=NULL — неотличимо от честного отказа сбора площадкой, + # хотя площадка вообще не была затронута. Тот же диагноз и тот же по духу + # обработчик, что у run_avito_full_load/run_cian_full_load/run_yandex_full_load + # (см. except NoProxyAvailableError там же) — ban_kind_of_exception() относит тип + # исключения к BAN_KIND_INFRA («наша инфраструктура», не площадка). done_buckets + # сохраняем как унаследованные skip_buckets — этот прогон новых не завершил, но + # терять уже собранный чекпоинт (#2687-класс дефекта) resume не должен. + logger.error("domclick-sweep run_id=%d: no proxy available — %s", run_id, exc) + counters.errors_count += 1 + runs.mark_banned( + db, + run_id, + f"domclick sweep aborted: {exc}", + {**counters.to_dict(), "done_buckets": sorted(skip_buckets)}, + ban_kind=ban_kind_of_exception(exc), + ) + return counters except Exception: logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id) counters.errors_count += 1