fix(tradein/domclick): свип ходит через прокси-пул, а отказ узла не обнуляет прогон (#2657) #2796
6 changed files with 157 additions and 22 deletions
114
tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py
Normal file
114
tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py
Normal 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
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue