gendesign/tradein-mvp/backend/app/services/scrapers/cian.py
TradeIn Deploy 7cc8d8370d feat(tradein): пагинация Циан/N1 3→8 страниц — плотность данных
После перехода на реальные координаты стало видно что данных тонко.
8 страниц × 4 комнаты ≈ 900+ лотов на источник вместо ~300.
2026-05-21 14:34:29 +03:00

484 lines
19 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.

"""Cian.ru scraper — парсинг вторички вокруг точки.
Стратегия:
- Cian.ru Next.js приложение хранит state в `__NEXT_DATA__` JSON (script tag в HTML).
- URL pattern: https://ekaterinburg.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2
ВАЖНО: Циан блокирует httpx по TLS fingerprint → "Обнаружен подозрительный трафик" (403).
Используем curl_cffi с impersonate='chrome120' — это libcurl-impersonate под капотом,
который воспроизводит TLS ClientHello как настоящий Chrome.
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any
from urllib.parse import urlencode, urljoin
from curl_cffi.requests import AsyncSession
from selectolax.parser import HTMLParser
from app.services.scrapers.base import BaseScraper, ScrapedLot
logger = logging.getLogger(__name__)
# Регион 4743 = Свердловская область (Cian internal)
CIAN_EKB_REGION_ID = 4743
class CianScraper(BaseScraper):
"""Cian vtorichka parser. Использует curl_cffi для обхода TLS fingerprint detection."""
name = "cian"
base_url = "https://ekaterinburg.cian.ru"
request_delay_sec = 6.0
def __init__(self) -> None:
super().__init__()
self._cffi: AsyncSession | None = None
async def __aenter__(self) -> "CianScraper":
await super().__aenter__()
# curl_cffi session с Chrome 120 TLS fingerprint
self._cffi = AsyncSession(
impersonate="chrome120",
timeout=25,
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",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
},
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._cffi is not None:
await self._cffi.close()
await super().__aexit__(*args)
async def fetch_around(
self, lat: float, lon: float, radius_m: int = 1000,
rooms: tuple[int, ...] | None = None, page: int = 1,
) -> list[ScrapedLot]:
"""Найти объявления Циан вокруг (lat, lon) в radius_m метрах.
rooms — список из 1,2,3,4 (Cian's room codes). Если None — все.
page — страница выдачи (Cian ~28 на страницу).
"""
self._anchor_lat = lat
self._anchor_lon = lon
self._anchor_radius_m = radius_m
url = self._build_url(lat, lon, radius_m, rooms, page)
try:
assert self._cffi is not None
response = await self._cffi.get(url)
except Exception: # noqa: BLE001
logger.exception("cian curl_cffi fetch failed for %s", url)
return []
if response.status_code != 200:
logger.warning("cian returned %d for %s", response.status_code, url)
return []
lots = self._parse_html(response.text)
logger.info(
"cian: %d lots around (%.4f, %.4f) rooms=%s page=%d",
len(lots), lat, lon, rooms, page,
)
await self.sleep_between_requests()
return lots
async def fetch_around_multi_room(
self, lat: float, lon: float, radius_m: int = 1000, pages: int = 8,
) -> list[ScrapedLot]:
"""Скрейп Циан по 1к/2к/3к/4+ × N страниц — расширяет выборку.
4 комнаты × 3 страницы × ~28 = до ~330 лотов до дедупликации.
"""
seen: dict[str, ScrapedLot] = {}
for rooms in ((1,), (2,), (3,), (4,)):
for page in range(1, pages + 1):
try:
lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms, page=page)
except Exception:
logger.exception(
"cian multi-room fetch failed rooms=%s page=%d", rooms, page
)
continue
if not lots:
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(
"cian 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: tuple[int, ...] | None = None,
page: int = 1,
) -> str:
"""URL для Cian каталога с регионом ЕКБ.
Cian не имеет «радиус» в URL params, но мы фильтруем по lat/lon
в postgres через ST_DWithin позже. Сейчас тащим всё ЕКБ.
"""
params: list[tuple[str, Any]] = [
("deal_type", "sale"),
("engine_version", "2"),
("offer_type", "flat"),
("region", CIAN_EKB_REGION_ID),
("sort", "creation_date_desc"),
]
if rooms:
for r in rooms:
# Cian rooms[]=1 для 1к, rooms[]=2 для 2к, и т.д.
params.append((f"room{r}", "1"))
if page > 1:
params.append(("p", page))
return f"{self.base_url}/cat.php?{urlencode(params)}"
def _parse_html(self, html: str) -> list[ScrapedLot]:
"""Cian вставляет state в `window.__NEXT_DATA__` или подобный JSON блок."""
# Попытка 1: __NEXT_DATA__ script
items = _extract_next_data_items(html)
if items:
return [
lot for lot in (self._item_to_lot(it) for it in items) if lot is not None
]
# Попытка 2: regex-извлечение из inline JSON state
items = _extract_inline_offers(html)
if items:
return [
lot for lot in (self._item_to_lot(it) for it in items) if lot is not None
]
# Попытка 3: DOM card scrape (fallback)
tree = HTMLParser(html)
cards = tree.css('[data-name="CardComponent"]') or tree.css(
'article[data-name="GeneralCard"]'
)
return [
lot for lot in (self._card_to_lot(c) for c in cards) if lot is not None
][:50]
def _item_to_lot(self, item: dict[str, Any]) -> ScrapedLot | None:
"""Парс Cian offer JSON → ScrapedLot."""
try:
# Cian offer структура
offer_id = item.get("cianId") or item.get("id")
url = item.get("fullUrl") or item.get("url")
if not url and offer_id:
url = f"https://ekaterinburg.cian.ru/sale/flat/{offer_id}/"
if not url:
return None
# Цена
bargain = item.get("bargainTerms") or {}
price = bargain.get("price") or item.get("price")
if not price:
return None
# Параметры
rooms = item.get("roomsCount")
area = item.get("totalArea") or item.get("totalAreaSquare")
if area:
try:
area = float(area)
except (TypeError, ValueError):
area = None
floor = item.get("floorNumber")
building = item.get("building") or {}
total_floors = building.get("floorsCount")
# Адрес
geo = item.get("geo") or {}
address_parts = geo.get("userInput") or ""
if isinstance(geo.get("address"), list):
address_parts = ", ".join(
a.get("fullName", "") or a.get("name", "")
for a in geo["address"][:3]
)
# Координаты
coords = geo.get("coordinates") or {}
lat = coords.get("lat")
lon = coords.get("lng")
# Fallback anchor с jitter если нет точных
if lat is None or lon is None:
if 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
# Год постройки
year_built = building.get("buildYear")
return ScrapedLot(
source="cian",
source_url=url,
source_id=str(offer_id) if offer_id else None,
address=address_parts or "Екатеринбург (Cian)",
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total_floors,
year_built=year_built,
price_rub=int(price),
photo_urls=[
p.get("fullUrl") or p.get("thumbnailUrl", "")
for p in (item.get("photos") or [])[:5]
if isinstance(p, dict)
],
raw_payload=item,
)
except Exception: # noqa: BLE001
logger.exception("cian item parse failed: %s", item.get("id"))
return None
def _card_to_lot(self, card: Any) -> ScrapedLot | None:
"""DOM card-парсинг для Cian 2025+ верстки.
Использует data-mark и data-name селекторы:
- [data-mark="OfferTitle"]: "2-комн. квартира, 47,5 м², 2/9 этаж"
- [data-mark="MainPrice"]: "6 000 000 ₽"
- [data-name="GeoLabel"]: address parts (multiple, объединяем)
- [data-mark="Description"]: long text (год постройки regex'ом)
- href контейнера → source_url
"""
try:
link_el = card.css_first('a[href*="/sale/flat/"]')
if link_el is None:
return None
href = link_el.attributes.get("href", "")
url = urljoin(self.base_url, href)
# offer_id из URL pattern /sale/flat/NNN/
offer_id_m = re.search(r"/sale/flat/(\d+)/", href)
offer_id = offer_id_m.group(1) if offer_id_m else None
# Цена
price_el = card.css_first('[data-mark="MainPrice"]')
price_text = price_el.text(strip=True) if price_el else ""
price = _extract_price_rub(price_text)
if not price:
return None
# Title → rooms / area / floor / total_floors
title_el = (
card.css_first('[data-mark="OfferTitle"]')
or card.css_first('[data-name="TitleComponent"]')
or card.css_first('[data-mark="Title"]')
)
title = title_el.text(strip=True) if title_el else ""
rooms = _extract_rooms_from_text(title)
area = _extract_area_from_text(title)
floor, total = _extract_floor_from_text(title)
# Address: собираем все GeoLabel, но показываем компактно
geo_parts = [
el.text(strip=True)
for el in card.css('[data-name="GeoLabel"]')
if el.text(strip=True)
]
# Полный список сохраним в raw_payload, а display address укоротим.
# Убираем "Свердловская область", "Екатеринбург", "м. *" (метро),
# оставляем "р-н *", "мкр. *", улицу и дом.
full_geo = list(geo_parts)
display_parts: list[str] = []
street_part: str | None = None
house_part: str | None = None
district_part: str | None = None
for p in geo_parts:
if p in {"Свердловская область", "Екатеринбург"}:
continue
if p.startswith("м. "):
continue # метро не нужно в адресе
if p.startswith("р") and district_part is None:
district_part = p
elif p.startswith("мкр. "):
continue # микрорайон опускаем для компактности
elif "улица" in p or "проспект" in p or "переулок" in p or "бульвар" in p or "шоссе" in p:
street_part = p
elif re.match(r"^\d", p):
house_part = p
else:
display_parts.append(p)
# Финальный display: "улица X, дом Y · район Z"
final_parts: list[str] = []
if street_part:
final_parts.append(street_part + (f", {house_part}" if house_part else ""))
if district_part:
final_parts.append(district_part)
address = " · ".join(final_parts) if final_parts else (
", ".join(p for p in geo_parts if p not in {"Свердловская область"}) or "Екатеринбург (Cian)"
)
# Description → попытка извлечь год постройки
desc_el = card.css_first('[data-mark="Description"]')
desc = desc_el.text(strip=True) if desc_el else ""
year_built = _extract_year_built(desc)
# PriceInfo: "126 316 ₽/м²" — можно посчитать area если её нет в title
if not area:
pinfo_el = card.css_first('[data-mark="PriceInfo"]')
pinfo = pinfo_el.text(strip=True) if pinfo_el else ""
m = re.search(r"([\d\s]+)\s*₽/м²", pinfo)
if m:
ppm2 = int(re.sub(r"\D", "", m.group(1)) or 0)
if ppm2 > 0:
area = round(price / ppm2, 2)
# Coords: НЕ ставим (Cian card HTML не отдаёт). Оставляем None —
# geocode-missing проставит реальные по address. Anchor+jitter тут
# был БАГ: Cian отдаёт ВСЕ объявления ЕКБ независимо от anchor, и
# jitter вокруг одного якоря давал фейковые координаты → ST_DWithin
# не находил аналоги для адресов вдали от центра.
lat = lon = None
# Photos из img.src в card (первые 5)
photo_urls: list[str] = []
for img in card.css("img.x31de4314--_18b0f--container, [data-name=Cover] img")[:5]:
src = img.attributes.get("src", "")
if src and "cian" in src and src not in photo_urls:
photo_urls.append(src)
return ScrapedLot(
source="cian",
source_url=url,
source_id=offer_id,
address=address,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total,
year_built=year_built,
price_rub=price,
photo_urls=photo_urls,
raw_payload={
"title": title,
"description": desc[:500],
"geo": full_geo,
},
)
except Exception: # noqa: BLE001
logger.exception("cian card parse failed")
return None
# ── HTML / JSON extraction helpers ──────────────────────────────────────────
_RE_NEXT_DATA = re.compile(
r'<script[^>]+id="__NEXT_DATA__"[^>]*>(.+?)</script>', re.DOTALL
)
_RE_INITIAL_STATE = re.compile(
r'window\._cianConfig\s*=\s*({.+?});</script>', re.DOTALL
)
def _extract_next_data_items(html: str) -> list[dict[str, Any]]:
"""Cian Next.js приложение хранит state в `__NEXT_DATA__` JSON."""
m = _RE_NEXT_DATA.search(html)
if not m:
return []
try:
data = json.loads(m.group(1))
except json.JSONDecodeError:
return []
# Структура у Cian примерно такая: props.pageProps.initialState.serpData.offers
cursor: Any = data
for key in ["props", "pageProps", "initialState"]:
if not isinstance(cursor, dict):
return []
cursor = cursor.get(key)
if not isinstance(cursor, dict):
return []
serp = cursor.get("results") or cursor.get("serpData") or cursor.get("data") or {}
offers = serp.get("offersSerialized") or serp.get("offers") or []
if isinstance(offers, list):
return offers
return []
def _extract_inline_offers(html: str) -> list[dict[str, Any]]:
"""Альтернативный поиск offers в любом большом JSON в HTML."""
# Найти offers: [...] inline
for match in re.finditer(r'"offers":\[(.+?)\](?=,"|\})', html):
chunk = "[" + match.group(1) + "]"
try:
offers = json.loads(chunk)
if isinstance(offers, list) and offers and isinstance(offers[0], dict):
return offers
except json.JSONDecodeError:
continue
return []
# ── Title parsers (Cian-specific format) ────────────────────────────────────
_RE_ROOMS = re.compile(r"(\d)-комн", re.IGNORECASE)
_RE_STUDIO = re.compile(r"студи", re.IGNORECASE)
_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м²", re.IGNORECASE)
_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт", re.IGNORECASE)
_RE_PRICE = re.compile(r"([\d\s]+?)\s*₽")
def _extract_rooms_from_text(text: str) -> int | None:
if _RE_STUDIO.search(text):
return 0
m = _RE_ROOMS.search(text)
return int(m.group(1)) if m else None
def _extract_area_from_text(text: str) -> float | None:
m = _RE_AREA.search(text)
return float(m.group(1).replace(",", ".")) if m else None
def _extract_floor_from_text(text: str) -> tuple[int | None, int | None]:
m = _RE_FLOOR.search(text)
if m:
return int(m.group(1)), int(m.group(2))
return None, None
def _extract_price_rub(text: str) -> int | None:
m = _RE_PRICE.search(text)
if m:
digits = re.sub(r"\D", "", m.group(1))
return int(digits) if digits else None
return None
_RE_YEAR_BUILT = re.compile(r"(?:19[5-9]\d|20[0-2]\d)\s*(?:года?\s*постройки|г\.?\s*постройки)", re.IGNORECASE)
def _extract_year_built(text: str) -> int | None:
"""Извлечь год постройки из description: '1988 года постройки' / '2005 г. постройки'."""
m = _RE_YEAR_BUILT.search(text)
if m:
year_m = re.search(r"(19[5-9]\d|20[0-2]\d)", m.group(0))
if year_m:
return int(year_m.group(1))
return None