gendesign/tradein-mvp/backend/app/services/scrapers/yandex_realty.py
lekss361 02267d0306 feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in)
- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest

Первый запуск на сервере (вручную, один раз):
  - создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
  - docker network create gendesign_shared (если нет)
  - docker compose -p gendesign-tradein up -d
  - docker compose -p gendesign exec caddy caddy reload
2026-05-21 00:25:39 +03:00

290 lines
10 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).
Я.Недвижимость отдаёт SSR HTML с `__INITIAL_STATE__` JSON в `<script>` теге.
Структура offers: state.search.offers — list of offers.
URL pattern:
https://realty.yandex.ru/ekaterinburg/kupit/kvartira/?
rgid=240970 # ЕКБ region
&offerType=SELL
&category=APARTMENT
&geoLat=56.83
&geoLon=60.59
&geoRadius=1000 # метры
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any
from urllib.parse import urlencode, urljoin
from selectolax.parser import HTMLParser
from app.services.scrapers.base import BaseScraper, ScrapedLot
logger = logging.getLogger(__name__)
YANDEX_EKB_RGID = 240970
class YandexRealtyScraper(BaseScraper):
"""Yandex.Недвижимость parser."""
name = "yandex"
base_url = "https://realty.yandex.ru"
request_delay_sec = 5.0
async def fetch_around(
self,
lat: float,
lon: float,
radius_m: int = 1000,
rooms: str | None = None,
sort: str = "DATE_DESC",
page: int = 0,
) -> list[ScrapedLot]:
self._anchor_lat = lat
self._anchor_lon = lon
self._anchor_radius_m = radius_m
url = self._build_url(lat, lon, radius_m, rooms, sort, page)
try:
response = await self._http_get(url)
except Exception:
logger.exception("yandex realty fetch failed for %s", url)
return []
if response.status_code != 200:
logger.warning("yandex realty returned %d", response.status_code)
return []
lots = self._parse_html(response.text)
logger.info(
"yandex realty: %d lots around (%.4f, %.4f) rooms=%s sort=%s page=%d",
len(lots), lat, lon, rooms or "ANY", sort, page,
)
await self.sleep_between_requests()
return lots
async def fetch_around_multi_room(
self, lat: float, lon: float, radius_m: int = 1000,
sorts: tuple[str, ...] = ("DATE_DESC",),
pages: tuple[int, ...] = (0,),
) -> list[ScrapedLot]:
"""Скрейп Yandex по сегментам STUDIO/1k/2k/3k/4k+ и опционально по
нескольким sort/page вариантам.
Каждая выдача = 20 лотов. Полный обход rooms×sorts×pages даст
5 × len(sorts) × len(pages) × 20 потенциальных лотов до дедупликации.
"""
seen: dict[str, ScrapedLot] = {}
for rooms in ("STUDIO", "1", "2", "3", "PLUS_4"):
for sort in sorts:
for page in pages:
try:
lots = await self.fetch_around(
lat, lon, radius_m, rooms=rooms, sort=sort, page=page,
)
except Exception:
logger.exception(
"yandex realty multi-room fetch failed: rooms=%s sort=%s page=%d",
rooms, sort, page,
)
continue
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 realty multi-room: %d unique lots around (%.4f, %.4f)",
len(seen), lat, lon,
)
return list(seen.values())
def _build_url(
self,
lat: float,
lon: float,
radius_m: int,
rooms: str | None = None,
sort: str = "DATE_DESC",
page: int = 0,
) -> str:
params: dict[str, Any] = {
"rgid": YANDEX_EKB_RGID,
"offerType": "SELL",
"category": "APARTMENT",
"geoLat": f"{lat:.4f}",
"geoLon": f"{lon:.4f}",
"geoRadius": radius_m,
"sort": sort,
}
if rooms:
# Yandex Realty принимает roomsTotal=STUDIO|1|2|3|PLUS_4
params["roomsTotal"] = rooms
if page > 0:
params["page"] = page
return f"{self.base_url}/ekaterinburg/kupit/kvartira/?{urlencode(params)}"
def _parse_html(self, html: str) -> list[ScrapedLot]:
# Поиск __INITIAL_STATE__ или __PRELOADED_STATE__
state = _extract_yandex_state(html)
if not state:
return []
offers = _items_from_state(state)
return [
lot for lot in (self._item_to_lot(o) for o in offers if isinstance(o, dict))
if lot is not None
]
def _item_to_lot(self, offer: dict[str, Any]) -> ScrapedLot | None:
try:
# Цена: offer.price.value (там целое число рублей)
price_obj = offer.get("price") or {}
price = price_obj.get("value")
if not price:
pf = price_obj.get("priceForWhole") or {}
price = pf.get("value") if isinstance(pf, dict) else pf
if not price:
return None
# Площадь
area_obj = offer.get("area") or {}
area = area_obj.get("value")
if area:
area = float(area)
rooms = offer.get("roomsTotal") or offer.get("rooms")
# Этажи — offer.floorsOffered = [N], building.floorsTotal
floors_offered = offer.get("floorsOffered") or []
floor = floors_offered[0] if floors_offered else offer.get("floor")
building = offer.get("building") or {}
total_floors = building.get("floorsTotal") or offer.get("floorsTotal")
year_built = building.get("builtYear")
wall_type = building.get("buildingType")
# Локация
loc = offer.get("location") or {}
address = (
loc.get("address")
or loc.get("geocoderAddress")
or "Екатеринбург (Я.Недвижимость)"
)
point = loc.get("point") or {}
lat = point.get("latitude")
lon = point.get("longitude")
# Anchor fallback (если EXACT нет — но обычно есть)
if (lat is None or lon is None) and hasattr(self, "_anchor_lat"):
jitter_deg = (self._anchor_radius_m / 1000) / 111
lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg
lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2
# URL
offer_id = offer.get("offerId") or offer.get("id")
internal_url = offer.get("unsignedInternalUrl") or ""
if internal_url.startswith("//"):
full_url = "https:" + internal_url
elif offer_id:
full_url = f"https://realty.yandex.ru/offer/{offer_id}/"
else:
return None
# Photos
photos = []
for img_field in ("appLargeImages", "extImages", "alikeImages"):
for img in (offer.get(img_field) or [])[:5]:
if isinstance(img, dict):
url = img.get("xl") or img.get("large") or img.get("main", "")
if url:
photos.append(url)
elif isinstance(img, str):
photos.append(img)
if photos:
break
return ScrapedLot(
source="yandex",
source_url=full_url,
source_id=str(offer_id) if offer_id else None,
address=address,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total_floors,
year_built=year_built,
house_type=_map_wall_type(wall_type),
price_rub=int(price),
photo_urls=photos[:5],
raw_payload={
# Compact dump (full offer 50KB+, тяжеловато для БД)
"offerId": offer_id,
"creationDate": offer.get("creationDate"),
"dealStatus": offer.get("dealStatus"),
"description": (offer.get("description") or "")[:500],
},
)
except Exception:
logger.exception("yandex item parse failed: %s", offer.get("offerId"))
return None
# ── Helpers ──────────────────────────────────────────────────────────────────
_RE_STATE = re.compile(
r'<script id="initial_state_script"[^>]*>(.+?)</script>',
re.DOTALL,
)
def _extract_yandex_state(html: str) -> dict[str, Any] | None:
m = _RE_STATE.search(html)
if not m:
return None
raw = m.group(1)
# Может начинаться с `window.__INITIAL_STATE__=` префикса
raw = re.sub(r'^\s*window\.[\w_]+\s*=\s*', '', raw).rstrip(';').strip()
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
def _items_from_state(state: dict[str, Any]) -> list[dict[str, Any]]:
"""Yandex Realty: state.map.offers.points содержит список объявлений (десктоп desktop SSR)."""
for path in [
("map", "offers", "points"), # верифицировано на проде
("search", "offers"),
("offers", "points"),
("offers", "items"),
]:
cursor: Any = state
for key in path:
if not isinstance(cursor, dict):
cursor = None
break
cursor = cursor.get(key)
if isinstance(cursor, list) and cursor:
return cursor
return []
def _map_wall_type(yandex_type: str | None) -> str | None:
if not yandex_type:
return None
mapping = {
"PANEL": "panel",
"BRICK": "brick",
"MONOLITH": "monolith",
"MONOLITH_BRICK": "monolith_brick",
"BLOCK": "panel",
}
return mapping.get(yandex_type.upper(), "other")