fix(tradein/domclick): свип ходит через прокси-пул, а отказ узла не обнуляет прогон (#2657) (#2796)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
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 3m5s
Deploy Trade-In / build-backend (push) Has been cancelled

This commit is contained in:
bot-backend 2026-08-09 17:21:49 +00:00
parent 45924021a7
commit 7b36f86ea6
6 changed files with 157 additions and 22 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (<pre> или 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",