diff --git a/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py b/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py new file mode 100644 index 00000000..e2a3f43a --- /dev/null +++ b/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py @@ -0,0 +1,114 @@ +"""#2657: домклик собирал 0 лотов, потому что ходил мимо прокси-пула. + +Прод 06-09.08.2026: `domclick_city_sweep` четыре прогона подряд `failed` с +`buckets=0/6, lots=0, errors=1`. Причина найдена не в QRATOR и не в куках, а в двух +сложившихся дефектах: + +1. **Домклик был единственным sweep-источником без `proxy_provider`** (#2160 P4 wiring + gap). `build_browser_fetcher(config, "domclick")` без провайдера → lease не берётся → + сайдкар идёт через статический `SCRAPER_PROXY_URL`. Этот адрес (прод + `scrape_proxies.id=1`, residential, `provider_affinity='domclick'`) с 06.08 отдаёт + `NS_ERROR_PROXY_BAD_GATEWAY` ИМЕННО на `bff-search-web.domclick.ru`. Живая проверка + 09.08 через сайдкар: тот же URL через id=1 → 500, через мобильный узел пула + (`id=10`) → 200 и `{"offersCount":704,"snippetsCount":678}` в бакете `st`. + Предпосылка миграции 173 («QRATOR банит все прокси кроме этого residential») + опровергнута: мобильный узел домкликовский BFF пускает. + +2. **Транспортная ошибка убивала весь свип на первом бакете.** `fetch_city` ловил + только `ValueError/TypeError` — ошибки РАЗБОРА. `httpx.HTTPStatusError` от сайдкара + пролетал наружу мимо цикла по `ROOM_BUCKETS`, и прогон заканчивался с `0/6`. Это же + не давало сработать ротации lease после N подряд провалов + (`browser_fetcher._LEASE_ROTATE_AFTER_FAILS`) — до неё не доживали. + +Фальсификация на старом коде: `test_scraper_leases_proxy_for_domclick` падает +`TypeError: unexpected keyword argument 'proxy_provider'`, +`test_transport_error_does_not_kill_the_whole_sweep` падает на пробросе +`HTTPStatusError` наружу. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.scheduler import _job_domclick_city_sweep +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper + + +def _config() -> SimpleNamespace: + return SimpleNamespace( + browser_http_endpoint="http://x:9000", + use_proxy_pool_browser=True, + environment="production", + ) + + +@pytest.fixture +def _no_browser() -> Any: + """Сайдкар не поднимается — нас интересует только то, ЧТО ему передали.""" + fetcher = MagicMock() + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + with patch("scraper_kit.providers._base.BrowserFetcher", return_value=fetcher) as bf: + yield bf + + +async def test_scraper_leases_proxy_for_domclick(_no_browser: MagicMock) -> None: + """Узел берётся из пула под именем "domclick" — а не из env сайдкара.""" + provider = MagicMock() + scraper = DomClickScraper(_config(), proxy_provider=provider) + with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)): + await scraper.fetch_city(city_id=4) + + assert _no_browser.call_args.kwargs["proxy_provider"] is provider + assert _no_browser.call_args.kwargs["use_pool"] is True + + +async def test_transport_error_does_not_kill_the_whole_sweep(_no_browser: MagicMock) -> None: + """Отказ сайдкара на первом бакете не обнуляет прогон — остальные бакеты идут. + + Прод-форма отказа: 500 от tradein-browser с NS_ERROR_PROXY_BAD_GATEWAY в теле. + """ + scraper = DomClickScraper(_config()) + calls = {"n": 0} + + async def _sweep(self: DomClickScraper, **_: object) -> None: + calls["n"] += 1 + if calls["n"] == 1: + raise httpx.HTTPStatusError( + "Server error '500 Internal Server Error' | tradein-browser: " + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + request=MagicMock(), + response=MagicMock(), + ) + + with patch.object(DomClickScraper, "_sweep_bucket", _sweep): + await scraper.fetch_city(city_id=4) + + assert calls["n"] == len(ROOM_BUCKETS) + # Битый бакет НЕ засчитан в охват (#2670) — прогон честно неполон. + assert scraper.buckets_completed == len(ROOM_BUCKETS) - 1 + assert scraper.fetch_errors == 1 + + +async def test_scheduler_passes_pool_to_domclick_job() -> None: + """Планировщик отдаёт домклику ctx.proxy_provider — как всем остальным свипам.""" + provider = MagicMock() + ctx = SimpleNamespace( + config=_config(), + matcher=MagicMock(), + shutdown_requested=lambda: False, + proxy_provider=provider, + ) + sweep = AsyncMock(return_value=None) + with patch("scraper_kit.orchestration.scheduler.run_domclick_city_sweep", sweep): + await _job_domclick_city_sweep(MagicMock(), 1, {}, ctx) # type: ignore[arg-type] + + assert sweep.await_args.kwargs["proxy_provider"] is provider diff --git a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py index ecc31dc9..9c5cfae6 100644 --- a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py +++ b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py @@ -191,7 +191,7 @@ class _FakeFetcher: def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "scraper_kit.providers._base.build_browser_fetcher", - lambda config, source: _FakeFetcher(), + lambda config, source, **_kw: _FakeFetcher(), ) diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index e84f377e..39f869f3 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -120,7 +120,7 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey fake_fetcher = _FakeFetcher() - def _fake_build_browser_fetcher(config: object, source: str) -> _FakeFetcher: + def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher: assert source == "domclick" return fake_fetcher 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 a900f996..eb6852b3 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 @@ -3742,6 +3742,7 @@ async def run_domclick_city_sweep( config: ScraperConfig, matcher: HouseMatcher, shutdown_requested: Callable[[], bool] = lambda: False, + proxy_provider: ProxyProvider | None = None, city_id: int = DOMCLICK_DEFAULT_CITY_ID, rooms: list[int] | None = None, pages: int = 100, @@ -3753,11 +3754,16 @@ async def run_domclick_city_sweep( Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE: DomClick не поддерживает geo-radius (fetch_around → NotImplementedError), поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все - ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy. + ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → прокси-пул. Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо прямых импортов app.* (см. scraper_kit.contracts). + proxy_provider (#2657): домклик был единственным sweep-источником без пула — ходил + через статический SCRAPER_PROXY_URL сайдкара, а тот адрес с 06.08 отдаёт + NS_ERROR_PROXY_BAD_GATEWAY именно на домкликовский BFF. Теперь узел берётся из пула, + как у avito/cian/yandex, и плохой узел ротируется/банится штатными механизмами. + ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned (независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты, прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed. @@ -3807,7 +3813,7 @@ async def run_domclick_city_sweep( async def _domclick_phase() -> None: """Единственная citywide-фаза: fetch_city + save.""" nonlocal lots - async with DomClickScraper(config) as _scraper: + async with DomClickScraper(config, proxy_provider=proxy_provider) as _scraper: _scraper_ref.append(_scraper) if request_delay_sec is not None: _scraper.request_delay_sec = _resolved_delay diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index c670a298..0ddd275e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -666,6 +666,7 @@ async def _job_domclick_city_sweep( config=ctx.config, matcher=ctx.matcher, shutdown_requested=ctx.shutdown_requested, + proxy_provider=ctx.proxy_provider, city_id=int(params.get("city_id", 4)), rooms=params.get("rooms"), pages=int(params.get("pages_per_anchor", 5)), diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index 1a41094f..c9b99ba5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -6,8 +6,9 @@ Strangler-копия (#2133) боевого `app.services.scrapers.domclick`, р (golden-parity — tests/test_scraper_kit_domclick_golden_parity.py). Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через -BrowserFetcher(source="domclick") (generic provider → shared mobile proxy). -QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy. +BrowserFetcher(source="domclick") → узел прокси-пула (#2657; до этого — статический +SCRAPER_PROXY_URL сайдкара, см. fetch_city). QRATOR банит прямые datacenter-запросы, +но пропускает через mobile proxy — проверено вживую 09.08.2026. Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (
или bare body).
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
@@ -41,7 +42,7 @@ from scraper_kit.pricing import BisectionConfig, ProbeResult, walk_price_range
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
if TYPE_CHECKING:
- from scraper_kit.contracts import ScraperConfig
+ from scraper_kit.contracts import ProxyProvider, ScraperConfig
logger = logging.getLogger(__name__)
@@ -251,12 +252,17 @@ class DomClickScraper(BaseScraper):
config: ScraperConfig,
*,
delay_provider: Callable[[str], float] | None = None,
+ proxy_provider: ProxyProvider | None = None,
) -> None:
super().__init__()
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
# BrowserFetcher, delay_provider заменяет прямой импорт
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
self._config = config
+ # #2657: proxy_provider — как у cian/yandex/avito. До этого домклик был
+ # ЕДИНСТВЕННЫМ sweep-источником без пула (#2160 P4 wiring gap) и потому ходил
+ # через статический SCRAPER_PROXY_URL сайдкара.
+ self._proxy_provider = proxy_provider
if delay_provider is not None:
self.request_delay_sec = delay_provider(self.name)
self.parse_failures: int = 0
@@ -317,15 +323,16 @@ class DomClickScraper(BaseScraper):
out_lots: list[ScrapedLot] = []
seen_ids: set[str] = set()
- # build_browser_fetcher(config, source) без proxy_provider (дефолт None) —
- # поведенчески идентично прежнему прямому BrowserFetcher(source=..., endpoint=...):
- # use_pool теперь читает config.use_proxy_pool_browser (#2364 caveat, тот же
- # класс что и avito) вместо хардкода False, но BrowserFetcher._pool_proxy
- # вычисляет `use_pool and proxy_provider is not None` — при proxy_provider=None
- # результат всегда False независимо от значения флага (см. _base.py
- # build_browser_fetcher docstring: domclick пока не подключён к browser-proxy-
- # пулу, #2160 P4 wiring gap). Поведение НЕ меняется.
- async with build_browser_fetcher(self._config, "domclick") as fetcher:
+ # #2657: proxy_provider прокинут — до этого домклик единственный из sweep-
+ # источников ходил без пула (#2160 P4 wiring gap), т.е. через статический
+ # SCRAPER_PROXY_URL сайдкара. Прод 06-09.08: этот адрес отдаёт
+ # NS_ERROR_PROXY_BAD_GATEWAY именно на bff-search-web.domclick.ru (проверено
+ # вживую 09.08: через него 500, через мобильный узел пула — 200 и
+ # snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
+ # proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
+ async with build_browser_fetcher(
+ self._config, "domclick", proxy_provider=self._proxy_provider
+ ) as fetcher:
for bucket in ROOM_BUCKETS:
logger.info(
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
@@ -350,15 +357,22 @@ class DomClickScraper(BaseScraper):
bucket,
)
# #2600 п.1: fetcher (lease) ещё жив — `async with` вокруг этого
- # цикла не закрылся, мы внутри его тела. no-op сегодня (домклик
- # SERP собирается БЕЗ proxy_provider, #2160 P4 wiring gap — см.
- # build_browser_fetcher выше), но провода готовы на будущее.
+ # цикла не закрылся, мы внутри его тела. С #2657 (proxy_provider
+ # прокинут выше) это уже не no-op: узел уходит в
+ # scrape_proxy_source_bans и следующий acquire("domclick") его не
+ # выдаст.
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
break
- except (ValueError, TypeError) as exc:
+ except Exception as exc:
# Defensive: bucket-level ошибка не должна убивать весь sweep.
- # _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
- # но если что-то всё же всплыло — переходим к следующему бакету.
+ # _count/_paginate глотают ошибки РАЗБОРА per-fetch (fetch_errors++),
+ # но транспортные (httpx.HTTPStatusError от сайдкара, таймаут узла)
+ # летели мимо и убивали прогон на ПЕРВОМ же бакете — прод 06-09.08:
+ # buckets=0/6, lots=0, четыре дня подряд. Это же лишало смысла
+ # ротацию lease после N подряд провалов (browser_fetcher
+ # _LEASE_ROTATE_AFTER_FAILS): до неё просто не доживали. Ловим
+ # Exception, а не BaseException — CancelledError (SIGTERM-drain,
+ # watchdog asyncio.wait_for) обязан пройти насквозь.
self.fetch_errors += 1
logger.warning(
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",