fix(tradein): обход Домклика начинается со сдвигом — комнатности 2+ перестают быть слепой зоной (#2854) #2932
3 changed files with 202 additions and 2 deletions
|
|
@ -142,3 +142,154 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey
|
|||
assert scraper.blocked is True
|
||||
assert fake_fetcher.banned # report_ban был вызван на ЖИВОМ fetcher'е
|
||||
assert "QRATOR" in fake_fetcher.banned[0]
|
||||
|
||||
|
||||
# ── #2854: точка входа в ROOM_BUCKETS сдвигается от прогона к прогону ───────────
|
||||
#
|
||||
# Блок QRATOR прилетает ВНУТРИ первой корзины — на проде buckets_completed=0 во всех
|
||||
# прогонах, включая тот, где собрано 389 лотов. Обход при этом всегда стартовал со
|
||||
# студий, поэтому комнатности 2+ не собирались никогда. Замер 19.08 по активным
|
||||
# объявлениям: у Домклика 575 студий, 408 однушек, 1 двушка, 0 трёшек; у Циана в тех
|
||||
# же категориях 6 461 / 7 352 / 4 581.
|
||||
|
||||
|
||||
class _OrderRecordingScraper:
|
||||
"""Ничего не делает, только запоминает порядок корзин."""
|
||||
|
||||
|
||||
async def _make_scraper_recording(monkeypatch: pytest.MonkeyPatch) -> tuple[DomClickScraper, list]:
|
||||
seen: list[str] = []
|
||||
|
||||
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
|
||||
return _FakeFetcher()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher
|
||||
)
|
||||
|
||||
async def _record(self: DomClickScraper, *, rooms: str, **_: object) -> None:
|
||||
seen.append(rooms)
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _record)
|
||||
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
|
||||
return DomClickScraper(config), seen
|
||||
|
||||
|
||||
async def test_fetch_city_rotates_bucket_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Сдвиг меняет ПОРЯДОК корзин, а не их состав — ни одна не теряется."""
|
||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||||
|
||||
scraper, seen = await _make_scraper_recording(monkeypatch)
|
||||
await scraper.fetch_city(city_id=1, start_bucket_index=2)
|
||||
|
||||
assert seen == list(ROOM_BUCKETS[2:]) + list(ROOM_BUCKETS[:2])
|
||||
assert sorted(seen) == sorted(ROOM_BUCKETS), "состав корзин изменился — потеряли охват"
|
||||
|
||||
|
||||
async def test_fetch_city_start_index_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Вызывающий передаёт остаток от run_id — он ничем не ограничен сверху."""
|
||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||||
|
||||
scraper, seen = await _make_scraper_recording(monkeypatch)
|
||||
await scraper.fetch_city(city_id=1, start_bucket_index=len(ROOM_BUCKETS) + 1)
|
||||
|
||||
assert seen[0] == ROOM_BUCKETS[1]
|
||||
assert scraper.bucket_start_index == 1
|
||||
|
||||
|
||||
async def test_blocked_run_collects_shifted_bucket_not_studios(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Главный сценарий: блок в ПЕРВОЙ же корзине — собранной оказывается сдвинутая.
|
||||
|
||||
Ровно так ведёт себя прод: одна корзина за прогон и обрыв. До #2854 этой
|
||||
единственной корзиной всегда были студии, из-за чего двушки и трёшки не
|
||||
появлялись в listings вовсе.
|
||||
"""
|
||||
from scraper_kit.domclick_exceptions import DomClickBlockedError
|
||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||||
|
||||
attempted: list[str] = []
|
||||
|
||||
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
|
||||
return _FakeFetcher()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher
|
||||
)
|
||||
|
||||
async def _block(self: DomClickScraper, *, rooms: str, **_: object) -> None:
|
||||
attempted.append(rooms)
|
||||
raise DomClickBlockedError("QRATOR block page")
|
||||
|
||||
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _block)
|
||||
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
|
||||
scraper = DomClickScraper(config)
|
||||
|
||||
await scraper.fetch_city(city_id=1, start_bucket_index=ROOM_BUCKETS.index("2"))
|
||||
|
||||
assert attempted == ["2"], "обход всё ещё начинается не со сдвинутой корзины"
|
||||
assert scraper.blocked is True
|
||||
assert scraper.bucket_start_index == ROOM_BUCKETS.index("2")
|
||||
|
||||
|
||||
async def test_default_start_index_keeps_previous_behaviour(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Контроль: без явного сдвига порядок прежний — правка не меняет вызовы без него."""
|
||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||||
|
||||
scraper, seen = await _make_scraper_recording(monkeypatch)
|
||||
await scraper.fetch_city(city_id=1)
|
||||
|
||||
assert seen == list(ROOM_BUCKETS)
|
||||
|
||||
|
||||
# ── проводка: pipeline обязан ПЕРЕДАВАТЬ сдвиг, а не просто уметь его принимать ──
|
||||
|
||||
|
||||
async def test_pipeline_passes_rotating_start_index() -> None:
|
||||
"""Без этого теста передачу сдвига можно снять, и проверки выше останутся зелёными:
|
||||
скрейпер по-прежнему умеет сдвигать, просто его об этом никто не просит.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
|
||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
|
||||
|
||||
pfx = "scraper_kit.orchestration.pipeline"
|
||||
run_id = 17
|
||||
scraper = MagicMock()
|
||||
scraper.__aenter__ = AsyncMock(return_value=scraper)
|
||||
scraper.__aexit__ = AsyncMock(return_value=None)
|
||||
scraper.fetch_city = AsyncMock(return_value=[])
|
||||
scraper.blocked = False
|
||||
scraper.geo_filtered = 0
|
||||
scraper.fetch_errors = 0
|
||||
scraper.buckets_completed = 0
|
||||
scraper.buckets_total = len(ROOM_BUCKETS)
|
||||
scraper.bucket_start_index = run_id % len(ROOM_BUCKETS)
|
||||
|
||||
runs_stub = MagicMock()
|
||||
runs_stub.is_cancelled.return_value = False
|
||||
|
||||
with (
|
||||
patch(f"{pfx}.DomClickScraper", return_value=scraper),
|
||||
patch(f"{pfx}.save_listings", MagicMock(return_value=(0, 0))),
|
||||
patch(f"{pfx}.runs", runs_stub),
|
||||
):
|
||||
await run_domclick_city_sweep(
|
||||
MagicMock(),
|
||||
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
|
||||
matcher=MagicMock(),
|
||||
run_id=run_id,
|
||||
city_id=4,
|
||||
pages=1,
|
||||
request_delay_sec=0.0,
|
||||
)
|
||||
|
||||
passed = scraper.fetch_city.await_args.kwargs.get("start_bucket_index")
|
||||
assert passed == run_id % len(ROOM_BUCKETS), (
|
||||
f"pipeline не передал сдвиг корзины (получено {passed!r}) — "
|
||||
"обход снова всегда начнётся со студий"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4016,6 +4016,9 @@ class DomClickCitySweepCounters:
|
|||
# 0/0 = скрейпер не успел создаться — тогда судить об охвате нечем.
|
||||
buckets_completed: int = 0
|
||||
buckets_total: int = 0
|
||||
# #2854: с какой корзины ROOM_BUCKETS начался обход. Без него прогон
|
||||
# неатрибутируем: «двушек не собрали» и «до двушек не дошли» выглядят одинаково.
|
||||
bucket_start_index: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||
|
|
@ -4115,7 +4118,25 @@ async def run_domclick_city_sweep(
|
|||
_scraper_ref.append(_scraper)
|
||||
if request_delay_sec is not None:
|
||||
_scraper.request_delay_sec = _resolved_delay
|
||||
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||
# #2854: точка входа в ROOM_BUCKETS сдвигается от прогона к прогону.
|
||||
# Блок прилетает внутри ПЕРВОЙ корзины (buckets_completed=0 во всех
|
||||
# прод-прогонах), а обход всегда стартовал со студий — поэтому в
|
||||
# listings у Домклика 575 студий, 408 однушек и ровно 1 двушка при
|
||||
# 7 352 двушках у Циана. Сдвиг не лечит блок, он раздаёт собранное по
|
||||
# всем корзинам.
|
||||
#
|
||||
# Источник сдвига — run_id, а не дата и не случайность: детерминирован
|
||||
# (тест воспроизводим), не зависит от часов и не требует нового
|
||||
# состояния в БД. Строгого round-robin он не даёт — run_id общий на все
|
||||
# источники и растёт неравномерно, — но за 30 суток каждая корзина
|
||||
# получает порядка пяти стартов, чего достаточно для критерия приёмки
|
||||
# «объявления с rooms >= 2 появились».
|
||||
lots = await _scraper.fetch_city(
|
||||
city_id=city_id,
|
||||
rooms=rooms,
|
||||
pages=pages,
|
||||
start_bucket_index=run_id % len(ROOM_BUCKETS),
|
||||
)
|
||||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
|
||||
|
|
@ -4158,6 +4179,7 @@ async def run_domclick_city_sweep(
|
|||
# скрейпер живая, а его счётчик показывает, докуда прогон дошёл.
|
||||
counters.buckets_completed = _s.buckets_completed
|
||||
counters.buckets_total = _s.buckets_total
|
||||
counters.bucket_start_index = _s.bucket_start_index
|
||||
|
||||
# pages_fetched: worst-case число страниц (buckets × pages cap).
|
||||
counters.pages_fetched = _num_fetches
|
||||
|
|
|
|||
|
|
@ -280,6 +280,9 @@ class DomClickScraper(BaseScraper):
|
|||
# Поэтому прогон, прошедший 3 бакета из 6, был неотличим от полного.
|
||||
self.buckets_total: int = len(ROOM_BUCKETS)
|
||||
self.buckets_completed: int = 0
|
||||
# #2854: с какой корзины начался обход. Без этого прогон неатрибутируем —
|
||||
# по данным нельзя отличить «корзина не собралась» от «до неё не дошли».
|
||||
self.bucket_start_index: int = 0
|
||||
|
||||
async def __aenter__(self) -> DomClickScraper:
|
||||
await super().__aenter__()
|
||||
|
|
@ -300,6 +303,7 @@ class DomClickScraper(BaseScraper):
|
|||
city_id: int,
|
||||
rooms: list[int] | None = None,
|
||||
pages: int = 100,
|
||||
start_bucket_index: int = 0,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Citywide sweep через BFF JSON API.
|
||||
|
||||
|
|
@ -312,6 +316,17 @@ class DomClickScraper(BaseScraper):
|
|||
city_id: игнорируется (EKB захардкожен).
|
||||
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
|
||||
pages: максимальное число страниц на бакет (safety cap).
|
||||
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
|
||||
Порядок циклически сдвигается, состав не меняется. Нужен потому, что
|
||||
блок QRATOR прилетает ВНУТРИ первой же корзины (buckets_completed=0 во
|
||||
всех прод-прогонах), а обход всегда стартовал со студий — поэтому
|
||||
комнатности 2+ не собирались никогда. Замер 19.08 по активным
|
||||
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
|
||||
площадок 2+ комнат — около двух третей выдачи.
|
||||
Сдвиг НЕ лечит блок, он распределяет то, что успевает собраться, по
|
||||
всем корзинам вместо одной. Работает при единственном свободном узле —
|
||||
в отличие от ротации lease, которой сейчас упираться некуда: в пуле 3
|
||||
включённых узла, 2 забанены Домкликом.
|
||||
|
||||
Returns:
|
||||
Дедуплицированный по source_id список ScrapedLot.
|
||||
|
|
@ -333,7 +348,19 @@ class DomClickScraper(BaseScraper):
|
|||
async with build_browser_fetcher(
|
||||
self._config, "domclick", proxy_provider=self._proxy_provider
|
||||
) as fetcher:
|
||||
for bucket in ROOM_BUCKETS:
|
||||
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
|
||||
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
|
||||
# run_id, а он ничем не ограничен.
|
||||
offset = start_bucket_index % len(ROOM_BUCKETS)
|
||||
buckets = ROOM_BUCKETS[offset:] + ROOM_BUCKETS[:offset]
|
||||
self.bucket_start_index = offset
|
||||
logger.info(
|
||||
"domklik: обход начинается с корзины rooms=%r (сдвиг %d из %d, #2854)",
|
||||
buckets[0],
|
||||
offset,
|
||||
len(ROOM_BUCKETS),
|
||||
)
|
||||
for bucket in buckets:
|
||||
logger.info(
|
||||
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
||||
bucket,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue