"""Yandex Realty detail page scraper. Fetches /offer// and extracts Product JSON-LD + DOM sections into a DetailEnrichment Pydantic model. Used by enrichment pipeline (Wave 5+ matching / Wave 6 estimator). """ from __future__ import annotations import logging import re from datetime import date from typing import Any from pydantic import BaseModel, Field from selectolax.parser import HTMLParser, Node from app.services.scrapers.base import BaseScraper from app.services.scrapers.yandex_helpers import ( RE_AGENCY_FOUNDED, RE_AGENCY_OBJECTS, RE_METRO_WALK, RE_VIEWS, RE_YEAR, find_ld_by_type, parse_house_type, parse_ru_date, parse_rub, ) logger = logging.getLogger(__name__) # ── Pydantic models ─────────────────────────────────────────────────────────── class MetroStation(BaseModel): name: str walk_min: int | None = None class DetailEnrichment(BaseModel): """Enrichment payload from a Yandex detail page.""" offer_id: str source_url: str # Pricing — Product JSON-LD `offers.price` is the exact int price_rub: int | None = None price_per_m2: int | None = None # Title + basic params title: str | None = None rooms: int | None = None area_m2: float | None = None floor: int | None = None total_floors: int | None = None # Address (full) address: str | None = None # Description (full text) description: str | None = None # Sale type — raw RU phrase ('свободная продажа' / 'альтернативная') sale_type_text: str | None = None # Stats views_total: int | None = None publish_date: date | None = None publish_date_relative: str | None = None # Agency block (OfferCardAuthorInfo) agency_name: str | None = None agency_founded_year: int | None = None agency_objects_count: int | None = None seller_name: str | None = None # last text line before "Агентство «...»" # Metro stations from "Расположение" section metro_stations: list[MetroStation] = Field(default_factory=list) # Photos — 8 sizes from Product.image[] photo_urls: list[str] = Field(default_factory=list) # Newbuilding linkage newbuilding_url: str | None = None newbuilding_id: str | None = None # NLP from description (best-effort) house_type_nlp: str | None = None year_built_hint: int | None = None metro_walk_min: int | None = None # Raw payload (trimmed) raw_payload: dict[str, Any] | None = None # ── Scraper ─────────────────────────────────────────────────────────────────── class YandexDetailScraper(BaseScraper): """Detail page scraper for realty.yandex.ru.""" name = "yandex_detail" base_url = "https://realty.yandex.ru" request_delay_sec = 4.0 # BaseScraper requires fetch_around — detail isn't geo-based, raise NotImplementedError async def fetch_around( self, lat: float, lon: float, radius_m: int = 1000 ) -> list: # type: ignore[override] raise NotImplementedError( "YandexDetailScraper is offer-id-based; use fetch_detail(offer_url) instead." ) async def fetch_detail(self, offer_url: str) -> DetailEnrichment | None: try: response = await self._http_get(offer_url) except Exception: logger.exception("yandex detail fetch failed: %s", offer_url) return None if response.status_code != 200: logger.warning( "yandex detail returned %d for %s", response.status_code, offer_url ) return None result = self.parse(response.text, offer_url=offer_url) await self.sleep_between_requests() return result def parse(self, html: str, offer_url: str) -> DetailEnrichment | None: offer_id_match = re.search(r"/offer/(\d+)/?", offer_url) if not offer_id_match: logger.warning("offer_url has no /offer//: %s", offer_url) return None offer_id = offer_id_match.group(1) tree = HTMLParser(html) # --- Product JSON-LD (authoritative price) --- product = find_ld_by_type(html, "Product") or {} offers_ld = product.get("offers") or {} if isinstance(offers_ld, list) and offers_ld: offers_ld = offers_ld[0] price_ld = offers_ld.get("price") if isinstance(offers_ld, dict) else None try: price_rub = int(price_ld) if price_ld else None except (TypeError, ValueError): price_rub = None # Photos from JSON-LD image[] (typically 8 size variants) images = product.get("image") or [] if isinstance(images, str): images = [images] photo_urls = [u for u in images if isinstance(u, str)] # --- Title + summary --- title_node = tree.css_first("h1") title = title_node.text(strip=True) if title_node else None rooms, area_m2, floor, total_floors = _parse_title(title or "") # --- OfferCardSummary text block --- summary_node = tree.css_first('[data-test="OfferCardSummary"]') summary_text = summary_node.text(strip=True) if summary_node else "" # Sale type — raw RU phrase sale_type_text: str | None = None for phrase in ("свободная продажа", "альтернативная"): if phrase in summary_text.lower(): sale_type_text = phrase break # Views + relative publish date from summary text views_match = RE_VIEWS.search(summary_text) views_total = int(views_match.group(1)) if views_match else None publish_date = parse_ru_date(summary_text) publish_date_relative = _extract_relative_date(summary_text) # price_per_m2 — from summary text if absent in LD price_per_m2: int | None = None ppm2_match = re.search(r"(\d[\d\s]+)\s*₽\s*за\s*м²", summary_text) if ppm2_match: price_per_m2 = parse_rub(ppm2_match.group(1)) # --- OfferCardAuthorInfo (agency block) --- author_node = tree.css_first('[data-test="OfferCardAuthorInfo"]') agency_name: str | None = None agency_founded_year: int | None = None agency_objects_count: int | None = None seller_name: str | None = None if author_node is not None: author_text = author_node.text(strip=True) agency_h2 = author_node.css_first("h2") agency_name = agency_h2.text(strip=True) if agency_h2 else None founded_m = RE_AGENCY_FOUNDED.search(author_text) if founded_m: agency_founded_year = int(founded_m.group(1)) objects_m = RE_AGENCY_OBJECTS.search(author_text) if objects_m: agency_objects_count = int(objects_m.group(1)) # seller_name — last text line before "Агентство" seller_name = _extract_seller_name(summary_text, agency_name) # --- Description section (after H2 "Описание") --- description = _find_section_text(tree, "Описание") # --- Address --- address = _extract_address(summary_text) # --- Metro stations from "Расположение" section --- location_text = _find_section_text(tree, "Расположение") or "" metro_stations = _parse_metro_stations(location_text) # --- Newbuilding link --- nb_url: str | None = None nb_id: str | None = None nb_link = tree.css_first('a[href*="/kupit/novostrojka/"]') if nb_link is not None: nb_href = nb_link.attributes.get("href", "") nb_match = re.search(r"/novostrojka/[\w-]+?-(\d+)/?", nb_href) if nb_match: nb_id = nb_match.group(1) nb_url = ( nb_href if nb_href.startswith("http") else f"https://realty.yandex.ru{nb_href}" ) # --- NLP best-effort from description --- nlp_text = description or summary_text house_type_nlp = parse_house_type(nlp_text) year_hint_m = RE_YEAR.search(nlp_text or "") year_built_hint = int(year_hint_m.group(1)) if year_hint_m else None walk_m = RE_METRO_WALK.search(nlp_text or "") metro_walk_min = int(walk_m.group(1)) if walk_m else None return DetailEnrichment( offer_id=offer_id, source_url=offer_url, price_rub=price_rub, price_per_m2=price_per_m2, title=title, rooms=rooms, area_m2=area_m2, floor=floor, total_floors=total_floors, address=address, description=description, sale_type_text=sale_type_text, views_total=views_total, publish_date=publish_date, publish_date_relative=publish_date_relative, agency_name=agency_name, agency_founded_year=agency_founded_year, agency_objects_count=agency_objects_count, seller_name=seller_name, metro_stations=metro_stations, photo_urls=photo_urls, newbuilding_url=nb_url, newbuilding_id=nb_id, house_type_nlp=house_type_nlp, year_built_hint=year_built_hint, metro_walk_min=metro_walk_min, raw_payload={ "summary_text": summary_text[:1000], "description_len": len(description) if description else 0, "photo_count": len(photo_urls), }, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _parse_title(title: str) -> tuple[int | None, float | None, int | None, int | None]: """Extract (rooms, area_m2, floor, total_floors) from h1 text.""" rooms: int | None = None area_m2: float | None = None floor: int | None = None total_floors: int | None = None area_m = re.search(r"(\d+[.,]?\d*)\s*м²", title) if area_m: area_m2 = float(area_m.group(1).replace(",", ".")) if re.search(r"студи[яюй]", title, re.IGNORECASE): rooms = 0 else: rooms_m = re.search(r"(\d+)\s*-?\s*комнатн", title, re.IGNORECASE) if rooms_m: try: rooms = int(rooms_m.group(1)) except ValueError: pass floor_m = re.search(r"(\d+)\s+этаж\s+из\s+(\d+)", title, re.IGNORECASE) if floor_m: floor = int(floor_m.group(1)) total_floors = int(floor_m.group(2)) return rooms, area_m2, floor, total_floors def _find_section_text(tree: HTMLParser, heading: str) -> str | None: """Find the text content of a
/
whose preceding h2/h3 matches heading. Yandex page structure varies; this scans h2/h3 nodes, then returns the concatenated text of subsequent sibling blocks until the next heading. """ for h in tree.css("h2, h3"): if heading.lower() in (h.text(strip=True) or "").lower(): # collect subsequent siblings until the next h2/h3 parts: list[str] = [] node: Node | None = h.next while node is not None: tag = (node.tag or "").lower() if tag in {"h2", "h3"}: break txt = node.text(strip=True) if hasattr(node, "text") else "" if txt: parts.append(txt) node = node.next return " ".join(parts).strip() or None return None def _extract_address(summary_text: str) -> str | None: """Best-effort address extraction from summary block.""" # Pattern: "Россия, Свердловская область, Екатеринбург, улица Х, д. N" m = re.search(r"(Россия[^•]+?)(?:•|\d+\s+просмотр|$)", summary_text) if m: addr = m.group(1).strip().rstrip(",").strip() return addr if len(addr) > 10 else None return None def _parse_metro_stations(location_text: str) -> list[MetroStation]: """Parse 'Уральская 11 мин. Динамо 16 мин.' → list of MetroStation.""" stations: list[MetroStation] = [] # name (1+ Cyrillic words) + space + N + space + мин(.|у|ут) for m in re.finditer(r"([А-ЯЁ][А-Яа-яё\s-]+?)\s+(\d+)\s*мин", location_text): name = m.group(1).strip() if 2 <= len(name) <= 40: stations.append(MetroStation(name=name, walk_min=int(m.group(2)))) if len(stations) >= 5: break return stations def _extract_relative_date(summary_text: str) -> str | None: """Capture phrases like '6 часов назад' / 'вчера' / '3 дня назад'.""" m = re.search( r"(\d+\s+(?:минут|час|часов|часа|день|дня|дней|недел[ьюи])\s+назад" r"|вчера|сегодня|позавчера)", summary_text, re.IGNORECASE, ) return m.group(1).strip() if m else None def _extract_seller_name(summary_text: str, agency_name: str | None) -> str | None: """Heuristic: line right before 'Агентство ...' in summary text.""" if not agency_name or agency_name not in summary_text: return None head = summary_text.split(agency_name, 1)[0] # last short token sequence (likely "Имя Фамилия") m = re.findall(r"([А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+){1,2})", head) return m[-1] if m else None