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:
|
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"scraper_kit.providers._base.build_browser_fetcher",
|
"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()
|
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"
|
assert source == "domclick"
|
||||||
return fake_fetcher
|
return fake_fetcher
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3742,6 +3742,7 @@ async def run_domclick_city_sweep(
|
||||||
config: ScraperConfig,
|
config: ScraperConfig,
|
||||||
matcher: HouseMatcher,
|
matcher: HouseMatcher,
|
||||||
shutdown_requested: Callable[[], bool] = lambda: False,
|
shutdown_requested: Callable[[], bool] = lambda: False,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
|
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
|
||||||
rooms: list[int] | None = None,
|
rooms: list[int] | None = None,
|
||||||
pages: int = 100,
|
pages: int = 100,
|
||||||
|
|
@ -3753,11 +3754,16 @@ async def run_domclick_city_sweep(
|
||||||
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
|
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
|
||||||
DomClick не поддерживает geo-radius (fetch_around → NotImplementedError),
|
DomClick не поддерживает geo-radius (fetch_around → NotImplementedError),
|
||||||
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
|
поэтому 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 приходят снаружи вместо
|
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
|
||||||
прямых импортов app.* (см. scraper_kit.contracts).
|
прямых импортов 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
|
ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned
|
||||||
(независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты,
|
(независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты,
|
||||||
прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed.
|
прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed.
|
||||||
|
|
@ -3807,7 +3813,7 @@ async def run_domclick_city_sweep(
|
||||||
async def _domclick_phase() -> None:
|
async def _domclick_phase() -> None:
|
||||||
"""Единственная citywide-фаза: fetch_city + save."""
|
"""Единственная citywide-фаза: fetch_city + save."""
|
||||||
nonlocal lots
|
nonlocal lots
|
||||||
async with DomClickScraper(config) as _scraper:
|
async with DomClickScraper(config, proxy_provider=proxy_provider) as _scraper:
|
||||||
_scraper_ref.append(_scraper)
|
_scraper_ref.append(_scraper)
|
||||||
if request_delay_sec is not None:
|
if request_delay_sec is not None:
|
||||||
_scraper.request_delay_sec = _resolved_delay
|
_scraper.request_delay_sec = _resolved_delay
|
||||||
|
|
|
||||||
|
|
@ -666,6 +666,7 @@ async def _job_domclick_city_sweep(
|
||||||
config=ctx.config,
|
config=ctx.config,
|
||||||
matcher=ctx.matcher,
|
matcher=ctx.matcher,
|
||||||
shutdown_requested=ctx.shutdown_requested,
|
shutdown_requested=ctx.shutdown_requested,
|
||||||
|
proxy_provider=ctx.proxy_provider,
|
||||||
city_id=int(params.get("city_id", 4)),
|
city_id=int(params.get("city_id", 4)),
|
||||||
rooms=params.get("rooms"),
|
rooms=params.get("rooms"),
|
||||||
pages=int(params.get("pages_per_anchor", 5)),
|
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).
|
(golden-parity — tests/test_scraper_kit_domclick_golden_parity.py).
|
||||||
|
|
||||||
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
|
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
|
||||||
BrowserFetcher(source="domclick") (generic provider → shared mobile proxy).
|
BrowserFetcher(source="domclick") → узел прокси-пула (#2657; до этого — статический
|
||||||
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
|
SCRAPER_PROXY_URL сайдкара, см. fetch_city). QRATOR банит прямые datacenter-запросы,
|
||||||
|
но пропускает через mobile proxy — проверено вживую 09.08.2026.
|
||||||
|
|
||||||
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
|
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
|
||||||
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
|
Парсинг: _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
|
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from scraper_kit.contracts import ScraperConfig
|
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -251,12 +252,17 @@ class DomClickScraper(BaseScraper):
|
||||||
config: ScraperConfig,
|
config: ScraperConfig,
|
||||||
*,
|
*,
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
||||||
# BrowserFetcher, delay_provider заменяет прямой импорт
|
# BrowserFetcher, delay_provider заменяет прямой импорт
|
||||||
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
||||||
self._config = config
|
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:
|
if delay_provider is not None:
|
||||||
self.request_delay_sec = delay_provider(self.name)
|
self.request_delay_sec = delay_provider(self.name)
|
||||||
self.parse_failures: int = 0
|
self.parse_failures: int = 0
|
||||||
|
|
@ -317,15 +323,16 @@ class DomClickScraper(BaseScraper):
|
||||||
out_lots: list[ScrapedLot] = []
|
out_lots: list[ScrapedLot] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
# build_browser_fetcher(config, source) без proxy_provider (дефолт None) —
|
# #2657: proxy_provider прокинут — до этого домклик единственный из sweep-
|
||||||
# поведенчески идентично прежнему прямому BrowserFetcher(source=..., endpoint=...):
|
# источников ходил без пула (#2160 P4 wiring gap), т.е. через статический
|
||||||
# use_pool теперь читает config.use_proxy_pool_browser (#2364 caveat, тот же
|
# SCRAPER_PROXY_URL сайдкара. Прод 06-09.08: этот адрес отдаёт
|
||||||
# класс что и avito) вместо хардкода False, но BrowserFetcher._pool_proxy
|
# NS_ERROR_PROXY_BAD_GATEWAY именно на bff-search-web.domclick.ru (проверено
|
||||||
# вычисляет `use_pool and proxy_provider is not None` — при proxy_provider=None
|
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
|
||||||
# результат всегда False независимо от значения флага (см. _base.py
|
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
|
||||||
# build_browser_fetcher docstring: domclick пока не подключён к browser-proxy-
|
# proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
|
||||||
# пулу, #2160 P4 wiring gap). Поведение НЕ меняется.
|
async with build_browser_fetcher(
|
||||||
async with build_browser_fetcher(self._config, "domclick") as fetcher:
|
self._config, "domclick", proxy_provider=self._proxy_provider
|
||||||
|
) as fetcher:
|
||||||
for bucket in ROOM_BUCKETS:
|
for bucket in ROOM_BUCKETS:
|
||||||
logger.info(
|
logger.info(
|
||||||
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
||||||
|
|
@ -350,15 +357,22 @@ class DomClickScraper(BaseScraper):
|
||||||
bucket,
|
bucket,
|
||||||
)
|
)
|
||||||
# #2600 п.1: fetcher (lease) ещё жив — `async with` вокруг этого
|
# #2600 п.1: fetcher (lease) ещё жив — `async with` вокруг этого
|
||||||
# цикла не закрылся, мы внутри его тела. no-op сегодня (домклик
|
# цикла не закрылся, мы внутри его тела. С #2657 (proxy_provider
|
||||||
# SERP собирается БЕЗ proxy_provider, #2160 P4 wiring gap — см.
|
# прокинут выше) это уже не no-op: узел уходит в
|
||||||
# build_browser_fetcher выше), но провода готовы на будущее.
|
# scrape_proxy_source_bans и следующий acquire("domclick") его не
|
||||||
|
# выдаст.
|
||||||
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
|
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
|
||||||
break
|
break
|
||||||
except (ValueError, TypeError) as exc:
|
except Exception as exc:
|
||||||
# Defensive: bucket-level ошибка не должна убивать весь sweep.
|
# 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
|
self.fetch_errors += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue