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 = """ + +