gendesign/tradein-mvp/backend/app/services/scrapers/yandex_realty.py
bot-backend d03c96a79a fix(scrapers): yandex area из embedded-state JSON (Refs #775)
Часть A (#24): yandex_realty.py парсил площадь из RAW-HTML (regex), пустого до
JS-гидрации → 82% NULL area. _parse_html теперь извлекает {offer_id: area_m2}
из <script id=initial_state_script> (или application/json) раз на страницу и
использует preferentially в _card_to_lot; DOM-regex остаётся fallback'ом.
4 offer-path + 5 area-полей (incl nested {value}). 6 тестов.

Часть B (#23, cian buildingCadastralNumber): УЖЕ реализовано — cian.py:291
читает offer.buildingCadastralNumber, :456 пишет в ScrapedLot, поле base.py:109
существует, тест test_cian_serp_cadastral_numbers зелёный. Изменений не нужно
(премиса аудита устарела). cian.py НЕ трогался.

ruff clean; pytest -k 'yandex or cian' 365 pass (+1 pre-existing unrelated fail
test_cian_valuation low_price mock, не из этого PR).
2026-05-30 19:35:13 +03:00

407 lines
15 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.Недвижимость scraper (realty.yandex.ru) — DOM-based SERP parser.
History: Yandex SSR used to embed full state in `<script id="initial_state_script">`.
As of 2026-05, the state is chunked + evaporates after hydration. This version
scrapes the rendered DOM via selectolax + helpers from yandex_helpers.py.
Area extraction: DOM concat-text is empty pre-hydration (~82% NULL). Fix: extract
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
Typical: 23 [data-test="OffersSerpItem"] cards per page.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date # noqa: F401 (used in type hints / future helpers)
from typing import Any
from selectolax.parser import HTMLParser, Node
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.base import BaseScraper, ScrapedLot
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
from app.services.scrapers.yandex_helpers import (
RE_FLOOR,
RE_JK_ID,
RE_OFFER_ID,
RE_PPM2,
RE_PRICE,
RE_TITLE_AREA,
RE_TITLE_ROOMS,
parse_listing_date,
parse_rub,
)
logger = logging.getLogger(__name__)
DEFAULT_CITY = "ekaterinburg"
MAX_PAGES = 3
PHOTO_DOMAIN = "avatars.mds.yandex"
PHOTO_SIZE_FROM = "app_snippet_small"
PHOTO_SIZE_TO = "main"
# Address container wraps street link + house number text node.
# Class is hashed but always starts with "AddressWithGeoLinks__addressContainer".
ADDRESS_CONTAINER_SELECTOR = '[class*="AddressWithGeoLinks__addressContainer"]'
_RE_WS = re.compile(r"\s+")
# Yandex SERP embeds offer data in a <script id="initial_state_script"> as JSON,
# or (since 2026-05) in <script type="application/json" ...> chunks.
# We look for these selectors in priority order.
_STATE_SCRIPT_SELECTORS = [
'script[id="initial_state_script"]',
'script[type="application/json"]',
]
# Area field candidates in a Yandex offer object (tried in order).
_AREA_FIELDS = ("totalArea", "area", "spaceTotal", "spaceAll", "squareTotal")
def _extract_offer_areas_from_state(html: str) -> dict[str, float]:
"""Extract {offer_id: area_m2} map from Yandex SERP embedded JSON state.
Tries each known script selector, parses JSON, then traverses known
offer-array paths looking for offer objects with an id + area field.
Returns empty dict if state is absent or unparseable.
"""
tree = HTMLParser(html)
candidates: list[str] = []
for selector in _STATE_SCRIPT_SELECTORS:
for node in tree.css(selector):
text = node.text() or ""
text = text.strip()
if text:
candidates.append(text)
result: dict[str, float] = {}
for text in candidates:
try:
state = json.loads(text)
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(state, dict):
continue
_collect_areas_from_state(state, result)
if result:
break # успешно извлекли из первого валидного blob'а
return result
def _collect_areas_from_state(state: dict[str, Any], out: dict[str, float]) -> None:
"""Walk known Yandex state paths to find offer objects and extract area.
Known paths (tried in order):
- state["offers"] (flat list)
- state["pageData"]["offers"] (SERP v2)
- state["listing"]["offers"] (SERP v3)
- state["serpData"]["items"] (SERP v4 chunks)
"""
offer_lists: list[Any] = []
for path in (
["offers"],
["pageData", "offers"],
["listing", "offers"],
["serpData", "items"],
):
node: Any = state
for key in path:
if not isinstance(node, dict):
node = None
break
node = node.get(key)
if isinstance(node, list):
offer_lists.append(node)
for offer_list in offer_lists:
for offer in offer_list:
if not isinstance(offer, dict):
continue
# Flatten one level of nesting (some Yandex shapes wrap the offer)
inner = offer.get("offer") or offer
if not isinstance(inner, dict):
continue
oid = inner.get("offerId") or inner.get("id")
if oid is None:
continue
area = _read_area_field(inner)
if area is not None:
out[str(oid)] = area
def _read_area_field(offer: dict[str, Any]) -> float | None:
"""Try known field names to read area (м²) from a Yandex offer dict."""
for field in _AREA_FIELDS:
val = offer.get(field)
if val is None:
continue
# Some fields are nested dicts: {"value": 52.0, "unit": "SQM"}
if isinstance(val, dict):
val = val.get("value")
if val is not None:
try:
result = float(val)
if result > 0:
return result
except (TypeError, ValueError):
continue
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
# Load global Yandex delay from DB at instantiation
self.request_delay_sec = get_scraper_delay(self.name)
async def fetch_around(
self,
lat: float,
lon: float,
radius_m: int = 1000,
page: int = 0,
) -> list[ScrapedLot]:
"""Fetch ONE page of SERP cards. lat/lon/radius_m ignored (Yandex uses
path-based vtorichka URL); kept for BaseScraper compat + logging.
"""
url = self._build_url(page=page)
try:
response = await self._http_get(url)
except Exception:
logger.exception("yandex serp fetch failed: %s", url)
return []
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)
logger.info(
"yandex serp page=%d city=%s: %d cards (anchor=(%.4f,%.4f) ignored)",
page,
self.city,
len(lots),
lat,
lon,
)
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 = MAX_PAGES,
**_legacy_kwargs: Any, # swallow rooms/sorts/pages from old callers
) -> list[ScrapedLot]:
"""Fetch up to max_pages of SERP cards, dedup by offer_id (source_id)."""
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
logger.info(
"yandex serp aggregate: %d unique lots over %d pages (city=%s)",
len(seen),
max_pages,
self.city,
)
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/"
if page > 0:
return f"{base}?page={page}"
return base
def _parse_html(self, html: str, page: int = 0) -> list[ScrapedLot]:
# 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)
if state_areas:
logger.debug("yandex serp: state areas extracted for %d offers", len(state_areas))
tree = HTMLParser(html)
cards = tree.css('[data-test="OffersSerpItem"]')
lots: list[ScrapedLot] = []
for card in cards:
lot = self._card_to_lot(card, page=page, state_areas=state_areas)
if lot is not None:
lots.append(lot)
return lots
def _card_to_lot(
self,
card: Node,
page: int = 0,
state_areas: dict[str, float] | None = None,
) -> ScrapedLot | None:
try:
# offer_id — required
offer_link = card.css_first('a[href^="/offer/"]')
if offer_link is None:
return None
href = offer_link.attributes.get("href", "")
id_match = RE_OFFER_ID.search(href)
if not id_match:
return None
offer_id = id_match.group(1)
text = card.text(strip=True)
# Price — required (ScrapedLot.price_rub > 0)
price_m = RE_PRICE.search(text)
price_rub = parse_rub(price_m.group(1)) if price_m else None
if not price_rub or price_rub <= 0:
return None
# Area: prefer JSON state (covers pre-hydration DOM where text is empty),
# fall back to DOM regex.
area_m2: float | None = (state_areas or {}).get(offer_id)
if area_m2 is None:
area_m = RE_TITLE_AREA.search(text)
area_m2 = float(area_m.group(1).replace(",", ".")) if area_m else None
rooms = self._parse_rooms(text)
floor_m = RE_FLOOR.search(text)
floor = int(floor_m.group(1)) if floor_m else None
total_floors = int(floor_m.group(2)) if floor_m else None
ppm2_m = RE_PPM2.search(text)
price_per_m2 = parse_rub(ppm2_m.group(1)) if ppm2_m else None
bargain = "торг" in text.lower()
listing_date = parse_listing_date(text)
# Repair state — инференс из текста карточки (#622). Yandex SERP не
# отдаёт структурного поля ремонта; берём сигнал из текста сниппета.
repair_state = infer_repair_state_from_text(text)
# Address — prefer the full address container (street + house number);
# fall back to the street aggregator link (street name only) if absent.
# See _extract_address for details.
address = self._extract_address(card)
# Photos
photo_urls = self._extract_photos(card)
# Newbuilding linkage
house_source: str | None = None
house_ext_id: str | None = None
house_url: str | None = None
nb_link = card.css_first('a[href*="/kupit/novostrojka/"]')
if nb_link is not None:
nb_href = nb_link.attributes.get("href", "")
nb_match = RE_JK_ID.search(nb_href)
if nb_match:
house_source = "yandex_realty_nb"
house_ext_id = nb_match.group(2)
house_url = (
nb_href
if nb_href.startswith("http")
else f"https://realty.yandex.ru{nb_href}"
)
return ScrapedLot(
source=self.name,
source_url=f"https://realty.yandex.ru/offer/{offer_id}/",
source_id=offer_id,
address=address,
lat=None,
lon=None,
rooms=rooms,
area_m2=area_m2,
floor=floor,
total_floors=total_floors,
repair_state=repair_state,
price_rub=price_rub,
price_per_m2=price_per_m2,
bargain_allowed=bargain,
listing_date=listing_date,
photo_urls=photo_urls,
house_source=house_source,
house_ext_id=house_ext_id,
house_url=house_url,
listing_segment="vtorichka",
raw_payload={
"card_text": text[:1000],
"page": page,
"city": self.city,
},
)
except Exception:
logger.exception("yandex card parse failed (page=%d)", page)
return None
@staticmethod
def _extract_address(card: Node) -> str | None:
"""Extract full address (street + house number) from a SERP card.
Yandex renders the address inside a container like:
<div class="AddressWithGeoLinks__addressContainer--XXXX">
<a href="/.../kupit/kvartira/st-...">улица Энгельса</a>, 38
</div>
The street-only `<a>` tag was used historically — it gave just the
street name and broke forward-geocoding precision (street-level only,
not exact house). Reading the container's text yields the full
"<street>, <house>" pair, including letter/fraction suffixes
(e.g. "2В", "2/2", "14к2"). Some cards prefix district/city info
(e.g. "Берёзовский, Александровский проспект, 5А") — that's still
a valid, more specific address for the geocoder.
Falls back to the street-only link when the container is missing
(older layout / unexpected DOM) so we never regress to NULL.
"""
addr_div = card.css_first(ADDRESS_CONTAINER_SELECTOR)
if addr_div is not None:
# text(strip=False) preserves spaces between text nodes and
# the anchor's text (selectolax with strip=True collapses them
# and yields "улица Энгельса,38" instead of "улица Энгельса, 38").
raw = addr_div.text(strip=False) or ""
normalized = _RE_WS.sub(" ", raw).strip().strip(",").strip()
if normalized:
return normalized
street_link = card.css_first('a[href*="/kupit/kvartira/st-"]')
return street_link.text(strip=True) if street_link else None
@staticmethod
def _parse_rooms(text: str) -> int | None:
m = RE_TITLE_ROOMS.search(text)
if not m:
return None
if m.group(2): # studio
return 0
if m.group(1): # numbered
try:
return int(m.group(1))
except ValueError:
return None
return None
@staticmethod
def _extract_photos(card: Node) -> list[str]:
urls: list[str] = []
for img in card.css("img"):
src = img.attributes.get("src", "") or ""
if PHOTO_DOMAIN in src:
urls.append(src.replace(PHOTO_SIZE_FROM, PHOTO_SIZE_TO))
if len(urls) >= 5:
break
return urls