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
114 lines
5.6 KiB
Python
114 lines
5.6 KiB
Python
"""#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
|