gendesign/tradein-mvp/backend/app/services/scrapers/cian.py
lekss361 c5227a164e
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m27s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m0s
feat(tradein/cian): promote kitchen/mortgage/apartments + normalize house_type (#2008)
cian house_type normalized via shared house_type_normalizer (monolithBrick->monolith_brick etc.) — fixes estimator soft-penalty false-mismatch (~1208 rows); backfill migration 141 + new columns is_apartments/is_rosreestr_checked. kitchen/mortgage/apartments promoted (dashboard/matching, not valuation — #2012). Refs #2008
2026-06-27 18:01:50 +00:00

1181 lines
56 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 — state-based SERP parser (137 fields per offer).
Стратегия (Stage 3 рефактор):
- Cian React micro-frontends хранят state в window._cianConfig['frontend-serp'].push().
- URL pattern: https://ekb.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2
ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema_Cian_SERP_Inventory sec 13).
ВАЖНО: Циан блокирует httpx/curl по TLS+fingerprint и сыплет Google reCAPTCHA на
датацентр-IP. Транспорт SERP переведён на BrowserFetcher (camoufox real-browser
fingerprint через tradein-browser сервис, #1806 / epic #883 Phase 2). Браузер
отдаёт полный SSR-HTML с Redux initialState — парсинг state не меняется.
Мобильный прокси (ha.mobileproxy.space) применяется server-side в браузер-контейнере
и ротирует IP сам, поэтому code-side _rotate_ip (changeip) больше не нужен.
НЕ используем anchor jitter: offer.geo.coordinates.{lat,lng} — точные координаты
прямо из SERP state. Jitter запрещён per implementation plan.
Exhaustive load (fetch_all_secondary):
- Cian SERP — регион-wide (не geo-bbox). Anchor-loop в существующем city-sweep
избыточен для Cian (все anchor'ы дают одну выдачу). Полный сбор = ОДИН проход
с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное деление диапазона).
"""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import logging
import math
import re
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlencode
import sentry_sdk
from app.core.config import settings
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.base import BaseScraper, ScrapedLot
from app.services.scrapers.browser_fetcher import BrowserFetcher
from app.services.scrapers.cian_state_parser import extract_state
from app.services.scrapers.house_type_normalizer import normalize_house_type
from app.services.scrapers.price_brackets import get_price_seed_brackets
from app.services.scrapers.repair_state_normalizer import (
infer_repair_state_from_text,
normalize_repair_state,
)
logger = logging.getLogger(__name__)
# Регион 4743 = Свердловская область (Cian internal region ID)
CIAN_EKB_REGION_ID = 4743
# SERP MFE name и state key (per Schema sec 1.2)
_MFE_SERP = "frontend-serp"
_STATE_KEY = "initialState"
# ── Константы exhaustive-загрузки ──────────────────────────────────────────────
# Верхняя граница цены при первом рекурсивном делении (нет явного hi).
# 200 млн ₽ заведомо выше ТОПа ЕКБ — дальние бакеты дадут 0 офферов.
_MAX_PRICE = 200_000_000
# Минимальный ценовой диапазон для рекурсии; при hi - lo < MIN_BRACKET
# прекращаем деление и пагинируем как есть (принимаем возможный «хвост»).
_MIN_BRACKET = 50_000
# Cian SERP показывает ~28 офферов на страницу.
_CIAN_OFFERS_PER_PAGE = 28
# Комнатности по умолчанию для exhaustive-загрузки.
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
# Если добавить room9 — запрос может давать 0 офферов или смешивать типы.
# До верификации кода студии — студии НЕ включаем в exhaustive-загрузку.
_DEFAULT_ROOMS_BUCKETS: list[tuple[int, ...]] = [
(1,),
(2,),
(3,),
(4,),
(5,),
(6,),
]
class CianScraper(BaseScraper):
"""Cian SERP scraper. Тянет SSR-HTML через BrowserFetcher (camoufox).
Извлекает 137 полей на offer из Redux initialState через cian_state_parser.
Координаты точные — НЕТ anchor jitter (offer.geo.coordinates).
Транспорт: одна BrowserFetcher(source="cian") сессия открывается в __aenter__
и переиспользуется на всех page-fetch'ах (#1806). camoufox-fingerprint + мобильный
прокси server-side обходят Cian reCAPTCHA; IP ротирует сам прокси.
"""
name = "cian"
# ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema sec 13, closed Q4)
base_url = "https://ekb.cian.ru"
# Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра.
request_delay_sec = 5.0 # консервативно: Cian менее агрессивен чем Avito, но 5s безопасно
def __init__(self) -> None:
super().__init__()
self.request_delay_sec = get_scraper_delay(self.name)
self._browser: BrowserFetcher | None = None
async def __aenter__(self) -> CianScraper:
await super().__aenter__()
# Одна BrowserFetcher(source="cian") сессия на весь scrape — camoufox real-browser
# fingerprint через tradein-browser сервис (#1806). Сервер роутит /fetch на
# отдельный браузер+мобильный прокси для cian. Прокси ротирует IP сам, поэтому
# code-side warm-up cookies и changeip-ротация больше не нужны.
self._browser = BrowserFetcher(source="cian")
await self._browser.__aenter__()
return self
async def __aexit__(self, *args: Any) -> None:
if self._browser is not None:
await self._browser.__aexit__(*args)
self._browser = None
await super().__aexit__(*args)
# ── Anti-block: IP rotation ───────────────────────────────────────────────
async def _rotate_ip(self) -> bool:
"""No-op после миграции на BrowserFetcher (#1806).
Мобильный прокси в браузер-контейнере ротирует IP сам, code-side changeip
больше не нужен. Метод сохранён (returns False) для обратной совместимости
с вызовом в _walk_price_range и тестами, которые его мокают.
"""
return False
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 для reference; Cian не поддерживает bbox).
Cian SERP отдаёт весь ЕКБ — фильтрация по радиусу происходит в postgres
через ST_DWithin после сохранения (точные coords из state).
rooms — список из 1,2,3,4 (Cian's room codes). Если None — все.
page — страница выдачи (Cian ~28 объявлений на страницу).
"""
url = self._build_url(rooms, page)
try:
assert self._browser is not None
html = await self._browser.fetch(url)
except Exception:
logger.exception("cian BrowserFetcher fetch failed for url=%s", url)
return []
lots = self._parse_serp_html(html)
logger.info(
"cian: %d lots fetched rooms=%s page=%d url=%s",
len(lots),
rooms,
page,
url,
)
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 комнаты × N страниц × ~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 (lat=%.4f lon=%.4f radius=%dm)",
len(seen),
lat,
lon,
radius_m,
)
return list(seen.values())
def _build_url(
self,
rooms: tuple[int, ...] | None = None,
page: int = 1,
min_price: int | None = None,
max_price: int | None = None,
) -> str:
"""URL для Cian каталога вторички ЕКБ.
Используем ekb.cian.ru (city-specific subdomain).
Регион задаётся через region= param как fallback для reliability.
min_price / max_price — опциональные ценовые границы (руб.) для price-бакетов.
"""
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:
params.append((f"room{r}", "1"))
if min_price is not None:
params.append(("minprice", min_price))
if max_price is not None:
params.append(("maxprice", max_price))
if page > 1:
params.append(("p", page))
return f"{self.base_url}/cat.php?{urlencode(params)}"
def _extract_total_offers(self, html: str) -> int | None:
"""Извлечь totalOffers из Redux state Cian SERP.
Использует тот же extract_state, что и _parse_serp_html.
Возвращает None при captcha/ошибке парсинга.
"""
state = extract_state(html, mfe=_MFE_SERP, key=_STATE_KEY)
if state is None:
return None
total = state.get("results", {}).get("totalOffers")
if total is None:
return None
try:
return int(total)
except (TypeError, ValueError):
return None
async def _fetch_page_html(
self,
rooms: tuple[int, ...] | None,
page: int,
min_price: int | None,
max_price: int | None,
) -> str | None:
"""GET одной SERP-страницы через BrowserFetcher, возвращает HTML или None при ошибке."""
assert self._browser is not None
url = self._build_url(rooms, page, min_price, max_price)
try:
return await self._browser.fetch(url)
except Exception:
logger.exception("cian: BrowserFetcher fetch failed url=%s", url)
return None
async def fetch_all_secondary(
self,
*,
rooms_buckets: list[tuple[int, ...]] | None = None,
price_cap_per_bucket: int = 1400,
max_pages_per_bucket: int = 54,
concurrency: int = 5,
secondary_only: bool = True,
on_bucket: Callable[..., Any] | None = None,
on_progress: Callable[[int], None] | None = None,
skip_buckets: set[str] | None = None,
) -> list[ScrapedLot]:
"""Exhaustive-загрузка Cian ЕКБ вторички через партиционирование КОМНАТНОСТЬ × ЦЕНА.
Обходит Cian SERP-cap (~54 стр/запрос ≈ 1500 результатов на запрос). Вместо
одной корень-бисекции [0, _MAX_PRICE] на комнатность (холостые probe-спуски
через пустой верх) — стартуем с общей data-driven seed-сетки ценовых брекетов
(get_price_seed_brackets() — shared EKB grid, общая с avito/yandex). Каждый
(комнатность × seed-брекет) дальше дробится _walk_price_range если
totalOffers > price_cap_per_bucket. Последний seed-брекет ОТКРЫТ (hi=None →
maxprice не ставится) — ловит весь хвост люкса без потолка.
Страницы внутри leaf-бакета запрашиваются параллельно (asyncio.gather + Semaphore).
Дедуп по source_id (dict seen), ОБЩИЙ по всем брекетам и комнатностям.
Параметры:
rooms_buckets: список room-кодов Cian для перебора (default: 1-6).
price_cap_per_bucket: максимум офферов в бакете перед делением (< 1500).
max_pages_per_bucket: Cian hard cap ~54; не превышать.
concurrency: максимум параллельных page-фетчей в leaf-бакете (default=5).
secondary_only: если True (default) — отбрасывает новостройки
(listing_segment=="novostroyki") после парсинга, до save/on_bucket.
object_type=1 SERP-параметр Cian ненадёжен (~5% выдачи), поэтому
фильтруем по authoritative listing_segment из offer.newbuilding.id.
on_bucket: опциональный callback(bucket_key, list[ScrapedLot]) после каждого
leaf-бакета. Может быть async или sync. Если кидает исключение — прерывает прогон.
on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket).
skip_buckets: множество ключей «room_label:lo:hi» уже завершённых бакетов —
пагинация и on_bucket для них пропускаются. Probe-запросы (для split-решения)
всё равно выполняются (их мало по сравнению с пагинацией).
Возвращает list[ScrapedLot] уникальных лотов (дедуп по source_id/source_url).
"""
_buckets = rooms_buckets if rooms_buckets is not None else _DEFAULT_ROOMS_BUCKETS
seen: dict[str, ScrapedLot] = {}
for rooms in _buckets:
room_label = f"room{'_'.join(str(r) for r in rooms)}"
logger.info(
"cian exhaustive: starting %s (price_cap=%d concurrency=%d secondary_only=%s)",
room_label,
price_cap_per_bucket,
concurrency,
secondary_only,
)
before = len(seen)
# Перебираем seed-брекеты: для ЗАКРЫТЫХ передаём hi = br_hi - 1
# (maxprice эксклюзивный сверху → соседние брекеты не пересекаются), для
# ОТКРЫТОГО (br_hi is None) — hi=None (без потолка). Дедуп по source_id
# в общем `seen` страхует на стыках.
for br_lo, br_hi in get_price_seed_brackets():
walk_hi = br_hi - 1 if br_hi is not None else None
await self._walk_price_range(
rooms=rooms,
lo=br_lo,
hi=walk_hi,
seen=seen,
price_cap_per_bucket=price_cap_per_bucket,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
)
room_collected = len(seen) - before
logger.info(
"cian exhaustive: %s done — collected %d (total unique=%d)",
room_label,
room_collected,
len(seen),
)
if on_progress is not None:
on_progress(len(seen))
logger.info("cian exhaustive: DONE — total unique=%d lots", len(seen))
return list(seen.values())
async def _walk_price_range(
self,
*,
rooms: tuple[int, ...],
lo: int,
hi: int | None,
seen: dict[str, ScrapedLot],
price_cap_per_bucket: int,
max_pages_per_bucket: int,
concurrency: int = 5,
secondary_only: bool = True,
on_bucket: Callable[..., Any] | None = None,
skip_buckets: set[str] | None = None,
_depth: int = 0,
) -> None:
"""Рекурсивное адаптивное бинарное партиционирование ценового диапазона [lo, hi].
Алгоритм для ЗАКРЫТОГО брекета (hi is not None):
1. Запросить page=1 с min_price=lo, max_price=hi → totalOffers.
2. Если totalOffers <= cap → пагинировать leaf-бакет параллельно (concurrency).
3. Если totalOffers > cap → разбить бакет пополам (рекурсия).
Guard: hi - lo < _MIN_BRACKET → пагинировать как есть (логируем WARNING).
ОТКРЫТЫЙ брекет (hi is None) — верхний seed-брекет без потолка (maxprice не
ставится, Cian отдаёт всё ≥lo). Делить нельзя (mid=(lo+hi)//2 невозможен) —
probe + пагинируем leaf напрямую. Хвост 250М+ крошечный; за cap обычно не
выходит, если вышел — пагинируем до max_pages + WARNING (tail-loss accepted).
После пагинации leaf-бакета вызывает on_bucket(bucket_key, bucket_lots) если задан.
bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый) —
checkpoint-ключ для resume.
on_bucket может быть async или sync. Исключение в on_bucket прерывает прогон.
skip_buckets: если bucket_key в skip_buckets — пагинацию и on_bucket пропускаем.
secondary_only: если True — новостройки (listing_segment=="novostroyki") отбрасываются
после сбора bucket_lots, до дедупа в seen и вызова on_bucket.
"""
room_label = f"room{'_'.join(str(r) for r in rooms)}"
_lo_param = lo if lo > 0 else None
_hi_repr = "open" if hi is None else str(hi)
# ── Шаг 1: probe page 1 ────────────────────────────────────────────────
html = await self._fetch_page_html(rooms, 1, _lo_param, hi)
await self.sleep_between_requests()
total: int | None = None
if html is not None:
total = self._extract_total_offers(html)
# Ретрай на captcha/ошибку: 1 повторный fetch. Мобильный прокси в браузер-
# контейнере ротирует IP сам (#1806), поэтому отдельный changeip-вызов
# (_rotate_ip) больше не нужен — просто повторяем запрос на чистом IP.
if total is None:
logger.warning(
"cian: totalOffers=None for %s [%d, %s] depth=%d — retry (proxy auto-rotates IP)",
room_label,
lo,
_hi_repr,
_depth,
)
await self._rotate_ip() # no-op (back-compat); прокси ротирует IP сам
html = await self._fetch_page_html(rooms, 1, _lo_param, hi)
await self.sleep_between_requests()
if html is not None:
total = self._extract_total_offers(html)
if total is None:
logger.error(
"cian: skipping bucket %s [%d, %s] — totalOffers unavailable after retry",
room_label,
lo,
_hi_repr,
)
return
logger.info(
"cian: %s [%d, %s] totalOffers=%d depth=%d",
room_label,
lo,
_hi_repr,
total,
_depth,
)
if total == 0:
return
# ── ОТКРЫТЫЙ брекет (hi is None): делить нельзя — пагинируем leaf напрямую ─
# total известен из probe → max_pages = min(ceil(total/per_page), cap).
# Если total > cap (не должно для 250М+, но guard) — пагинируем как есть до
# max_pages_per_bucket с WARNING (хвост люкса крошечный, tail-loss accepted).
if hi is None:
if total > price_cap_per_bucket:
logger.warning(
"cian: OPEN bucket %s [%d, open] totalOffers=%d > cap=%d — paginating "
"without split (tail-loss accepted, lux tail tiny)",
room_label,
lo,
total,
price_cap_per_bucket,
)
await self._paginate_open_bucket(
rooms=rooms,
room_label=room_label,
lo=lo,
html=html,
total=total,
seen=seen,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
)
return
# ── Шаг 2: деление или пагинация (только ЗАКРЫТЫЙ брекет, hi: int) ─────
bracket_size = hi - lo
need_split = total > price_cap_per_bucket
too_narrow = bracket_size < _MIN_BRACKET
if need_split and too_narrow:
logger.warning(
"cian: %s [%d, %d] totalOffers=%d > cap=%d but bracket=%d < MIN_BRACKET=%d "
"— paginating as-is (tail loss ~%d)",
room_label,
lo,
hi,
total,
price_cap_per_bucket,
bracket_size,
_MIN_BRACKET,
max(0, total - price_cap_per_bucket),
)
need_split = False # принудительно пагинируем
if need_split:
mid = (lo + hi) // 2
# [lo, mid]
await self._walk_price_range(
rooms=rooms,
lo=lo,
hi=mid,
seen=seen,
price_cap_per_bucket=price_cap_per_bucket,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
_depth=_depth + 1,
)
# [mid+1, hi]
await self._walk_price_range(
rooms=rooms,
lo=mid + 1,
hi=hi,
seen=seen,
price_cap_per_bucket=price_cap_per_bucket,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
_depth=_depth + 1,
)
return
# Закрытый leaf-бакет — общая пагинация (см. _paginate_leaf_bucket).
await self._paginate_leaf_bucket(
rooms=rooms,
room_label=room_label,
lo=lo,
hi=hi,
html=html,
total=total,
seen=seen,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
)
async def _paginate_open_bucket(
self,
*,
rooms: tuple[int, ...],
room_label: str,
lo: int,
html: str | None,
total: int,
seen: dict[str, ScrapedLot],
max_pages_per_bucket: int,
concurrency: int,
secondary_only: bool,
on_bucket: Callable[..., Any] | None,
skip_buckets: set[str] | None,
) -> None:
"""Пагинация ОТКРЫТОГО leaf-бакета (hi=None, без maxprice)."""
await self._paginate_leaf_bucket(
rooms=rooms,
room_label=room_label,
lo=lo,
hi=None,
html=html,
total=total,
seen=seen,
max_pages_per_bucket=max_pages_per_bucket,
concurrency=concurrency,
secondary_only=secondary_only,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
)
async def _paginate_leaf_bucket(
self,
*,
rooms: tuple[int, ...],
room_label: str,
lo: int,
hi: int | None,
html: str | None,
total: int,
seen: dict[str, ScrapedLot],
max_pages_per_bucket: int,
concurrency: int,
secondary_only: bool,
on_bucket: Callable[..., Any] | None,
skip_buckets: set[str] | None,
) -> None:
"""Параллельная пагинация leaf-бакета [lo, hi] (закрытого или открытого).
ОТКРЫТЫЙ (hi is None): maxprice не ставится (_build_url отдаёт всё ≥lo),
bucket_key = "room_label:lo:open". Страница 1 берётся из probe-`html`.
"""
_lo_param = lo if lo > 0 else None
_hi_repr = "open" if hi is None else str(hi)
bucket_key = f"{room_label}:{lo}:{_hi_repr}"
# Если бакет уже завершён в предыдущем запуске — пропускаем пагинацию и on_bucket.
if skip_buckets and bucket_key in skip_buckets:
logger.info(
"cian: skip bucket %s — already done (resume)",
bucket_key,
)
return
pages_needed = math.ceil(total / _CIAN_OFFERS_PER_PAGE)
max_pages = min(pages_needed, max_pages_per_bucket)
if pages_needed > max_pages_per_bucket:
# Cian hard-cap срабатывает: часть офферов недостижима через пагинацию.
# Это происходит когда bracket < _MIN_BRACKET но totalOffers всё равно > cap.
tail_loss = total - max_pages_per_bucket * _CIAN_OFFERS_PER_PAGE
logger.warning(
"cian: %s [%d, %s] totalOffers=%d exceeds page cap "
"(max_pages=%d × %d=%d offers) — tail loss ~%d offers, "
"consider narrowing price_cap_per_bucket or _MIN_BRACKET",
room_label,
lo,
_hi_repr,
total,
max_pages_per_bucket,
_CIAN_OFFERS_PER_PAGE,
max_pages_per_bucket * _CIAN_OFFERS_PER_PAGE,
tail_loss,
)
# Страница 1 уже есть (html из probe выше); остальные — параллельно.
sem = asyncio.Semaphore(concurrency)
async def _one_page(p: int) -> list[ScrapedLot]:
if p == 1:
# Используем уже полученный HTML от probe
return self._parse_serp_html(html) if html else []
async with sem:
page_html = await self._fetch_page_html(rooms, p, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec)
if page_html is None:
logger.warning(
"cian: page_html=None %s [%d, %s] page=%d — skipping page",
room_label,
lo,
_hi_repr,
p,
)
return []
return self._parse_serp_html(page_html)
page_results = await asyncio.gather(
*[_one_page(p) for p in range(1, max_pages + 1)],
return_exceptions=True,
)
bucket_lots: list[ScrapedLot] = []
for p_idx, res in enumerate(page_results, start=1):
if isinstance(res, BaseException):
logger.warning(
"cian: page exception %s [%d, %s] page=%d%r",
room_label,
lo,
_hi_repr,
p_idx,
res,
)
else:
bucket_lots.extend(res)
collected_this_bucket = len(bucket_lots)
# ── Фильтр новостроек (secondary_only) ────────────────────────────────
dropped_nb = 0
if secondary_only:
filtered = [lot for lot in bucket_lots if lot.listing_segment != "novostroyki"]
dropped_nb = collected_this_bucket - len(filtered)
bucket_lots = filtered
# Дедуп в общий seen
for lot in bucket_lots:
key = lot.source_id or lot.source_url
if key:
seen[key] = lot
logger.info(
"cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d",
room_label,
lo,
_hi_repr,
max_pages,
collected_this_bucket,
dropped_nb,
len(seen),
)
# ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots:
res_cb = on_bucket(bucket_key, bucket_lots)
if inspect.isawaitable(res_cb):
await res_cb
def _parse_serp_html(self, html: str) -> list[ScrapedLot]:
"""Извлечь offers из Cian Redux state.
Использует cian_state_parser.extract_state() — общая утилита Stage 2.
Возвращает пустой список если state не найден.
"""
state = extract_state(html, mfe=_MFE_SERP, key=_STATE_KEY)
if state is None:
logger.warning(
"cian SERP state extraction failed (mfe=%s key=%s) — "
"возможно Cian изменил структуру или вернул captcha",
_MFE_SERP,
_STATE_KEY,
)
return []
offers_data: list[dict[str, Any]] = state.get("results", {}).get("offers", [])
if not offers_data:
logger.warning(
"cian state found but results.offers пуст (totalOffers=%s)",
state.get("results", {}).get("totalOffers", "?"),
)
return []
logger.info(
"cian SERP state ok: %d offers (totalOffers=%s)",
len(offers_data),
state.get("results", {}).get("totalOffers", "?"),
)
lots: list[ScrapedLot] = []
for offer in offers_data:
lot = self._offer_to_lot(offer)
if lot is not None:
lots.append(lot)
raw_count = len(offers_data)
saved_count = len(lots)
# Охрана от silent-failure: если получили offers, но ни один не прошёл парсинг
# — likely schema regression (как descriptionMinhash str→list[int] 2026-05-23).
if raw_count > 0 and saved_count == 0:
logger.error(
"cian SERP: 0/%d offers прошли _offer_to_lot — возможна schema regression",
raw_count,
)
try:
if settings.glitchtip_dsn:
sentry_sdk.capture_message(
f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot"
" — possible schema regression",
level="error",
)
except Exception:
pass # sentry_sdk not initialised in dev
return lots
def _offer_to_lot(self, offer: dict[str, Any]) -> ScrapedLot | None:
"""Парсинг одного Cian offer (137 fields) → ScrapedLot.
Маппинг полей per Schema_Cian_SERP_Inventory sec 3, sec 8, sec 11.
Без anchor jitter: координаты из offer.geo.coordinates (точные).
"""
try:
# ── Identity ─────────────────────────────────────────────────────
offer_id = offer.get("cianId") or offer.get("id")
if not offer_id:
logger.debug("cian offer пропущен: нет cianId/id")
return None
source_id = str(offer_id)
url = offer.get("fullUrl")
if not url:
url = f"{self.base_url}/sale/flat/{offer_id}/"
# ── Цена ─────────────────────────────────────────────────────────
bargain: dict[str, Any] = offer.get("bargainTerms") or {}
price = bargain.get("priceRur") or bargain.get("price")
if not price:
logger.debug("cian offer %s пропущен: нет цены", offer_id)
return None
try:
price_rub = int(price)
except (TypeError, ValueError):
logger.debug("cian offer %s: не удалось распарсить цену %r", offer_id, price)
return None
if price_rub <= 0:
return None
# ── Параметры квартиры ───────────────────────────────────────────
rooms: int | None = offer.get("roomsCount")
# Студия: flatType == 'studio' → 0 комнат
if offer.get("flatType") == "studio" and rooms is None:
rooms = 0
area_raw = offer.get("totalArea")
area_m2: float | None = None
if area_raw is not None:
try:
area_m2 = float(area_raw)
except (TypeError, ValueError):
pass
living_area_raw = offer.get("livingArea")
living_area_m2: float | None = None
if living_area_raw is not None:
try:
living_area_m2 = float(living_area_raw)
except (TypeError, ValueError):
pass
# kitchen_area: сырое (str) в raw_payload + промоутим float в ScrapedLot
# колонку kitchen_area_m2 (#2008, Class B — дашборд/matching, не valuation).
kitchen_area_raw = offer.get("kitchenArea")
kitchen_area_m2: float | None = None
if kitchen_area_raw is not None:
try:
kitchen_area_m2 = float(kitchen_area_raw)
except (TypeError, ValueError):
pass
floor: int | None = offer.get("floorNumber")
# ── Здание ───────────────────────────────────────────────────────
building: dict[str, Any] = offer.get("building") or {}
total_floors: int | None = building.get("floorsCount")
year_built: int | None = building.get("buildYear")
# house_type: cian materialType (camelCase: monolithBrick / gasSilicateBlock
# / stalin / ...) НЕ равен каноничному enum → estimator soft-penalty ложно
# штрафовал аналог. Нормализуем на ингесте (#2008, Class A); сырьё — в
# raw_payload['raw_material_type'] для аудита.
raw_material_type: str | None = building.get("materialType")
house_type: str | None = normalize_house_type(raw_material_type)
# Newbuilding deadline year — если нет buildYear
if year_built is None:
deadline = building.get("deadline") or {}
year_built = deadline.get("year")
# ── Кадастр ─────────────────────────────────────────────────────
# cadastralNumber — кадастр КВАРТИРЫ
cadastral_number: str | None = offer.get("cadastralNumber")
# buildingCadastralNumber — кадастр ДОМА
building_cadastral_number: str | None = offer.get("buildingCadastralNumber")
# ── Геолокация (точные координаты — NO jitter) ───────────────────
geo: dict[str, Any] = offer.get("geo") or {}
coords: dict[str, Any] = geo.get("coordinates") or {}
lat: float | None = coords.get("lat")
lon: float | None = coords.get("lng")
# Если нет coords → сохраняем без lat/lon (geocode-missing обработает позже)
if lat is None or lon is None:
logger.debug(
"cian offer %s: нет точных координат в state (geo=%s)",
offer_id,
coords,
)
lat = lon = None
# ── Адрес из geo.address[] ───────────────────────────────────────
# newbuilding нужен ДО _format_address: для ЖК geo.address[] часто без
# части {type:"house"} → дом-номер восстанавливаем из building/кадастра/
# названия ЖК (#1773), иначе все корпуса схлопываются в house-less
# catch-all и same-building anchor рвётся.
address_parts = geo.get("address") or []
newbuilding: dict[str, Any] = offer.get("newbuilding") or {}
nb_id = newbuilding.get("id")
is_newbuilding = bool(nb_id) and int(nb_id) > 0
address = _format_address(
address_parts,
building=building,
building_cadastral_number=building_cadastral_number,
newbuilding=newbuilding,
is_newbuilding=is_newbuilding,
)
# ── Metro stations ───────────────────────────────────────────────
undergrounds: list[dict[str, Any]] = geo.get("undergrounds") or []
metro_stations = [
{
"name": u.get("name"),
"time": u.get("time"),
"mode": u.get("transportType"),
"line_color": u.get("lineColor"),
"line_id": u.get("lineId"),
"is_default": u.get("isDefault", False),
}
for u in undergrounds
if u.get("name")
]
# ── Newbuilding link ─────────────────────────────────────────────
# newbuilding / nb_id / is_newbuilding уже вычислены выше (до адреса).
# id == 0 — sentinel для вторички (per Schema sec 20.5)
house_source: str | None = None
house_ext_id: str | None = None
listing_segment: str | None = None
if is_newbuilding:
# Предпочитаем кадастр ДОМА как same-building anchor (#1773): nb_id
# группирует по ЖК целиком, а один ЖК — это несколько корпусов
# (6679/11110/7019). buildingCadastralNumber стабильно идентифицирует
# ФИЗИЧЕСКИЙ дом → Tier 0 cadastr_exact в match_or_create_house
# схлопывает раздробленные корпуса. nb_id оставляем fallback'ом.
if building_cadastral_number:
house_source = "cian_building_cad"
house_ext_id = building_cadastral_number
else:
house_source = "cian_newbuilding"
house_ext_id = str(nb_id)
listing_segment = "novostroyki"
else:
house_source = "cian"
listing_segment = "vtorichka"
# ── Дополнительные характеристики ────────────────────────────────
balconies_count: int | None = offer.get("balconiesCount")
loggias_count: int | None = offer.get("loggiasCount")
bedrooms_count: int | None = offer.get("bedroomsCount")
has_balcony: bool | None = None
if balconies_count is not None:
has_balcony = balconies_count > 0
elif loggias_count is not None:
has_balcony = loggias_count > 0
# Мебель и repair_state
# hasFurniture (мебель вообще) и isSoldFurnished (продаётся с мебелью) —
# разная семантика, нельзя OR-склеивать: явный False у hasFurniture
# затирался бы коалесингом. Берём hasFurniture; только если он None,
# падаем на isSoldFurnished как fallback (#1388).
_has_furniture = offer.get("hasFurniture")
has_furniture: bool | None = (
_has_furniture if _has_furniture is not None else offer.get("isSoldFurnished")
)
# decoration — для новостроек (отделка); repair_state — для вторички
# Cian SERP: offer.decoration содержит raw значения (without/cosmetic/euro/design/...)
# Нормализуем до enum needs_repair/standard/good/excellent при инgesте.
repair_state: str | None = normalize_repair_state(offer.get("decoration"))
# ── Продавец ─────────────────────────────────────────────────────
phones: list[dict[str, Any]] = offer.get("phones") or []
is_homeowner: bool | None = offer.get("isByHomeowner")
is_pro_seller: bool | None = offer.get("isPro")
# ── Сделка ───────────────────────────────────────────────────────
sale_type: str | None = bargain.get("saleType")
bargain_allowed: bool | None = bargain.get("bargainAllowed")
# ── Class B promote (#2008): ипотека / апартаменты / проверка ЕГРН ──
# Раньше лежали только в raw_payload; промоутим в колонки listings для
# дашборда покрытия и matching (НЕ для valuation — estimator их не читает).
mortgage_available: bool | None = bargain.get("mortgageAllowed")
is_apartments: bool | None = offer.get("isApartments")
is_rosreestr_checked: bool | None = offer.get("isRosreestrChecked")
# ── Description + minhash ─────────────────────────────────────────
description: str | None = offer.get("description")
# Cian предоставляет готовый minhash для dedup и cross-source matching.
# С ~2026-05-23 Cian изменил тип descriptionMinhash: str → list[int].
# Нормализуем оба варианта; НЕ sorted() — порядок band-ов значим для LSH.
_raw_minhash = offer.get("descriptionMinhash")
if isinstance(_raw_minhash, list):
description_minhash: str | None = ",".join(str(x) for x in _raw_minhash) or None
elif isinstance(_raw_minhash, str):
description_minhash = _raw_minhash or None
else:
description_minhash = None
# Если Cian не дал minhash — вычисляем простой SHA1 из description
if not description_minhash and description:
description_minhash = hashlib.sha1(
description.lower().encode("utf-8", errors="replace")
).hexdigest()[:32]
# Fallback: repair_state из описания, если decoration отсутствует (#622)
if repair_state is None and description:
repair_state = infer_repair_state_from_text(description)
# ── Фото ─────────────────────────────────────────────────────────
photo_urls: list[str] = [
p["fullUrl"]
for p in (offer.get("photos") or [])
if isinstance(p, dict) and p.get("fullUrl")
]
# ── Дата публикации ───────────────────────────────────────────────
added_ts = offer.get("addedTimestamp")
listing_date = None
if added_ts:
try:
listing_date = datetime.fromtimestamp(int(added_ts), tz=UTC).date()
except (TypeError, ValueError, OSError):
pass
# ── Raw payload (полный offer для enrichment в будущем) ───────────
raw_payload: dict[str, Any] = {
"cian_id": offer_id,
"flat_type": offer.get("flatType"),
"is_apartments": offer.get("isApartments"),
"offer_type": offer.get("offerType"),
"category": offer.get("category"),
"has_furniture": has_furniture,
# сырой kitchenArea (str) — колонка kitchen_area_m2 (float) промоучена выше
"kitchen_area_m2": kitchen_area_raw,
# сырой materialType (camelCase) — house_type нормализован выше (#2008)
"raw_material_type": raw_material_type,
"mortgage_allowed": bargain.get("mortgageAllowed"),
"sale_type": sale_type,
"newbuilding_name": newbuilding.get("name"),
"is_from_developer": (
offer.get("fromDeveloper") or newbuilding.get("isFromDeveloper")
),
"builders_ids": offer.get("buildersIds"),
"is_rosreestr_checked": offer.get("isRosreestrChecked"),
"is_layout_approved": offer.get("isLayoutApproved"),
"user_id": offer.get("userId"),
"published_user_id": offer.get("publishedUserId"),
"is_by_commercial_owner": offer.get("isByCommercialOwner"),
"is_cian_partner": offer.get("isCianPartner"),
"description_words_highlighted": offer.get("descriptionWordsHighlighted"),
"building_parking": building.get("parking"),
"building_total_area": building.get("totalArea"),
}
return ScrapedLot(
source="cian",
source_url=url,
source_id=source_id,
address=address,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area_m2,
floor=floor,
total_floors=total_floors,
year_built=year_built,
house_type=house_type,
repair_state=repair_state,
has_balcony=has_balcony,
kitchen_area_m2=kitchen_area_m2,
mortgage_available=mortgage_available,
is_apartments=is_apartments,
is_rosreestr_checked=is_rosreestr_checked,
kadastr_num=cadastral_number,
house_source=house_source,
house_ext_id=house_ext_id,
listing_segment=listing_segment,
price_rub=price_rub,
price_per_m2=None, # compute_price_per_m2() вычислит
listing_date=listing_date,
photo_urls=photo_urls,
raw_payload=raw_payload,
# Cian-specific (Stage 2 fields)
living_area_m2=living_area_m2,
bedrooms_count=bedrooms_count,
balconies_count=balconies_count,
loggias_count=loggias_count,
description_minhash=description_minhash,
cadastral_number=cadastral_number,
building_cadastral_number=building_cadastral_number,
phones=phones,
is_homeowner=is_homeowner,
is_pro_seller=is_pro_seller,
bargain_allowed=bargain_allowed,
sale_type=sale_type,
metro_stations=metro_stations,
)
except Exception:
_oid = offer.get("cianId") or offer.get("id")
logger.exception("cian _offer_to_lot failed for offer_id=%s", _oid)
return None
# ── Address formatter ────────────────────────────────────────────────────────
# Дом-токен в хвосте строки: «29», «29А», «12 к1», «5/2», «10 стр.3», «1С7».
# Якорим в конец строки (\Z) — берём именно завершающий номер, не цифры из
# названия ЖК («Суходольский квартал»). Не матчим «р-н», «мкр» и пр. слова.
_HOUSE_TOKEN_RE = re.compile(
r"(\d+[А-Яа-яA-Za-z]?" # базовый номер: 29 / 29А / 1С
r"(?:\s*(?:к|корп\.?|стр\.?|с)\s*\d+[А-Яа-яA-Za-z]?)?" # корпус/строение
r"(?:\s*/\s*\d+[А-Яа-яA-Za-z]?)?)" # дробь 5/2
r"\s*\Z"
)
def _recover_newbuilding_house(
*,
building: dict[str, Any] | None,
building_cadastral_number: str | None,
newbuilding: dict[str, Any] | None,
) -> str | None:
"""Восстановить дом-токен для новостройки, когда geo.address[] без {type:"house"}.
Приоритет (#1773):
(a) building.houseNumber / building.address — если SERP-state их отдал;
(b) building_cadastral_number — стабильный same-building anchor: если ни
номер, ни название не дали дом, добавляем «кад. 66:41:...:11396» как
ключ-якорь, чтобы раздробленные корпуса схлопывались по кадастру;
(c) хвостовой дом-токен из newbuilding.name («… 29» / «… 29А к1»).
НЕ выдумываем catch-all номер: если ничего из (a)/(b)/(c) не нашли — None.
Кадастр (b) предпочтительнее как house anchor (см. _offer_to_lot).
"""
building = building or {}
newbuilding = newbuilding or {}
# (a) building.houseNumber / building.address — прямые поля SERP-state
house_number = building.get("houseNumber")
if house_number is not None:
token = str(house_number).strip()
if token:
return token
b_addr = (building.get("address") or "").strip()
if b_addr:
m = _HOUSE_TOKEN_RE.search(b_addr)
if m:
return m.group(1).strip()
# (c) хвостовой дом-токен из названия ЖК (до кадастра — он читабельнее)
nb_name = (newbuilding.get("name") or "").strip()
if nb_name:
m = _HOUSE_TOKEN_RE.search(nb_name)
if m:
return m.group(1).strip()
# (b) кадастр дома как anchor-ключ (последний приоритет в строке адреса,
# но самый надёжный для матчинга — Tier 0 cadastr_exact)
if building_cadastral_number:
cad = building_cadastral_number.strip()
if cad:
return f"кад. {cad}"
return None
def _format_address(
address_parts: list[dict[str, Any]],
*,
building: dict[str, Any] | None = None,
building_cadastral_number: str | None = None,
newbuilding: dict[str, Any] | None = None,
is_newbuilding: bool = False,
) -> str:
"""Сформировать читаемый адрес из geo.address[] Cian.
Пропускаем location (страна/регион) и metro — берём раион/улицу/дом.
Пример: [location=Москва, raion=Пресненский, street=..., house=1С7, metro=Москва-Сити]
'Пресненский, улица ..., 1С7'
#1773: для новостроек (is_newbuilding) geo.address[] часто без {type:"house"}.
Если части house нет — восстанавливаем номер дома через _recover_newbuilding_house
(building → название ЖК → кадастр) и дописываем его в конец строки, чтобы
same-building anchor (fingerprint/кадастр) не рвался по house-less улице.
"""
if not address_parts:
return "Екатеринбург (Cian)"
skip_types = {"location", "metro"}
parts: list[str] = []
has_house = False
for part in address_parts:
if not isinstance(part, dict):
continue
ptype = part.get("type", "")
if ptype == "house":
has_house = True
if ptype in skip_types:
continue
name = (part.get("fullName") or part.get("name") or "").strip()
if name:
parts.append(name)
# Восстановление дома только для новостроек без части house — вторичка с
# house-less адресом не трогается (не выдумываем номер).
if is_newbuilding and not has_house:
recovered = _recover_newbuilding_house(
building=building,
building_cadastral_number=building_cadastral_number,
newbuilding=newbuilding,
)
if recovered:
parts.append(recovered)
return ", ".join(parts) if parts else "Екатеринбург (Cian)"