gendesign/tradein-mvp/backend/app/services/scrapers/yandex_realty.py
lekss361 440bb2ca51
All checks were successful
Deploy Trade-In / deploy (push) Successful in 53s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / build-backend (push) Successful in 54s
feat(tradein/yandex): normalize house_type + promote kitchen/ceiling to columns (#2007)
house_type normalized via shared house_type_normalizer (canon = _IMV_HOUSE_TYPE_MAP) — fixes estimator soft-penalty false-mismatch on ~70% yandex analogs; backfill migration 140 (SCREAMING->canon + 'other'->NULL). kitchen/ceiling promoted to columns (dashboard/matching, not valuation — see #2012); ceiling capped 2.0-6.0m to avoid numeric(3,2) overflow. Refs #2007
2026-06-27 17:45:11 +00:00

1085 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Yandex.Nedvizhimost scraper (realty.yandex.ru) -- gate-API JSON parser.
Transport: BrowserFetcher (camoufox real-browser fingerprint via tradein-browser service).
camoufox's fingerprint is not tarpitted by yandex and does not burn the mobile-proxy IP;
raw system-curl over SOCKS5 gets the proxy IP flagged. One BrowserFetcher context is opened
per scraper session (__aenter__) and reused across all page fetches.
camoufox returns the gate-API JSON wrapped in HTML (verified prod 2026-06-17):
<html><head>...</head><body><pre>{"response":...}</pre></body></html>
JSON is extracted via _extract_json_from_content() (handles the <pre> wrapper).
API path: GET https://realty.yandex.ru/gate/react-page/get/
_pageType=search&_providers=react-search-results-data
type=SELL&category=APARTMENT&newFlat=NO&rgid=559132 (EKB)
roomsTotal=<int|STUDIO>&priceMin=...&priceMax=...&page=<1-based>
Response shape: response.search.offers.{entities[], pager{page,pageSize,totalItems,totalPages}}
Page param is 1-based: page=1 -> first page (pager.page=0 in response).
page=0 -> API returns error. Paginate from page=1 to totalPages (inclusive).
Studio roomsTotal:
Request: roomsTotal=STUDIO
Response entities: roomsTotal=None -> mapped to rooms=0 (existing convention).
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import re
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import Any
from urllib.parse import urlencode
from curl_cffi.requests import AsyncSession as _CurlCffiSession
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.base import BaseScraper, ScrapedLot
from app.services.scrapers.browser_fetcher import BrowserFetcher
from app.services.scrapers.house_type_normalizer import normalize_house_type
from app.services.scrapers.price_brackets import get_price_seed_brackets
logger = logging.getLogger(__name__)
# camoufox renders JSON endpoints as <pre>{"response":...}</pre> inside an HTML wrapper.
_RE_PRE_JSON = re.compile("<pre[^>]*>(\\{.*?)</pre>", re.DOTALL | re.IGNORECASE)
DEFAULT_CITY = "ekaterinburg"
_GATE_MAX_PAGES_CAP = 50
_EKB_RGID = 559132
_GATE_URL = "https://realty.yandex.ru/gate/react-page/get/"
ROOM_GATE_PARAM: dict[str, str | int] = {
"studio": "STUDIO",
"1": 1,
"2": 2,
"3": 3,
"4+": 4,
}
ROOM_PATH: dict[str, str] = {
"studio": "studiya",
"1": "odnokomnatnaya",
"2": "dvuhkomnatnaya",
"3": "trehkomnatnaya",
"4+": "4-i-bolee",
}
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),
]
_YANDEX_MAX_PRICE = 200_000_000
_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:
status_code: int
text: str
def _is_gate_error(payload: dict[str, Any]) -> bool:
"""Return True when gate-API returned an error object instead of search results."""
return "error" in payload or "response" not in payload
def _extract_gate_data(
payload: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]] | None:
"""Extract (entities, pager) from valid gate-API response. None on missing path."""
try:
offers = payload["response"]["search"]["offers"]
entities: list[dict[str, Any]] = offers["entities"]
pager: dict[str, Any] = offers["pager"]
return entities, pager
except (KeyError, TypeError):
return None
def _extract_json_from_content(content: str) -> str | None:
"""Extract raw JSON text from camoufox HTML-wrapped content.
camoufox renders a JSON endpoint as:
<html><head>...</head><body><pre>{"response":...}</pre></body></html>
Strategy:
1. Try <pre>...</pre> regex (primary -- matches prod output).
2. Fallback: slice from the first '{' (handles raw-JSON response, no wrapper).
Returns the JSON string, or None if no '{' is found.
"""
m = _RE_PRE_JSON.search(content)
if m:
return m.group(1)
idx = content.find("{")
if idx != -1:
return content[idx:]
return None
def _segment_for(new_flat: str) -> str:
"""Map a newFlat gate-API value to the listings.listing_segment value."""
return "novostroyki" if new_flat == "YES" else "vtorichka"
def _parse_creation_date(raw: Any) -> tuple[date | None, int | None]:
"""Parse gate-API creationDate ('2026-05-24T15:10:27Z') → (date, days_on_market).
days_on_market = floor((now - creationDate).days), clamped to >= 0.
Returns (None, None) when raw is absent or unparseable (never raises).
"""
if not raw or not isinstance(raw, str):
return None, None
txt = raw.strip()
# datetime.fromisoformat handles 'Z' only from py3.11+, but normalise defensively.
if txt.endswith("Z"):
txt = txt[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(txt)
except ValueError:
return None, None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
listing_date = dt.date()
delta_days = (datetime.now(UTC) - dt).days
days_on_market = max(delta_days, 0)
return listing_date, days_on_market
def _author_fields(author: dict[str, Any]) -> tuple[bool | None, bool | None, str | None]:
"""Map entity.author → (is_homeowner, is_pro_seller, agency_name).
author.category ∈ {OWNER, AGENCY, AGENT, ...}.
OWNER → is_homeowner=True
AGENCY / AGENT → is_pro_seller=True, agency_name = organization or agentName
Returns (None, None, None) when author/category absent.
"""
category = (author.get("category") or "").upper()
if not category:
return None, None, None
if category == "OWNER":
return True, None, None
if category in {"AGENCY", "AGENT"}:
agency_name = author.get("organization") or author.get("agentName") or None
return None, True, agency_name
return None, None, None
def _to_bigint(raw: Any) -> int | None:
"""Coerce a gate-API numeric value (str or number) to int. None when unparseable."""
if raw is None:
return None
try:
return int(float(raw))
except (TypeError, ValueError):
return None
def _predicted_prices(entity: dict[str, Any]) -> tuple[int | None, int | None, int | None]:
"""Extract (value, min, max) from entity.predictions.predictedPrice.
Yandex's own per-offer valuation. The gate-API nests it under
``predictions.predictedPrice`` (NOT ``predictedPrice.predictedPrice`` —
that key does not exist; verified live 2026-06-18 against the gate-API).
Fields are strings (e.g. "7554000"). Returns (None, None, None) when the
nested path is absent (e.g. novostroyki offers carry no valuation).
"""
outer = entity.get("predictions") or {}
inner = outer.get("predictedPrice") or {}
if not isinstance(inner, dict):
return None, None, None
return (
_to_bigint(inner.get("value")),
_to_bigint(inner.get("min")),
_to_bigint(inner.get("max")),
)
def _metro_stations(location: dict[str, Any]) -> list[dict[str, Any]]:
"""Build metro_stations [{name, min}] from location.metroList[] (+ location.metro)."""
stations: list[dict[str, Any]] = []
raw_list = location.get("metroList")
if isinstance(raw_list, list):
for m in raw_list:
if isinstance(m, dict) and m.get("name"):
stations.append({"name": m.get("name"), "min": m.get("timeToMetro")})
if not stations:
single = location.get("metro")
if isinstance(single, dict) and single.get("name"):
stations.append({"name": single.get("name"), "min": single.get("timeToMetro")})
return stations
def _parse_gate_json(
payload: dict[str, Any], page_param: int = 1, new_flat: str = "NO"
) -> list[ScrapedLot]:
"""Parse gate-API JSON payload into a list of ScrapedLot.
Verified field mapping (from .issdump/yandex_gate_api_sample.json + live prod 2026-06-17):
offerId -> source_id
url (//realty.yandex.ru/offer/) -> source_url (prepend "https:")
price.value -> price_rub
price.valuePerPart -> price_per_m2
area.value -> area_m2
livingSpace.value -> living_area_m2
kitchenSpace.value -> kitchen_area_m2 (+ raw_payload["kitchen_area_m2"])
roomsTotal (int | None) -> rooms (None -> 0 for studio)
floorsOffered[0] -> floor
floorsTotal -> total_floors
building.builtYear -> year_built
building.buildingType -> house_type (normalize_house_type: MONOLIT->monolith)
ceilingHeight -> ceiling_height_m (+ raw_payload["ceiling_height"])
location.point.latitude/longitude -> lat / lon
location.geocoderAddress -> address (full, with house number)
mainImages[] -> photo_urls (prepend "https:", up to 5)
building.siteId -> house_ext_id (JK linkage)
listing_segment -> "novostroyki" (newFlat=YES) | "vtorichka" (newFlat=NO)
creationDate (ISO Z) -> listing_date / publish_date + days_on_market
description -> description
author.category/organization -> is_homeowner / is_pro_seller / agency_name
predictedPrice.predictedPrice.* -> predicted_price_rub / _min / _max
price.trend / price.previous -> price_trend / price_previous_rub
location.metroList[] -> metro_stations [{name, min}]
offerId -> yandex_offer_id
"""
result = _extract_gate_data(payload)
if result is None:
logger.warning("yandex gate: response missing response.search.offers path")
return []
entities, _pager = result
lots: list[ScrapedLot] = []
for entity in entities:
lot = _entity_to_lot(entity, page_param=page_param, new_flat=new_flat)
if lot is not None:
lots.append(lot)
return lots
def _entity_to_lot(
entity: dict[str, Any], page_param: int = 1, new_flat: str = "NO"
) -> ScrapedLot | None:
"""Convert one gate-API entity dict to ScrapedLot. None on missing required fields."""
try:
offer_id = str(entity.get("offerId") or "")
if not offer_id:
return None
url_raw = entity.get("url") or ""
source_url = f"https:{url_raw}" if url_raw.startswith("//") else url_raw
if not source_url:
source_url = f"https://realty.yandex.ru/offer/{offer_id}/"
price_dict = entity.get("price") or {}
price_val = price_dict.get("value")
if not price_val or price_val <= 0:
return None
price_rub = int(price_val)
price_per_m2_raw = price_dict.get("valuePerPart")
price_per_m2 = int(price_per_m2_raw) if price_per_m2_raw else None
area_dict = entity.get("area") or {}
area_m2_raw = area_dict.get("value")
area_m2 = float(area_m2_raw) if area_m2_raw is not None else None
living_dict = entity.get("livingSpace") or {}
living_area_raw = living_dict.get("value")
living_area_m2 = float(living_area_raw) if living_area_raw is not None else None
kitchen_dict = entity.get("kitchenSpace") or {}
kitchen_area_raw = kitchen_dict.get("value")
kitchen_area_m2 = float(kitchen_area_raw) if kitchen_area_raw is not None else None
rooms_raw = entity.get("roomsTotal")
if rooms_raw is None:
rooms = 0 # studio
else:
try:
rooms = int(rooms_raw)
except (TypeError, ValueError):
rooms = None
floors_offered = entity.get("floorsOffered") or []
floor = int(floors_offered[0]) if floors_offered else None
floors_total_raw = entity.get("floorsTotal")
total_floors = int(floors_total_raw) if floors_total_raw is not None else None
ceiling_height = entity.get("ceilingHeight")
# Колонка ceiling_height — numeric(3,2), max 9.99. Yandex SERP отдаёт мусор
# (видели 18 м) → запись out-of-range уронила бы весь батч DataError'ом
# (per-lot SAVEPOINT ловит только IntegrityError). Берём только правдоподобный
# диапазон 2.06.0 м, иначе None; сырое значение остаётся в raw_payload. (#2007)
_ceiling_raw = float(ceiling_height) if ceiling_height is not None else None
ceiling_height_m = (
_ceiling_raw if _ceiling_raw is not None and 2.0 <= _ceiling_raw <= 6.0 else None
)
building = entity.get("building") or {}
year_built_raw = building.get("builtYear")
year_built = int(year_built_raw) if year_built_raw is not None else None
# buildingType — SCREAMING (MONOLIT/BRICK/...). Нормализуем в канон, иначе
# estimator soft-penalty штрафует yandex-аналоги (#2007).
raw_building_type = building.get("buildingType")
house_type = normalize_house_type(raw_building_type)
site_id = building.get("siteId")
site_name = building.get("siteName")
house_source: str | None = None
house_ext_id: str | None = None
if site_id:
house_source = "yandex_realty_nb"
house_ext_id = str(site_id)
location = entity.get("location") or {}
point = location.get("point") or {}
lat_raw = point.get("latitude")
lon_raw = point.get("longitude")
lat = float(lat_raw) if lat_raw is not None else None
lon = float(lon_raw) if lon_raw is not None else None
address = location.get("geocoderAddress") or location.get("streetAddress") or None
# ── Rich gate-API fields ───────────────────────────────────────────
listing_date, days_on_market = _parse_creation_date(entity.get("creationDate"))
description = entity.get("description") or None
author = entity.get("author") or {}
is_homeowner, is_pro_seller, agency_name = _author_fields(author)
predicted_value, predicted_min, predicted_max = _predicted_prices(entity)
price_trend = price_dict.get("trend") or None
price_previous_rub = _to_bigint(price_dict.get("previous"))
metro_stations = _metro_stations(location)
photo_urls: list[str] = []
for img in entity.get("mainImages") or []:
if img:
photo_urls.append(f"https:{img}" if img.startswith("//") else img)
if len(photo_urls) >= 5:
break
return ScrapedLot(
source="yandex",
source_url=source_url,
source_id=offer_id,
address=address,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area_m2,
living_area_m2=living_area_m2,
floor=floor,
total_floors=total_floors,
year_built=year_built,
house_type=house_type,
kitchen_area_m2=kitchen_area_m2,
ceiling_height_m=ceiling_height_m,
price_rub=price_rub,
price_per_m2=price_per_m2,
photo_urls=photo_urls,
house_source=house_source,
house_ext_id=house_ext_id,
house_url=None,
listing_segment=_segment_for(new_flat),
listing_date=listing_date,
publish_date=listing_date,
days_on_market=days_on_market,
description=description,
is_homeowner=is_homeowner,
is_pro_seller=is_pro_seller,
agency_name=agency_name,
metro_stations=metro_stations,
yandex_offer_id=offer_id,
predicted_price_rub=predicted_value,
predicted_price_min=predicted_min,
predicted_price_max=predicted_max,
price_trend=price_trend,
price_previous_rub=price_previous_rub,
raw_payload={
"page_param": page_param,
"ceiling_height": ceiling_height,
"kitchen_area_m2": kitchen_area_raw,
"raw_building_type": raw_building_type, # сырой SCREAMING — для отладки
"site_name": site_name,
"offer_id": offer_id,
},
)
except Exception:
logger.exception("yandex gate: entity parse failed offer_id=%s", entity.get("offerId"))
return None
class YandexRealtyScraper(BaseScraper):
name = "yandex"
base_url = "https://realty.yandex.ru"
request_delay_sec = 5.0
def __init__(self, city: str = DEFAULT_CITY) -> None:
super().__init__()
self.city = city
self.request_delay_sec = get_scraper_delay(self.name)
self._browser: BrowserFetcher | None = None
# _cffi_session retained only for _rotate_ip (changeip call).
self._cffi_session: _CurlCffiSession | None = None
self._cookies: dict[str, str] = {}
async def __aenter__(self) -> YandexRealtyScraper: # type: ignore[override]
"""Open ONE BrowserFetcher (camoufox) session, reused across all page fetches.
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", fetch_timeout_s=_YANDEX_BROWSER_FETCH_TIMEOUT_S
)
await self._browser.__aenter__()
return self
async def __aexit__(self, *args: object) -> None: # type: ignore[override]
if self._browser is not None:
await self._browser.__aexit__(*args)
self._browser = None
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) -> _CurlResponse: # type: ignore[override]
"""Fetch gate-API URL via the shared BrowserFetcher (camoufox).
camoufox returns HTML-wrapped JSON; the JSON is extracted and returned as
the body of a _CurlResponse so existing callers (json.loads(resp.text),
resp.status_code checks) keep working unchanged.
Maps outcomes onto the curl-style status contract:
- JSON extracted -> status_code=200, text=<json>
- fetch raised / no JSON found -> status_code=0 (treated as transient
failure by the caller retry logic)
"""
if self._browser is None:
raise RuntimeError("YandexRealtyScraper must be used as async context manager")
try:
content = await self._browser.fetch(url)
except Exception:
logger.warning("yandex gate: BrowserFetcher.fetch failed url=%s", url, exc_info=True)
return _CurlResponse(status_code=0, text="")
json_text = _extract_json_from_content(content)
if not json_text:
logger.warning(
"yandex gate: could not extract JSON from content url=%s first200=%r",
url,
content[:200],
)
return _CurlResponse(status_code=0, text="")
return _CurlResponse(status_code=200, text=json_text)
async def _rotate_ip(self) -> bool:
"""Rotate mobile proxy IP via changeip URL. Returns True on success."""
from app.core.config import settings as _settings
rotate_url = _settings.yandex_proxy_rotate_url or _settings.avito_proxy_rotate_url
if not rotate_url:
return False
sep = "&" if "?" in rotate_url else "?"
try:
async with _CurlCffiSession(timeout=30) as rot:
await rot.get(f"{rotate_url}{sep}format=json")
await asyncio.sleep(9)
logger.info("yandex proxy: IP rotated via changeip")
return True
except Exception:
logger.warning("yandex proxy: IP rotation failed", exc_info=True)
return False
def _build_url(
self,
page: int = 1,
rooms: str | None = None,
price_min: int | None = None,
price_max: int | None = None,
new_flat: str = "NO",
) -> str:
"""Build gate-API URL. page: 1-based (page=0 -> API error).
new_flat: "NO" -> vtorichka (default, preserves legacy behavior),
"YES" -> novostroyki (primary-sale / reassignment segment).
"""
params: dict[str, str | int] = {
"rgid": _EKB_RGID,
"type": "SELL",
"category": "APARTMENT",
"newFlat": new_flat,
"_pageType": "search",
"_providers": "react-search-results-data",
"page": page,
}
if rooms and rooms in ROOM_GATE_PARAM:
params["roomsTotal"] = ROOM_GATE_PARAM[rooms]
if price_min is not None:
params["priceMin"] = price_min
if price_max is not None:
params["priceMax"] = price_max
return f"{_GATE_URL}?{urlencode(params)}"
async def _fetch_page_json(
self,
rooms: str | None,
page: int,
price_min: int | None,
price_max: int | None,
new_flat: str = "NO",
) -> dict[str, Any] | None:
"""Fetch one gate-API page. Returns parsed JSON dict or None on error/invalid."""
url = self._build_url(
page=page, rooms=rooms, price_min=price_min, price_max=price_max, new_flat=new_flat
)
try:
resp = await self._http_get(url, timeout=60)
except Exception:
logger.exception("yandex gate: GET failed url=%s", url)
return None
if resp.status_code != 200: # type: ignore[union-attr]
logger.warning("yandex gate: HTTP %d url=%s", resp.status_code, url) # type: ignore[union-attr]
return None
try:
payload: dict[str, Any] = json.loads(resp.text) # type: ignore[union-attr]
except (json.JSONDecodeError, ValueError):
logger.warning("yandex gate: JSON parse failed url=%s", url)
return None
if _is_gate_error(payload):
logger.warning("yandex gate: error response url=%s keys=%s", url, list(payload.keys()))
return None
return payload
async def fetch_around(
self,
lat: float,
lon: float,
radius_m: int = 1000,
page: int = 1,
rooms: str | None = None,
price_min: int | None = None,
price_max: int | None = None,
new_flat: str = "NO",
) -> list[ScrapedLot]:
"""Fetch ONE page of gate-API results. lat/lon/radius_m ignored (uses rgid).
page: 1-based. Tarpit resilience: status_code==0 or JSON error
-> rotate IP + retry up to _YANDEX_TARPIT_MAX_RETRIES times.
Non-tarpit failures (4xx/5xx) are not retried.
new_flat: "NO" -> vtorichka, "YES" -> novostroyki.
"""
url = self._build_url(
page=page, rooms=rooms, price_min=price_min, price_max=price_max, new_flat=new_flat
)
payload: dict[str, Any] | None = None
for attempt in range(1 + _YANDEX_TARPIT_MAX_RETRIES):
try:
response = await self._http_get(url, timeout=60)
except Exception:
logger.exception("yandex gate fetch failed: %s", url)
return []
status = response.status_code # type: ignore[union-attr]
if status == 0:
logger.warning(
"yandex gate: status=0 (tarpit?) rooms=%s page=%d"
" -- rotating IP + retry attempt %d",
rooms,
page,
attempt + 1,
)
await self._rotate_ip()
await asyncio.sleep(2)
continue
if status != 200:
logger.warning(
"yandex gate: HTTP %d rooms=%s page=%d url=%s", status, rooms, page, url
)
return []
try:
payload = json.loads(response.text) # type: ignore[union-attr]
except (json.JSONDecodeError, ValueError):
logger.warning(
"yandex gate: JSON parse failed rooms=%s page=%d -- rotating + retry %d",
rooms,
page,
attempt + 1,
)
await self._rotate_ip()
await asyncio.sleep(2)
continue
if _is_gate_error(payload):
logger.warning(
"yandex gate: error payload rooms=%s page=%d keys=%s -- rotating + retry %d",
rooms,
page,
list(payload.keys()),
attempt + 1,
)
await self._rotate_ip()
await asyncio.sleep(2)
payload = None
continue
break
if payload is None:
logger.warning(
"yandex gate: all retries exhausted rooms=%s page=%d -- returning empty",
rooms,
page,
)
return []
lots = _parse_gate_json(payload, page_param=page, new_flat=new_flat)
logger.info(
"yandex gate page=%d rooms=%s price=[%s-%s] newFlat=%s: %d cards",
page,
rooms or "all",
price_min,
price_max,
new_flat,
len(lots),
)
await self.sleep_between_requests()
return lots
async def fetch_around_multi_room(
self,
lat: float,
lon: float,
radius_m: int = 1000,
max_pages: int = _GATE_MAX_PAGES_CAP,
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.
Pagination driven by pager.totalPages from first page response.
Each combo iterates page=1 .. min(totalPages, max_pages, _GATE_MAX_PAGES_CAP).
Deduplicates by source_id/source_url across ALL combos AND segments (one `seen`).
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)]
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 new_flat in _segments:
_seg = _segment_for(new_flat)
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:
payload_p1: dict[str, Any] | None = None
p1_url = self._build_url(
page=1,
rooms=rooms,
price_min=price_min,
price_max=price_max,
new_flat=new_flat,
)
for _attempt in range(1 + _YANDEX_TARPIT_MAX_RETRIES):
try:
resp = await self._http_get(p1_url, timeout=60)
except Exception:
logger.exception(
"yandex gate combo fetch failed combo=%s", combo_label
)
break
if resp.status_code == 0: # type: ignore[union-attr]
logger.warning(
"yandex gate: tarpit combo=%s page=1 attempt=%d -- rotating",
combo_label,
_attempt + 1,
)
await self._rotate_ip()
await asyncio.sleep(2)
continue
if resp.status_code != 200: # type: ignore[union-attr]
logger.warning(
"yandex gate: HTTP %d combo=%s page=1",
resp.status_code,
combo_label, # type: ignore[union-attr]
)
break
try:
payload_p1 = json.loads(resp.text) # type: ignore[union-attr]
except (json.JSONDecodeError, ValueError):
logger.warning(
"yandex gate: JSON parse error combo=%s page=1", combo_label
)
payload_p1 = None
await self._rotate_ip()
await asyncio.sleep(2)
continue
if _is_gate_error(payload_p1):
logger.warning(
"yandex gate: error payload combo=%s page=1", combo_label
)
payload_p1 = None
break
break
if payload_p1 is None:
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(
pager_p1.get("totalPages", 1),
max_pages,
_GATE_MAX_PAGES_CAP,
)
lots_p1 = _parse_gate_json(payload_p1, page_param=1, new_flat=new_flat)
if not lots_p1:
logger.debug(
"yandex gate combo [%s] page=1: empty -- stopping", combo_label
)
break
for lot in lots_p1:
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
continue
if total_pages is not None and page > total_pages:
break
lots = await self.fetch_around(
lat,
lon,
radius_m,
page=page,
rooms=rooms,
price_min=price_min,
price_max=price_max,
new_flat=new_flat,
)
if not lots:
logger.debug(
"yandex gate combo [%s] page=%d: empty -- stopping",
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
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, skipped=%d)",
len(seen),
len(combos),
len(_segments),
_segments,
combos_skipped,
)
return list(seen.values())
async def fetch_all_secondary(
self,
*,
rooms_buckets: list[str] | None = None,
price_cap_per_bucket: int = 500,
max_pages_per_bucket: int = _GATE_MAX_PAGES_CAP,
concurrency: int = 4,
on_bucket: Any = None,
on_progress: Any = None,
skip_buckets: set[str] | None = None,
) -> list[ScrapedLot]:
"""Exhaustive EKB vtorichka load via KOMNATNOST x TSENA partitioning.
Uses gate-API JSON (totalItems from pager) instead of DOM state extraction.
Стартует с общей data-driven seed-сетки ценовых брекетов
(get_price_seed_brackets() — shared EKB grid, общая с avito/cian) вместо
одной корень-бисекции [0, _YANDEX_MAX_PRICE]. Закрытые seed-брекеты передают
hi = br_hi - 1 (priceMax эксклюзивный сверху → нет пересечений), открытый
(br_hi is None) — hi=None (без priceMax, весь хвост люкса). Дедуп по
source_id в общем seen страхует на стыках.
"""
_buckets = rooms_buckets if rooms_buckets is not None else list(ROOM_PATH.keys())
seen: dict[str, ScrapedLot] = {}
for rooms_key in _buckets:
logger.info(
"yandex gate exhaustive: starting rooms=%s (price_cap=%d concurrency=%d)",
rooms_key,
price_cap_per_bucket,
concurrency,
)
before = len(seen)
for br_lo, br_hi in get_price_seed_brackets():
walk_hi = br_hi - 1 if br_hi is not None else None
await self._walk_price_range(
rooms=rooms_key,
lo=br_lo,
hi=walk_hi,
seen=seen,
price_cap_per_bucket=price_cap_per_bucket,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
)
room_collected = len(seen) - before
logger.info(
"yandex gate exhaustive: rooms=%s done -- collected %d (total unique=%d)",
rooms_key,
room_collected,
len(seen),
)
if on_progress is not None:
on_progress(len(seen))
logger.info("yandex gate exhaustive: DONE -- total unique=%d lots", len(seen))
return list(seen.values())
async def _walk_price_range(
self,
*,
rooms: str | None,
lo: int,
hi: int | None,
seen: dict[str, ScrapedLot],
price_cap_per_bucket: int,
max_pages_per_bucket: int,
concurrency: int = 4,
on_bucket: Any = None,
skip_buckets: set[str] | None = None,
_depth: int = 0,
) -> None:
"""Recursive binary price-range partitioning.
Probe page=1 -> read totalItems from pager.
Closed bracket (hi is not None): totalItems > cap and bracket >= MIN_BRACKET
-> split; else paginate leaf.
Open bracket (hi is None): top seed-bracket without priceMax ceiling. Cannot
split (no mid) -> paginate leaf directly (lux tail tiny, tail-loss accepted).
Fallback: totalItems=None -> paginate-until-empty (safe degradation).
skip_buckets: resume checkpoint -- skip leaf buckets already done.
"""
_lo_param = lo if lo > 0 else None
_hi_repr = "open" if hi is None else str(hi)
probe_payload = await self._fetch_page_json(rooms, 1, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec)
total: int | None = None
probe_lots: list[ScrapedLot] = []
if probe_payload is None:
logger.warning(
"yandex gate: probe failed rooms=%s [%d, %s] depth=%d -- rotating + retry",
rooms,
lo,
_hi_repr,
_depth,
)
rotated = await self._rotate_ip()
if rotated:
probe_payload = await self._fetch_page_json(rooms, 1, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec)
if probe_payload is not None:
result = _extract_gate_data(probe_payload)
if result is not None:
_, pager = result
total = pager.get("totalItems")
probe_lots = _parse_gate_json(probe_payload, page_param=1)
else:
logger.warning(
"yandex gate: probe data extract failed rooms=%s [%d, %s]", rooms, lo, _hi_repr
)
if total is None:
# Degraded path: paginate-until-empty without knowing total
logger.warning(
"yandex gate: totalItems unknown rooms=%s [%d, %s] -- paginate until empty",
rooms,
lo,
_hi_repr,
)
page = 1
bucket_key = _combo_label(rooms, lo, hi)
if skip_buckets and bucket_key in skip_buckets:
return
pages_fetched = 0
while pages_fetched < max_pages_per_bucket:
payload = await self._fetch_page_json(rooms, page, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec)
if payload is None:
break
lots = _parse_gate_json(payload, page_param=page)
if not lots:
break
for lot in lots:
if lot.source_id and lot.source_id not in seen:
seen[lot.source_id] = lot
page += 1
pages_fetched += 1
if on_bucket is not None:
on_bucket(bucket_key, len(seen))
return
# Split or paginate. ОТКРЫТЫЙ брекет (hi is None) делить нельзя (нет mid) —
# сразу пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
cap = price_cap_per_bucket
_min_bracket = 500_000
if hi is not None and total > cap and (hi - lo) >= _min_bracket and _depth < 8:
mid = (lo + hi) // 2
await self._walk_price_range(
rooms=rooms,
lo=lo,
hi=mid,
seen=seen,
price_cap_per_bucket=cap,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
_depth=_depth + 1,
)
await self._walk_price_range(
rooms=rooms,
lo=mid + 1,
hi=hi,
seen=seen,
price_cap_per_bucket=cap,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
_depth=_depth + 1,
)
return
# Leaf bucket: paginate
bucket_key = _combo_label(rooms, lo, hi)
if skip_buckets and bucket_key in skip_buckets:
logger.debug("yandex gate: skip bucket %s (checkpoint)", bucket_key)
return
total_pages = min(
math.ceil(total / 20),
_GATE_MAX_PAGES_CAP,
max_pages_per_bucket,
)
logger.info("yandex gate: leaf bucket %s total=%d pages=%d", bucket_key, total, total_pages)
# Add probe lots (page 1 already fetched)
for lot in probe_lots:
if lot.source_id and lot.source_id not in seen:
seen[lot.source_id] = lot
if total_pages <= 1:
if on_bucket is not None:
on_bucket(bucket_key, len(seen))
return
# Paginate pages 2..total_pages with concurrency
sem = asyncio.Semaphore(concurrency)
async def _fetch_leaf_page(pg: int) -> list[ScrapedLot]:
async with sem:
payload = await self._fetch_page_json(rooms, pg, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec)
if payload is None:
return []
return _parse_gate_json(payload, page_param=pg)
tasks = [_fetch_leaf_page(pg) for pg in range(2, total_pages + 1)]
results = await asyncio.gather(*tasks)
for page_lots in results:
for lot in page_lots:
if lot.source_id and lot.source_id not in seen:
seen[lot.source_id] = lot
if on_bucket is not None:
on_bucket(bucket_key, len(seen))
def _combo_label(rooms: str | None, lo: int | None, hi: int | None) -> str:
"""Уникальная метка бакета для checkpoint-логики."""
return f"{rooms or 'any'}:{lo}-{hi}"