From 705c98801c400b593771cb79b36dce4dc6cbce27 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 17:40:39 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=D0=BD=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BE=20=D1=81=D0=B4=D0=B2=D0=B8=D0=B3=D0=BE=D0=BC=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0=D1=82=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=202+=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20=D0=B1=D1=8B=D1=82=D1=8C=20=D1=81=D0=BB=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B7=D0=BE=D0=BD=D0=BE=D0=B9=20(#2854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок QRATOR прилетает ВНУТРИ первой корзины: buckets_completed=0 во всех прод- прогонах, включая тот, где собрано 389 лотов. Обход при этом всегда стартовал со студий, поэтому корзины '2', '3', '4', '5+' не начинались вовсе. Цена видна не в счётчиках, а в самих данных. Активные объявления по комнатности (замер 19.08): комнат домклик циан яндекс студии 575 3 2 451 1 408 6 461 4 769 2 1 7 352 4 895 3 0 4 581 3 175 4 0 873 780 Одна двушка и ноль трёшек. У соседних площадок 2+ комнат — около двух третей выдачи, то есть Домклик как источник структурно не покрывает большую часть рынка. Правка НЕ лечит блок. Она раздаёт то, что успевает собраться, по всем корзинам: точка входа в ROOM_BUCKETS циклически сдвигается от прогона к прогону, состав корзин не меняется. Работает при ОДНОМ свободном узле — это существенно, потому что ротация lease, которую задача предлагает первой, сегодня упирается в пул: 3 включённых узла, 2 забанены Домкликом до 21.08. Источник сдвига — run_id, не дата и не случайность: детерминирован (тест воспроизводим), не зависит от часов, не требует нового состояния в БД. Строгого round-robin не даёт, но за 30 суток каждая корзина получает порядка пяти стартов. bucket_start_index попадает в counters: без него прогон неатрибутируем — «двушек не собрали» и «до двушек не дошли» выглядят в данных одинаково. Ложные деактивации при более редком сборе проверены и не подтвердились: сторож здоровья источника работает, 15.08 прогон deactivate_stale_domklik показал skipped_unhealthy=1 при confirmations=94. Тесты: 3 красных на origin/main (TypeError на новом аргументе), 5 зелёных с обеих сторон. Отдельный тест на ПРОВОДКУ — без него передачу сдвига можно снять, и проверки скрейпера останутся зелёными; фальсифицирован снятием передачи. --- .../backend/tests/test_domclick_sweep.py | 151 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 24 ++- .../scraper_kit/providers/domclick/serp.py | 29 +++- 3 files changed, 202 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index 39f869f3..c0a9bc40 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -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}) — " + "обход снова всегда начнётся со студий" + ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 81631271..0f1bfefb 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index c9b99ba5..9e93cbe1 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -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,