"""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']+id="__NEXT_DATA__"[^>]*>(.+?)', re.DOTALL ) _RE_INITIAL_STATE = re.compile( r'window\._cianConfig\s*=\s*({.+?});', 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