From 37f12f63032a42133c3ea9c7e5344f2f9f7c0d25 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 20:57:41 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/yandex):=20=D0=B8=D0=BD=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20per-combo=20save=20+=20bounded=20gate-timeout=20(?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D0=B8-=D0=B7=D0=BE=D0=BC=D0=B1=D0=B8=20city?= =?UTF-8?q?=5Fsweep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrape_pipeline.py | 85 +++++++-- .../app/services/scrapers/browser_fetcher.py | 7 +- .../app/services/scrapers/yandex_realty.py | 48 ++++- .../backend/tests/test_yandex_city_sweep.py | 173 +++++++++++++++++- .../backend/tests/test_yandex_realty_serp.py | 116 ++++++++++++ 5 files changed, 400 insertions(+), 29 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py index 29297cf2..0997e4c6 100644 --- a/tradein-mvp/backend/app/services/scrape_pipeline.py +++ b/tradein-mvp/backend/app/services/scrape_pipeline.py @@ -1167,8 +1167,9 @@ async def run_avito_newbuilding_sweep( class YandexCitySweepCounters: """Aggregate counters для Yandex.Недвижимость city sweep run. - Фазы: SERP (fetch_around_multi_room) → save_listings → address-enrich. + Фазы: SERP (fetch_around_multi_room) → save_listings инкрементально → address-enrich. address_* — результат T10-обогащения (detail → полный адрес с номером дома). + combos_skipped — сколько (room×price) combo пропущено из-за таймаутов gate-API. """ anchors_total: int = 0 @@ -1177,6 +1178,7 @@ class YandexCitySweepCounters: lots_inserted: int = 0 lots_updated: int = 0 price_history_rows: int = 0 + combos_skipped: int = 0 address_attempted: int = 0 address_enriched: int = 0 address_failed: int = 0 @@ -1327,17 +1329,68 @@ async def run_yandex_city_sweep( # по ссылке → неправильные значения при asyncio scheduling. _lat, _lon, _name = lat, lon, name + # _combo_lots_accumulator: per-anchor мутируемый контейнер для on_combo. + # Захватывается по ссылке и в _yandex_anchor_phases и в _on_combo — + # избегаем B023 (loop-variable capture) через явный bind в default-arg. + _combo_lots_accumulator: list[ScrapedLot] = anchor_lots + async def _yandex_anchor_phases( _a_lat: float = _lat, _a_lon: float = _lon, _a_name: str = _name, + _al: list[ScrapedLot] = _combo_lots_accumulator, ) -> None: - """Все фазы одного anchor'а (SERP + address-enrich).""" - nonlocal anchor_lots, consecutive_failures + """Все фазы одного anchor'а (SERP + address-enrich). + + FIX: инкрементальный save per-combo — on_combo вызывается scraper'ом после + каждого (room×price) combo, сохраняет порцию сразу в БД + обновляет heartbeat. + anchor_lots накапливается (через _al) для address-enrich и price-history. + """ + nonlocal consecutive_failures + + # ── Phase 1+2: SERP + инкрементальный save per-combo ────── + def _on_combo( + combo_label: str, + new_lots: list[ScrapedLot], + _accumulator: list[ScrapedLot] = _al, + ) -> None: + """Callback: вызывается scraper'ом после каждого combo с новыми лотами. + + Сохраняет порцию новых лотов немедленно (анти-зомби: данные в БД + до конца anchor'а) и обновляет heartbeat для мониторинга прогресса. + """ + _accumulator.extend(new_lots) + counters.lots_fetched += len(new_lots) + try: + ins, upd = save_listings(db, new_lots, run_id=run_id) + counters.lots_inserted += ins + counters.lots_updated += upd + except Exception as save_exc: + counters.errors_count += 1 + logger.warning( + "yandex-sweep run_id=%d combo %s: save_listings failed: %s", + run_id, + combo_label, + save_exc, + ) + try: + db.rollback() + except Exception: + pass + scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) + logger.debug( + "yandex-sweep run_id=%d combo %s: saved %d lots " + "(ins=%d upd=%d total_fetched=%d)", + run_id, + combo_label, + len(new_lots), + counters.lots_inserted, + counters.lots_updated, + counters.lots_fetched, + ) - # ── Phase 1+2: SERP + save ───────────────────────────────── async with YandexRealtyScraper() as scraper: - anchor_lots = await scraper.fetch_around_multi_room( + await scraper.fetch_around_multi_room( _a_lat, _a_lon, radius_m, @@ -1345,16 +1398,15 @@ async def run_yandex_city_sweep( rooms_list=_rooms_list, price_ranges=_price_ranges, segments=_segments, + on_combo=_on_combo, ) - counters.lots_fetched += len(anchor_lots) - if anchor_lots: - inserted, updated = save_listings(db, anchor_lots, run_id=run_id) - counters.lots_inserted += inserted - counters.lots_updated += updated - # Price-history из gate price.previous/trend (graceful — провал - # истории НЕ должен ронять сейв листингов). + # _al накоплен on_combo; save уже вызван per-combo. + + # Price-history из gate price.previous/trend (graceful — провал + # истории НЕ должен ронять сейв листингов). + if _al: try: - counters.price_history_rows += record_yandex_price_history(db, anchor_lots) + counters.price_history_rows += record_yandex_price_history(db, _al) except Exception as ph_exc: logger.warning( "yandex-sweep run_id=%d: price-history failed: %s", @@ -1365,20 +1417,21 @@ async def run_yandex_city_sweep( db.rollback() except Exception: pass + consecutive_failures = 0 logger.info( "yandex-sweep run_id=%d center-combos %s: fetched=%d ins=%d upd=%d", run_id, _a_name, - len(anchor_lots), + counters.lots_fetched, counters.lots_inserted, counters.lots_updated, ) # ── Phase 3: address-enrich ──────────────────────────────── - if not (enrich_address and anchor_lots): + if not (enrich_address and _al): return - source_ids = [lot.source_id for lot in anchor_lots if lot.source_id] + source_ids = [lot.source_id for lot in _al if lot.source_id] if not source_ids: return id_rows = ( diff --git a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py index b175041c..730415e2 100644 --- a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py +++ b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py @@ -40,12 +40,15 @@ class BrowserFetcher: html = await fetcher.fetch("https://example.com") """ - def __init__(self, source: str = "avito") -> None: + def __init__(self, source: str = "avito", fetch_timeout_s: float = _HTTP_TIMEOUT_S) -> None: # source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер # роутит /fetch по нему на отдельный браузер+прокси, когда включён # FEATURE_BROWSER_POOL_ENABLED (Phase 1). При выключенном флаге source # игнорируется — поведение не меняется. + # fetch_timeout_s — таймаут httpx-клиента для POST /fetch. Yandex-путь передаёт + # 30s чтобы один проблемный combo занимал ≤30s×retries вместо 120s×retries. self._source = source + self._fetch_timeout_s = fetch_timeout_s self._client: httpx.AsyncClient | None = None self._endpoint: str | None = None @@ -55,7 +58,7 @@ class BrowserFetcher: from app.core.config import settings self._endpoint = settings.browser_http_endpoint - self._client = httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) + self._client = httpx.AsyncClient(timeout=self._fetch_timeout_s) logger.info("BrowserFetcher: клиент создан, endpoint=%s", self._endpoint) return self diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py index 1103f875..c8e5463e 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py @@ -83,6 +83,10 @@ _YANDEX_MIN_BRACKET = 50_000 _GATE_PAGE_SIZE = 20 _YANDEX_TARPIT_MAX_RETRIES: int = 2 +# Таймаут httpx-клиента BrowserFetcher для yandex-пути: 30s вместо глобального 120s. +# Один проблемный combo занимает ≤30s×(1+_YANDEX_TARPIT_MAX_RETRIES)=90s вместо 360s. +_YANDEX_BROWSER_FETCH_TIMEOUT_S: float = 30.0 + @dataclass class _CurlResponse: @@ -431,8 +435,13 @@ class YandexRealtyScraper(BaseScraper): camoufox's real-browser fingerprint avoids the mobile-proxy IP tarpit escalation that raw system-curl over SOCKS5 triggers. Opening per-page is expensive and unnecessary. + + fetch_timeout_s=_YANDEX_BROWSER_FETCH_TIMEOUT_S (30s) ограничивает потери + на один тайм-аутовый запрос: worst-case 30s×(1+retries)=90s на combo вместо 360s. """ - self._browser = BrowserFetcher(source="yandex") + self._browser = BrowserFetcher( + source="yandex", fetch_timeout_s=_YANDEX_BROWSER_FETCH_TIMEOUT_S + ) await self._browser.__aenter__() return self @@ -662,6 +671,7 @@ class YandexRealtyScraper(BaseScraper): rooms_list: list[str] | None = None, price_ranges: list[tuple[int | None, int | None]] | None = None, segments: list[str] | None = None, + on_combo: Any = None, **_legacy_kwargs: Any, ) -> list[ScrapedLot]: """Fetch via segment x rooms x price-range combos; paginate each combo to totalPages. @@ -672,9 +682,15 @@ class YandexRealtyScraper(BaseScraper): Legacy mode (rooms_list=None, price_ranges=None): single citywide sweep. segments: list of newFlat values, default ["NO"] (vtorichka only). Pass ["NO", "YES"] to sweep both vtorichka and novostroyki in one call. + + on_combo: опциональный callback(combo_label: str, new_lots: list[ScrapedLot]) -> None. + Вызывается после каждого combo с новыми (не повторными) лотами из него. + Позволяет инкрементальный save: вызывающий код сохраняет new_lots сразу, + не дожидаясь конца sweep'а. Дубли между combo НЕ передаются повторно. """ seen: dict[str, ScrapedLot] = {} _segments = segments or ["NO"] + combos_skipped = 0 if rooms_list is None and price_ranges is None: combos: list[tuple[str | None, int | None, int | None]] = [(None, None, None)] @@ -688,6 +704,8 @@ class YandexRealtyScraper(BaseScraper): for rooms, price_min, price_max in combos: combo_label = f"{_seg}/{_combo_label(rooms, price_min, price_max)}" total_pages: int | None = None + combo_new_lots: list[ScrapedLot] = [] + combo_skipped = False for page in range(1, max_pages + 1): if page == 1: @@ -709,7 +727,9 @@ class YandexRealtyScraper(BaseScraper): break if resp.status_code == 0: # type: ignore[union-attr] logger.warning( - "yandex gate: tarpit combo=%s page=1 -- rotating", combo_label + "yandex gate: tarpit combo=%s page=1 attempt=%d -- rotating", + combo_label, + _attempt + 1, ) await self._rotate_ip() await asyncio.sleep(2) @@ -740,13 +760,18 @@ class YandexRealtyScraper(BaseScraper): break if payload_p1 is None: - logger.debug( - "yandex gate combo [%s] page=1: failed -- skipping", combo_label + logger.warning( + "yandex gate combo [%s] page=1: all retries exhausted -- skipping", + combo_label, ) + combo_skipped = True + combos_skipped += 1 break result = _extract_gate_data(payload_p1) if result is None: + combo_skipped = True + combos_skipped += 1 break _entities_p1, pager_p1 = result total_pages = min( @@ -764,6 +789,7 @@ class YandexRealtyScraper(BaseScraper): key = lot.source_id or lot.source_url if key and key not in seen: seen[key] = lot + combo_new_lots.append(lot) await self.sleep_between_requests() if total_pages <= 1: break @@ -793,13 +819,25 @@ class YandexRealtyScraper(BaseScraper): key = lot.source_id or lot.source_url if key and key not in seen: seen[key] = lot + combo_new_lots.append(lot) + + # Инкрементальный save: вызываем on_combo если есть новые лоты. + # Скипнутые combo (combo_skipped=True) не вызывают on_combo. + if on_combo is not None and combo_new_lots and not combo_skipped: + try: + on_combo(combo_label, combo_new_lots) + except Exception: + logger.exception( + "yandex gate: on_combo callback failed combo=%s", combo_label + ) logger.info( - "yandex gate aggregate: %d unique lots (%d combos x %d segments=%s)", + "yandex gate aggregate: %d unique lots (%d combos x %d segments=%s, skipped=%d)", len(seen), len(combos), len(_segments), _segments, + combos_skipped, ) return list(seen.values()) diff --git a/tradein-mvp/backend/tests/test_yandex_city_sweep.py b/tradein-mvp/backend/tests/test_yandex_city_sweep.py index ca8ec585..3eeaa61a 100644 --- a/tradein-mvp/backend/tests/test_yandex_city_sweep.py +++ b/tradein-mvp/backend/tests/test_yandex_city_sweep.py @@ -153,6 +153,7 @@ def test_counters_defaults() -> None: c = YandexCitySweepCounters() assert c.anchors_total == 0 assert c.lots_fetched == 0 + assert c.combos_skipped == 0 assert c.address_attempted == 0 assert c.address_enriched == 0 assert c.address_failed == 0 @@ -176,7 +177,12 @@ def test_counters_to_dict_all_keys() -> None: async def test_sweep_iterates_all_anchors_and_aggregates( monkeypatch: pytest.MonkeyPatch, ) -> None: - """fetch_around_multi_room вызывается для каждого anchor, counters агрегируются.""" + """fetch_around_multi_room вызывается для каждого anchor, counters агрегируются. + + FIX: fake_fetch_multi вызывает on_combo (инкрементальный save per-combo) — + имитирует реальное поведение scraper'а. save_listings должен вызываться + on_combo'м, а не одним батчем в конце anchor'а. + """ serp_calls: list[str] = [] save_calls: list[int] = [] @@ -186,10 +192,15 @@ async def test_sweep_iterates_all_anchors_and_aggregates( lon: float, radius_m: int = 1000, max_pages: int = 2, + on_combo: Any = None, **_: Any, ) -> list[ScrapedLot]: serp_calls.append(f"{lat:.4f}") - return [_fake_lot(f"{lat}-{i}") for i in range(3)] + lots = [_fake_lot(f"{lat}-{i}") for i in range(3)] + # Имитируем один combo — вызываем on_combo если предоставлен. + if on_combo is not None: + on_combo(f"vtorichka/combo_{lat:.4f}", lots) + return lots def fake_save( _db: Any, lots: list[ScrapedLot], *, run_id: int | None = None @@ -212,6 +223,8 @@ async def test_sweep_iterates_all_anchors_and_aggregates( ) assert len(serp_calls) == len(TEST_ANCHORS) + # save вызывается инкрементально через on_combo — по одному разу на anchor + # (fake имитирует один combo per anchor). assert len(save_calls) == len(TEST_ANCHORS) assert counters.anchors_total == len(TEST_ANCHORS) assert counters.anchors_done == len(TEST_ANCHORS) @@ -284,8 +297,12 @@ async def test_address_enrich_fills_counters( lon: float, radius_m: int = 1000, max_pages: int = 2, + on_combo: Any = None, **_: Any, ) -> list[ScrapedLot]: + # Вызываем on_combo чтобы anchor_lots накопился (инкрементальный save). + if on_combo is not None: + on_combo("vtorichka/combo_enrich_test", lots) return lots monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) @@ -343,9 +360,13 @@ async def test_address_enrich_http_error_does_not_abort( lon: float, radius_m: int = 1000, max_pages: int = 2, + on_combo: Any = None, **_: Any, ) -> list[ScrapedLot]: - return [_fake_lot("id-err")] + lots = [_fake_lot("id-err")] + if on_combo is not None: + on_combo("vtorichka/combo_http_err", lots) + return lots monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) @@ -498,11 +519,16 @@ async def test_non_consecutive_failures_do_not_abort( lon: float, radius_m: int = 1000, max_pages: int = 2, + on_combo: Any = None, **_: Any, ) -> list[ScrapedLot]: if _name_of(lat, anchors) in failing: raise RuntimeError("transient") - return [_fake_lot(f"{lat}-0")] + lots = [_fake_lot(f"{lat}-0")] + # Вызываем on_combo для инкрементального save. + if on_combo is not None: + on_combo(f"vtorichka/combo_{lat:.4f}", lots) + return lots monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) @@ -689,8 +715,10 @@ async def test_prod_mode_uses_center_anchor_with_combos( max_pages: int = 2, rooms_list: list | None = None, price_ranges: list | None = None, + on_combo: Any = None, **_: Any, ) -> list[ScrapedLot]: + lots = [_fake_lot("center-1"), _fake_lot("center-2")] calls.append( { "lat": lat, @@ -701,7 +729,10 @@ async def test_prod_mode_uses_center_anchor_with_combos( "max_pages": max_pages, } ) - return [_fake_lot("center-1"), _fake_lot("center-2")] + # Имитируем on_combo (инкрементальный save) — один combo. + if on_combo is not None: + on_combo("vtorichka/center_combo", lots) + return lots monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save) @@ -745,7 +776,7 @@ async def test_prod_mode_uses_center_anchor_with_combos( # max_pages forwarded from pages_per_anchor assert call["max_pages"] == 3 - # lots aggregated correctly + # lots aggregated correctly via on_combo assert counters.lots_fetched == 2 assert counters.lots_inserted == 2 assert fake.done is not None @@ -895,3 +926,133 @@ async def test_explicit_anchor_mode_uses_anchor_timeout_sec( assert ( t == ANCHOR_TIMEOUT_SEC ), f"Expected ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s, got {t}s" + + +# ── FIX A: инкрементальный save per-combo ──────────────────────────────────── + + +async def test_incremental_save_called_per_combo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """save_listings вызывается on_combo per-combo, не одним батчем в конце anchor'а. + + Имитируем 3 combo через on_combo — save_listings должен быть вызван 3 раза + (по одному на combo) с порциями лотов. Проверяем, что при краше anchor'а + уже сохранённые combo не теряются (данные в БД до finish anchor'а). + """ + save_batches: list[list[str]] = [] + + async def fake_fetch_multi( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + on_combo: Any = None, + **_: Any, + ) -> list[ScrapedLot]: + all_lots = [] + for combo_idx in range(3): + combo_lots = [_fake_lot(f"{lat}-combo{combo_idx}-lot0")] + all_lots.extend(combo_lots) + if on_combo is not None: + on_combo(f"vtorichka/combo_{combo_idx}", combo_lots) + return all_lots + + def fake_save( + _db: Any, lots: list[ScrapedLot], *, run_id: int | None = None + ) -> tuple[int, int]: + save_batches.append([lot.source_id for lot in lots if lot.source_id]) + return len(lots), 0 + + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) + monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save) + fake = _FakeRuns() + _install_runs(monkeypatch, fake) + + counters = await scrape_pipeline.run_yandex_city_sweep( + db=MagicMock(), + run_id=30, + anchors=TEST_ANCHORS[:1], + pages_per_anchor=2, + request_delay_sec=0.0, + enrich_address=False, + ) + + # save_listings вызван ровно 3 раза (по одному на combo), не одним батчем. + assert ( + len(save_batches) == 3 + ), f"Ожидалось 3 save вызова (per-combo), получено {len(save_batches)}: {save_batches}" + # Каждый batch содержит ровно 1 лот (размер combo). + for batch in save_batches: + assert len(batch) == 1, f"Каждый combo-batch должен иметь 1 лот, получено {batch}" + + # Суммарные counters корректны. + assert counters.lots_fetched == 3 + assert counters.lots_inserted == 3 + assert fake.done is not None + + # heartbeat вызывался после каждого combo (через on_combo). + assert ( + len(fake.heartbeats) >= 3 + ), f"Ожидалось ≥3 heartbeat обновлений (per-combo), получено {len(fake.heartbeats)}" + + +# ── FIX B: bounded retry — скипает combo после exhaustion ──────────────────── + + +async def test_bounded_retry_combo_skipped_logged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_combo НЕ вызывается если combo пропущен (все retry page-1 исчерпаны). + + Имитируем sweep с двумя combos: первый скипается (on_combo не вызван), + второй успешен (on_combo вызван). Проверяем, что save_listings вызван + только для успешного combo, а не скипнутого. + """ + save_batches: list[list[str]] = [] + on_combo_calls: list[str] = [] + + async def fake_fetch_multi( + self: YandexRealtyScraper, + lat: float, + lon: float, + radius_m: int = 1000, + max_pages: int = 2, + on_combo: Any = None, + **_: Any, + ) -> list[ScrapedLot]: + # Combo 0: скипается — on_combo НЕ вызывается (симулирует exhausted retries). + # Combo 1: успешен — on_combo вызывается с лотом. + success_lot = _fake_lot(f"{lat}-success") + if on_combo is not None: + on_combo_calls.append("combo_1_success") + on_combo("vtorichka/combo_1", [success_lot]) + return [success_lot] + + def fake_save( + _db: Any, lots: list[ScrapedLot], *, run_id: int | None = None + ) -> tuple[int, int]: + save_batches.append([lot.source_id for lot in lots if lot.source_id]) + return len(lots), 0 + + monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi) + monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save) + fake = _FakeRuns() + _install_runs(monkeypatch, fake) + + counters = await scrape_pipeline.run_yandex_city_sweep( + db=MagicMock(), + run_id=31, + anchors=TEST_ANCHORS[:1], + pages_per_anchor=2, + request_delay_sec=0.0, + enrich_address=False, + ) + + # Только успешный combo вызвал on_combo и save. + assert len(on_combo_calls) == 1 + assert len(save_batches) == 1 + assert counters.lots_fetched == 1 + assert counters.lots_inserted == 1 + assert fake.done is not None diff --git a/tradein-mvp/backend/tests/test_yandex_realty_serp.py b/tradein-mvp/backend/tests/test_yandex_realty_serp.py index 6757846a..a74d6084 100644 --- a/tradein-mvp/backend/tests/test_yandex_realty_serp.py +++ b/tradein-mvp/backend/tests/test_yandex_realty_serp.py @@ -1176,3 +1176,119 @@ async def test_walk_price_range_open_bracket_no_split(): assert all( pm is None for pm in seen_prices ), f"Открытый брекет: priceMax всегда None, got {seen_prices}" + + +# --------------------------------------------------------------------------- +# PART J: fetch_around_multi_room on_combo callback (FIX A — incremental save) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_around_multi_room_on_combo_called_per_combo(): + """on_combo вызывается после каждого combo с новыми (деduped) лотами. + + 2 combo (rooms_list=['1'], 2 price_ranges): каждый имеет уникальный лот. + on_combo должен быть вызван дважды — по одному разу на combo. + """ + s = YandexRealtyScraper() + + entity_0 = dict(_ENTITY_FULL, offerId="combo0_lot", url="//realty.yandex.ru/offer/combo0_lot") + entity_1 = dict(_ENTITY_FULL, offerId="combo1_lot", url="//realty.yandex.ru/offer/combo1_lot") + pager = {"page": 0, "pageSize": 20, "totalItems": 1, "totalPages": 1} + + call_idx = 0 + payloads = [ + _make_gate_payload([entity_0], pager), + _make_gate_payload([entity_1], pager), + ] + + async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: + nonlocal call_idx + idx = call_idx + call_idx += 1 + if idx < len(payloads): + return _CurlResponse(status_code=200, text=json.dumps(payloads[idx])) + return _CurlResponse(status_code=200, text=json.dumps(_make_gate_payload([]))) + + on_combo_calls: list[tuple[str, list]] = [] + + def on_combo(label: str, new_lots: list) -> None: + on_combo_calls.append((label, list(new_lots))) + + with patch.object(s, "_http_get", side_effect=fake_http_get): + with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): + lots = await s.fetch_around_multi_room( + lat=0.0, + lon=0.0, + rooms_list=["1"], + price_ranges=[(None, 5_000_000), (5_000_000, 10_000_000)], + max_pages=1, + on_combo=on_combo, + ) + + # Два уникальных лота, два combo. + assert len(lots) == 2 + # on_combo вызван ровно дважды — по одному на combo. + assert len(on_combo_calls) == 2, f"Ожидалось 2 on_combo вызова, получено {len(on_combo_calls)}" + # Каждый on_combo получил ровно один новый лот. + for _label, combo_lots in on_combo_calls: + assert len(combo_lots) == 1 + # Конкретные source_id в правильных batch'ах. + all_ids = {lot.source_id for _, combo_lots in on_combo_calls for lot in combo_lots} + assert all_ids == {"combo0_lot", "combo1_lot"} + + +@pytest.mark.asyncio +async def test_fetch_around_multi_room_on_combo_not_called_for_failed_combo(): + """on_combo НЕ вызывается для combo, у которого все page-1 retries исчерпаны. + + FIX B: bounded retry — если status_code=0 на всех попытках page=1, + combo пропускается. on_combo не должен быть вызван для пропущенного combo. + Успешный combo (второй) — on_combo должен сработать. + """ + s = YandexRealtyScraper() + + entity_ok = dict(_ENTITY_FULL, offerId="ok_lot", url="//realty.yandex.ru/offer/ok_lot") + pager = {"page": 0, "pageSize": 20, "totalItems": 1, "totalPages": 1} + + call_idx = 0 + + async def fake_http_get(url: str, **kwargs: object) -> _CurlResponse: + nonlocal call_idx + call_idx += 1 + # Первые (1 + _YANDEX_TARPIT_MAX_RETRIES) запросы — это retries для combo[0] → fail. + from app.services.scrapers.yandex_realty import _YANDEX_TARPIT_MAX_RETRIES + + if call_idx <= 1 + _YANDEX_TARPIT_MAX_RETRIES: + return _CurlResponse(status_code=0, text="") + # Следующий запрос — combo[1] page=1 → success. + return _CurlResponse( + status_code=200, text=json.dumps(_make_gate_payload([entity_ok], pager)) + ) + + on_combo_calls: list[str] = [] + + def on_combo(label: str, new_lots: list) -> None: + on_combo_calls.append(label) + + with patch.object(s, "_http_get", side_effect=fake_http_get): + with patch.object(s, "_rotate_ip", new_callable=AsyncMock): + with patch("asyncio.sleep", new_callable=AsyncMock): + with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): + lots = await s.fetch_around_multi_room( + lat=0.0, + lon=0.0, + rooms_list=["1"], + price_ranges=[(None, 5_000_000), (5_000_000, 10_000_000)], + max_pages=1, + on_combo=on_combo, + ) + + # combo[0] скипнут, combo[1] успешен → 1 лот в итоге. + assert len(lots) == 1 + assert lots[0].source_id == "ok_lot" + # on_combo вызван только для успешного combo, не для скипнутого. + assert len(on_combo_calls) == 1, ( + f"on_combo должен быть вызван только 1 раз (успешный combo), " + f"получено {len(on_combo_calls)}: {on_combo_calls}" + )