From 571be4233bc51cf45f3abbbed3c05c1136016feb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 31 May 2026 06:11:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20yandex=20SERP=20curl=5Fcffi+co?= =?UTF-8?q?okies=20transport=20+=20rooms=C3=97price=20combos=20(T4+T5)=20(?= =?UTF-8?q?#847)=20Co-authored-by:=20bot-backend=20=20Co-committed-by:=20bot-backend=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/core/config.py | 8 + .../app/services/scrapers/yandex_realty.py | 259 +++++++++++++-- .../backend/tests/test_yandex_realty_serp.py | 295 ++++++++++++++++++ 3 files changed, 536 insertions(+), 26 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 08681852..de1891ad 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -164,5 +164,13 @@ class Settings(BaseSettings): # Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу). avito_proxy_max_rotations: int = 2 + # ── Yandex SERP cookies (#801/T4) ─────────────────────────────────────── + # Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]). + # Если задан и файл существует — cookies передаются в curl_cffi-сессию при + # Yandex SERP-запросах; снижает вероятность captcha на datacenter IP. + # Пусто / файл не найден = запросы без cookies (не падаем, только warning). + # ENV: YANDEX_COOKIES_FILE. + yandex_cookies_file: str | None = None + settings = Settings() diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py index 21c34c57..893bb63d 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py @@ -8,10 +8,15 @@ Area extraction: DOM concat-text is empty pre-hydration (~82% NULL). Fix: extrac area from the page-level embedded JSON state (script[id="initial_state_script"] or application/json script), keyed by offerId. DOM regex is kept as fallback. -URL pattern (path-based, no geo radius): - https://realty.yandex.ru/{city}/kupit/kvartira/vtorichniy-rynok/?page=N +Transport (T4): curl_cffi AsyncSession(impersonate="chrome120") — Chrome TLS +fingerprint bypasses Yandex anti-bot gate that returns captcha/shell HTML for plain +httpx. Optional cookies loaded from YANDEX_COOKIES_FILE env path. -Typical: 23 [data-test="OffersSerpItem"] cards per page. +Combos (T5): fetch_around_multi_room supports rooms × price-range combinations to +work around Yandex SERP ~575-card cap per query. Full combos → ~4000 unique listings. + +URL pattern (path-based, no geo radius): + https://realty.yandex.ru/{city}/kupit/kvartira/[{room-slug}/]vtorichniy-rynok/?... """ from __future__ import annotations @@ -20,8 +25,11 @@ import json import logging import re from datetime import date # noqa: F401 (used in type hints / future helpers) +from pathlib import Path from typing import Any +from urllib.parse import urlencode +from curl_cffi.requests import AsyncSession as _CurlCffiSession from selectolax.parser import HTMLParser, Node from app.services.scraper_settings import get_scraper_delay @@ -63,6 +71,61 @@ _STATE_SCRIPT_SELECTORS = [ # Area field candidates in a Yandex offer object (tried in order). _AREA_FIELDS = ("totalArea", "area", "spaceTotal", "spaceAll", "squareTotal") +# Captcha detection markers (checked in first 5000 chars, lowercased). +_CAPTCHA_MARKERS = ("/showcaptcha", "smartcaptcha", "checkboxcaptcha") + +# Path segments for room buckets (Yandex URL canonical slugs). +# Key matches the rooms label used in combos; value = URL path segment. +ROOM_PATH: dict[str, str] = { + "studio": "studiya", + "1": "odnokomnatnaya", + "2": "dvuhkomnatnaya", + "3": "trehkomnatnaya", + "4+": "4-i-bolee", +} + +# Default price ranges (price_min, price_max) in rubles. None = open-ended. +DEFAULT_PRICE_RANGES: list[tuple[int | None, int | None]] = [ + (None, 5_000_000), + (5_000_000, 7_000_000), + (7_000_000, 10_000_000), + (10_000_000, 15_000_000), + (15_000_000, 25_000_000), + (25_000_000, None), +] + + +def _load_cookies_from_file(path: str | None) -> dict[str, str]: + """Load Yandex cookies from JSON file path (Netscape/browser export format). + + Format: [{name: str, value: str, ...}, ...]. + Returns empty dict if path is None, missing, or unparseable — not a failure. + """ + if not path: + return {} + p = Path(path) + if not p.exists(): + logger.warning("yandex cookies file not found: %s — running without cookies", path) + return {} + try: + raw = json.loads(p.read_text(encoding="utf-8")) + result = { + item["name"]: item["value"] + for item in raw + if isinstance(item, dict) and "name" in item and "value" in item + } + logger.info("yandex: loaded %d cookies from %s", len(result), path) + return result + except Exception: + logger.warning("yandex: failed to parse cookies file %s — running without", path) + return {} + + +def _is_captcha(html: str) -> bool: + """Return True if the response looks like a Yandex captcha page.""" + head = html[:5000].lower() + return any(m in head for m in _CAPTCHA_MARKERS) or "докажите, что вы не робот" in head + def _extract_offer_areas_from_state(html: str) -> dict[str, float]: """Extract {offer_id: area_m2} map from Yandex SERP embedded JSON state. @@ -165,6 +228,49 @@ class YandexRealtyScraper(BaseScraper): self.city = city # Load global Yandex delay from DB at instantiation self.request_delay_sec = get_scraper_delay(self.name) + self._cffi_session: _CurlCffiSession | None = None + self._cookies: dict[str, str] = {} + + async def __aenter__(self) -> YandexRealtyScraper: # type: ignore[override] + """Override: Chrome120 TLS impersonation bypasses Yandex anti-bot gate. + + Plain httpx returns captcha/shell-HTML on datacenter IPs. + Sibling pattern: yandex_valuation.py. + Cookies loaded from YANDEX_COOKIES_FILE settings path (optional). + """ + # Deferred import to avoid circular / settings-at-import-time issues + from app.core.config import settings + + self._cookies = _load_cookies_from_file(settings.yandex_cookies_file) + self._cffi_session = _CurlCffiSession( + impersonate="chrome120", + cookies=self._cookies or {}, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + }, + ) + return self + + async def __aexit__(self, *args: object) -> None: # type: ignore[override] + if self._cffi_session is not None: + await self._cffi_session.close() + self._cffi_session = None + + async def _http_get(self, url: str, **kwargs: object) -> object: # type: ignore[override] + """curl_cffi-based GET with Chrome120 impersonation. + + Returns curl_cffi Response (compatible API: .status_code, .text). + Caller must check status_code; no automatic retry (BaseScraper.retry + decorator is per-method and not inherited cleanly when overridden). + """ + if self._cffi_session is None: + raise RuntimeError("YandexRealtyScraper must be used as async context manager") + kwargs.setdefault("timeout", 30) + return await self._cffi_session.get(url, **kwargs) async def fetch_around( self, @@ -172,11 +278,18 @@ class YandexRealtyScraper(BaseScraper): lon: float, radius_m: int = 1000, page: int = 0, + rooms: str | None = None, + price_min: int | None = None, + price_max: int | None = None, ) -> list[ScrapedLot]: - """Fetch ONE page of SERP cards. lat/lon/radius_m ignored (Yandex uses - path-based vtorichka URL); kept for BaseScraper compat + logging. + """Fetch ONE page of SERP cards. + + lat/lon/radius_m ignored (Yandex uses path-based vtorichka URL); + kept for BaseScraper compat + logging. + rooms: room-bucket slug (one of ROOM_PATH keys) or None = all rooms. + price_min/price_max: price filters in rubles (None = open-ended). """ - url = self._build_url(page=page) + url = self._build_url(page=page, rooms=rooms, price_min=price_min, price_max=price_max) try: response = await self._http_get(url) except Exception: @@ -185,14 +298,27 @@ class YandexRealtyScraper(BaseScraper): if response.status_code != 200: logger.warning("yandex serp returned %d for %s", response.status_code, url) return [] - lots = self._parse_html(response.text, page=page) + + # NBSP → space: parse_rub() buggy on \xa0 in capture groups → price=None (T4) + html = response.text.replace("\xa0", " ") + + if _is_captcha(html): + logger.warning( + "yandex serp: captcha detected on page=%d url=%s — returning empty", + page, + url, + ) + return [] + + lots = self._parse_html(html, page=page) logger.info( - "yandex serp page=%d city=%s: %d cards (anchor=(%.4f,%.4f) ignored)", + "yandex serp page=%d city=%s rooms=%s price=[%s-%s]: %d cards", page, self.city, + rooms or "all", + price_min, + price_max, len(lots), - lat, - lon, ) await self.sleep_between_requests() return lots @@ -203,34 +329,101 @@ class YandexRealtyScraper(BaseScraper): lon: float, radius_m: int = 1000, max_pages: int = MAX_PAGES, - **_legacy_kwargs: Any, # swallow rooms/sorts/pages from old callers + rooms_list: list[str] | None = None, + price_ranges: list[tuple[int | None, int | None]] | None = None, + **_legacy_kwargs: Any, # swallow old callers' kwargs ) -> list[ScrapedLot]: - """Fetch up to max_pages of SERP cards, dedup by offer_id (source_id).""" + """Fetch SERP via rooms × price-range combos to bypass SERP ~575-card cap. + + When rooms_list/price_ranges are provided (T5 combos mode), iterates all + (room, price_min, price_max) combinations, each up to max_pages pages, + deduplicating by source_id/source_url across combos. + + When both are None (legacy mode), falls back to single citywide sweep + (original behaviour — up to max_pages pages, no filtering). + """ seen: dict[str, ScrapedLot] = {} - for page in range(max_pages): - lots = await self.fetch_around(lat, lon, radius_m, page=page) - if not lots: - break # empty page → no more results - for lot in lots: - key = lot.source_id or lot.source_url - if key and key not in seen: - seen[key] = lot + + if rooms_list is None and price_ranges is None: + # Legacy / citywide mode — no combos + combos: list[tuple[str | None, int | None, int | None]] = [(None, None, None)] + else: + r_list = rooms_list or list(ROOM_PATH.keys()) + p_ranges = price_ranges or DEFAULT_PRICE_RANGES + combos = [(r, lo, hi) for r in r_list for lo, hi in p_ranges] + + for rooms, price_min, price_max in combos: + combo_label = _combo_label(rooms, price_min, price_max) + for page in range(max_pages): + lots = await self.fetch_around( + lat, + lon, + radius_m, + page=page, + rooms=rooms, + price_min=price_min, + price_max=price_max, + ) + if not lots: + logger.debug( + "yandex combos [%s] page=%d: empty — stopping combo", + combo_label, + page, + ) + break + for lot in lots: + key = lot.source_id or lot.source_url + if key and key not in seen: + seen[key] = lot + logger.info( - "yandex serp aggregate: %d unique lots over %d pages (city=%s)", + "yandex serp aggregate: %d unique lots (city=%s, %d combos)", len(seen), - max_pages, self.city, + len(combos), ) return list(seen.values()) - def _build_url(self, page: int = 0) -> str: - # Yandex paginates 1-based; `page=0` → no param (first page) - base = f"{self.base_url}/{self.city}/kupit/kvartira/vtorichniy-rynok/" + def _build_url( + self, + page: int = 0, + rooms: str | None = None, + price_min: int | None = None, + price_max: int | None = None, + ) -> str: + """Build Yandex SERP URL. + + rooms: key from ROOM_PATH for path-based room bucket; None = all rooms. + price_min/price_max: optional price filters in rubles. + newFlat=NO_DEAL is added when any price filter is set — prevents Yandex + from canonicalizing away /vtorichniy-rynok/ for some room slugs. + """ + if rooms and rooms in ROOM_PATH: + path = f"/{self.city}/kupit/kvartira/{ROOM_PATH[rooms]}/vtorichniy-rynok/" + else: + path = f"/{self.city}/kupit/kvartira/vtorichniy-rynok/" + base = f"{self.base_url}{path}" + + params: dict[str, str | int] = {} + if price_min is not None: + params["priceMin"] = price_min + if price_max is not None: + params["priceMax"] = price_max + if price_min is not None or price_max is not None: + # Guarantee vtorichka — Yandex sometimes drops /vtorichniy-rynok/ on canonicalize + params["newFlat"] = "NO_DEAL" if page > 0: - return f"{base}?page={page}" + params["page"] = page + + if params: + return f"{base}?{urlencode(params)}" return base def _parse_html(self, html: str, page: int = 0) -> list[ScrapedLot]: + # NBSP-fix applied by caller (fetch_around) before _parse_html; + # also applied here as a safety net for direct callers (tests, scripts). + html = html.replace("\xa0", " ") + # Extract offer-level area from embedded JSON state first (covers ~82% of # cards where DOM text is empty pre-hydration). Falls back to DOM regex per card. state_areas = _extract_offer_areas_from_state(html) @@ -405,3 +598,17 @@ class YandexRealtyScraper(BaseScraper): if len(urls) >= 5: break return urls + + +def _combo_label( + rooms: str | None, + price_min: int | None, + price_max: int | None, +) -> str: + """Human-readable label for a (rooms, price_min, price_max) combo.""" + r = rooms or "all-rooms" + if price_min is None and price_max is None: + return f"{r}/any-price" + lo = f"{price_min // 1_000_000}M" if price_min is not None else "0" + hi = f"{price_max // 1_000_000}M" if price_max is not None else "inf" + return f"{r}/{lo}-{hi}" diff --git a/tradein-mvp/backend/tests/test_yandex_realty_serp.py b/tradein-mvp/backend/tests/test_yandex_realty_serp.py index e4e61810..dc66109a 100644 --- a/tradein-mvp/backend/tests/test_yandex_realty_serp.py +++ b/tradein-mvp/backend/tests/test_yandex_realty_serp.py @@ -2,14 +2,21 @@ import json from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from app.services.scrapers.base import ScrapedLot from app.services.scrapers.yandex_realty import ( DEFAULT_CITY, + DEFAULT_PRICE_RANGES, MAX_PAGES, + ROOM_PATH, YandexRealtyScraper, + _combo_label, _extract_offer_areas_from_state, + _is_captcha, + _load_cookies_from_file, ) # Realistic single-card fixture (trimmed but selector-faithful). @@ -339,3 +346,291 @@ def test_extract_offer_areas_empty_when_no_script(): html = "

no state here

" areas = _extract_offer_areas_from_state(html) assert areas == {} + + +# ── PART B: T4 transport + NBSP fix ───────────────────────────────────────── + + +def test_nbsp_price_fix(): + """NBSP (\xa0) in price tokens must not break parse_rub → price_rub must be non-None. + + Yandex uses \xa0 as thousands separator in prices. Without the replace, parse_rub + returns None because regex can't capture across NBSP — the card is then skipped. + """ + # Price with \xa0 thousands separator (e.g. "4\xa0399\xa0000 ₽") + nbsp_card_html = """ + +
+ x +
36,8\xa0м²\xa0·\xa01-комнатная\xa0·\xa08\xa0этаж\xa0из\xa09\xa0·\xa04\xa0399\xa0000\xa0₽
+
+""" + s = YandexRealtyScraper() + # _parse_html applies NBSP-fix before parsing — price must be extracted + lots = s._parse_html(nbsp_card_html, page=0) + assert len(lots) == 1 + assert lots[0].price_rub is not None + assert lots[0].price_rub > 0 + + +def test_is_captcha_detects_showcaptcha(): + """HTML containing /showcaptcha must be flagged as captcha.""" + html = '' + assert _is_captcha(html) is True + + +def test_is_captcha_detects_smartcaptcha(): + """HTML with smartcaptcha JS is flagged.""" + html = "" + assert _is_captcha(html) is True + + +def test_is_captcha_clean_page(): + """Normal SERP page is not flagged as captcha.""" + assert _is_captcha(SINGLE_CARD_HTML) is False + + +def test_is_captcha_robot_text(): + """Russian 'докажите, что вы не робот' message is detected.""" + html = "Докажите, что вы не робот — введите код." + assert _is_captcha(html) is True + + +def test_load_cookies_from_file_missing_path(): + """None or missing path → empty dict, no exception.""" + assert _load_cookies_from_file(None) == {} + assert _load_cookies_from_file("/nonexistent/path.json") == {} + + +def test_load_cookies_from_file_valid(tmp_path): + """Valid Netscape-format cookie JSON → dict with name→value.""" + data = [ + {"name": "yandex_login", "value": "user123", "domain": ".yandex.ru"}, + {"name": "Session_id", "value": "abc456", "domain": ".yandex.ru"}, + {"missing_name_key": "ignored"}, + ] + f = tmp_path / "cookies.json" + f.write_text(json.dumps(data), encoding="utf-8") + result = _load_cookies_from_file(str(f)) + assert result == {"yandex_login": "user123", "Session_id": "abc456"} + + +# ── PART C: T5 combos — _build_url with rooms + price filters ─────────────── + + +def test_build_url_with_rooms(): + """Room slug mapped correctly to Yandex path segment.""" + s = YandexRealtyScraper() + url = s._build_url(page=0, rooms="1") + assert "/odnokomnatnaya/vtorichniy-rynok/" in url + assert "page" not in url + + +def test_build_url_with_rooms_2(): + s = YandexRealtyScraper() + url = s._build_url(page=0, rooms="2") + assert "/dvuhkomnatnaya/vtorichniy-rynok/" in url + + +def test_build_url_with_rooms_studio(): + s = YandexRealtyScraper() + url = s._build_url(page=0, rooms="studio") + assert "/studiya/vtorichniy-rynok/" in url + + +def test_build_url_with_price_range(): + """Price range adds priceMin/priceMax + newFlat=NO_DEAL.""" + s = YandexRealtyScraper() + url = s._build_url(page=0, price_min=5_000_000, price_max=7_000_000) + assert "priceMin=5000000" in url + assert "priceMax=7000000" in url + assert "newFlat=NO_DEAL" in url + # No rooms → base citywide path + assert "/vtorichniy-rynok/" in url + assert "odnokomnatnaya" not in url + + +def test_build_url_rooms_and_price_and_page(): + """All parameters combined: rooms + price + paginated.""" + s = YandexRealtyScraper() + url = s._build_url(page=3, rooms="3", price_min=10_000_000, price_max=15_000_000) + assert "/trehkomnatnaya/vtorichniy-rynok/" in url + assert "priceMin=10000000" in url + assert "priceMax=15000000" in url + assert "newFlat=NO_DEAL" in url + assert "page=3" in url + + +def test_build_url_open_ended_price_min_only(): + """Only priceMin set → no priceMax param but newFlat present.""" + s = YandexRealtyScraper() + url = s._build_url(price_min=25_000_000) + assert "priceMin=25000000" in url + assert "priceMax" not in url + assert "newFlat=NO_DEAL" in url + + +def test_build_url_open_ended_price_max_only(): + """Only priceMax → no priceMin param.""" + s = YandexRealtyScraper() + url = s._build_url(price_max=5_000_000) + assert "priceMax=5000000" in url + assert "priceMin" not in url + assert "newFlat=NO_DEAL" in url + + +def test_build_url_unknown_rooms_falls_back_to_citywide(): + """Unknown/invalid rooms key falls back to citywide (no room path segment).""" + s = YandexRealtyScraper() + url = s._build_url(rooms="99-room-penthouse") + assert "/kupit/kvartira/vtorichniy-rynok/" in url + assert "studiya" not in url + assert "odnokomnatnaya" not in url + + +def test_combos_produce_different_urls(): + """T5: rooms × price-ranges produce distinct URLs (no URL collisions).""" + s = YandexRealtyScraper() + urls = set() + rooms_list = list(ROOM_PATH.keys()) + for rooms in rooms_list: + for lo, hi in DEFAULT_PRICE_RANGES: + urls.add(s._build_url(page=0, rooms=rooms, price_min=lo, price_max=hi)) + expected = len(rooms_list) * len(DEFAULT_PRICE_RANGES) + assert len(urls) == expected, f"URL collisions: {expected} combos but {len(urls)} unique URLs" + + +def test_combo_label_all(): + assert _combo_label(None, None, None) == "all-rooms/any-price" + + +def test_combo_label_room_and_price(): + assert _combo_label("1", 5_000_000, 7_000_000) == "1/5M-7M" + + +def test_combo_label_open_ended(): + assert _combo_label("4+", 25_000_000, None) == "4+/25M-inf" + assert _combo_label("studio", None, 5_000_000) == "studio/0-5M" + + +# ── PART D: T4 transport — async context manager + _http_get mock ─────────── + + +@pytest.mark.asyncio +async def test_aenter_creates_cffi_session(): + """__aenter__ creates a _CurlCffiSession (Chrome120 impersonation).""" + mock_settings = MagicMock() + mock_settings.yandex_cookies_file = None + + with patch("app.services.scrapers.yandex_realty._CurlCffiSession") as mock_cls: + mock_session = AsyncMock() + mock_cls.return_value = mock_session + with patch("app.services.scrapers.yandex_realty._load_cookies_from_file", return_value={}): + # Patch settings import inside __aenter__ without triggering Settings() + with patch.dict("sys.modules", {"app.core.config": MagicMock(settings=mock_settings)}): + s = YandexRealtyScraper() + result = await s.__aenter__() + assert result is s + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args + assert call_kwargs.kwargs.get("impersonate") == "chrome120" + + +@pytest.mark.asyncio +async def test_aexit_closes_session(): + """__aexit__ closes the curl_cffi session and sets it to None.""" + s = YandexRealtyScraper() + mock_session = AsyncMock() + s._cffi_session = mock_session + await s.__aexit__(None, None, None) + mock_session.close.assert_awaited_once() + assert s._cffi_session is None + + +@pytest.mark.asyncio +async def test_http_get_raises_when_no_context(): + """_http_get must raise RuntimeError when called outside async context manager.""" + s = YandexRealtyScraper() + with pytest.raises(RuntimeError, match="async context manager"): + await s._http_get("https://realty.yandex.ru/test/") + + +@pytest.mark.asyncio +async def test_fetch_around_returns_empty_on_captcha(): + """fetch_around returns [] and logs warning when captcha detected.""" + s = YandexRealtyScraper() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = ( + "Докажите, что вы не робот. " + 'пройдите проверку' + ) + s._cffi_session = AsyncMock() + s._cffi_session.get = AsyncMock(return_value=mock_resp) + + with patch.object(s, "sleep_between_requests", new_callable=AsyncMock): + lots = await s.fetch_around(lat=0.0, lon=0.0) + assert lots == [] + + +@pytest.mark.asyncio +async def test_fetch_around_multi_room_dedup(): + """fetch_around_multi_room deduplicates offers seen in multiple combos.""" + s = YandexRealtyScraper() + + # Two combos both return the same offer_id "1234" plus unique ids + combo_results: list[list[ScrapedLot]] = [] + for i in range(2): + lot_shared = MagicMock(spec=ScrapedLot) + lot_shared.source_id = "shared_offer_1234" + lot_shared.source_url = "https://realty.yandex.ru/offer/shared_offer_1234/" + + lot_unique = MagicMock(spec=ScrapedLot) + lot_unique.source_id = f"unique_offer_{i}" + lot_unique.source_url = f"https://realty.yandex.ru/offer/unique_offer_{i}/" + + combo_results.append([lot_shared, lot_unique]) + + call_count = 0 + + async def fake_fetch_around(*args, **kwargs): + nonlocal call_count + idx = call_count + call_count += 1 + if idx < len(combo_results): + return combo_results[idx] + return [] + + with patch.object(s, "fetch_around", side_effect=fake_fetch_around): + 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, 7_000_000)], + max_pages=1, + ) + + # shared_offer_1234 appears once; 2 unique offers + ids = {lot.source_id for lot in lots} + assert "shared_offer_1234" in ids + assert len(ids) == 3 # shared + 2 unique + + +@pytest.mark.asyncio +async def test_fetch_around_multi_room_legacy_no_combos(): + """Legacy mode (no rooms_list/price_ranges) uses single citywide sweep.""" + s = YandexRealtyScraper() + calls: list[dict] = [] + + async def fake_fetch_around( + lat, lon, radius_m=1000, page=0, rooms=None, price_min=None, price_max=None + ): + calls.append({"page": page, "rooms": rooms, "price_min": price_min}) + return [] # empty → stops pagination + + with patch.object(s, "fetch_around", side_effect=fake_fetch_around): + await s.fetch_around_multi_room(lat=0.0, lon=0.0, max_pages=3) + + # Should call with rooms=None, price_min=None (citywide) + assert all(c["rooms"] is None for c in calls) + assert all(c["price_min"] is None for c in calls)