"""DomClick.ru scraper — вторичка через JSON BFF API (#1968). Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через BrowserFetcher(source="domclick") (generic provider → shared mobile proxy). QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy. Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (
 или bare body).
Парсинг: _extract_json() вытаскивает первый {...} из ответа.

Стратегия нумерации комнат (ROOM_BUCKETS):
  "st"  — студии (force rooms=0 в ScrapedLot)
  "1"–"4" — соответственно
  "5+"  — 5 и более комнат

Пагинация: offset 0, 20, 40, …, cap=2000. Если snippetsCount > 2000
для бакета, рекурсивно делим по цене (binary split) до тех пор пока
каждая ветка укладывается в cap.

# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
через detail-эндпоинт после первоначального сбора.
"""

from __future__ import annotations

import hashlib
import json
import logging
from datetime import date
from typing import Any
from urllib.parse import urlencode

from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.base import BaseScraper, ScrapedLot
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text

logger = logging.getLogger(__name__)

# ── API constants ─────────────────────────────────────────────────────────────

_BFF_BASE = "https://bff-search-web.domclick.ru"
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
_EKB_AREA_ID = "20561"

# Buckets to sweep — порядок влияет на логи.
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")

OFFSET_CAP: int = 2000  # max offset принятый BFF API
PAGE_SIZE: int = 20  # items per page (жёстко задан API)
# Потолок цены для binary split. 1 млрд ₽ — безопасно выше любой ЕКБ-квартиры;
# выбран так чтобы НЕ отбрасывать листинги дороже потолка при сплите unbounded-бакета
# (родительский count не ограничен сверху → clamped _lte должен покрывать весь хвост).
LTE_MAX: int = 1_000_000_000
MIN_PRICE_SPAN: int = 100_000  # ниже этого span прекращаем делить

# ── QRATOR block detection ────────────────────────────────────────────────────

_QRATOR_MARKERS: tuple[str, ...] = (
    "qrator",
    "bot_mitigation",
    "система защиты",
    "403 | домклик",
    "captcha",
    "access denied",
)

# ── EKB geo guard ─────────────────────────────────────────────────────────────

_EKB_LAT_MIN: float = 56.6
_EKB_LAT_MAX: float = 57.0
_EKB_LON_MIN: float = 60.2
_EKB_LON_MAX: float = 60.9
_EKB_REGION_NAME: str = "Екатеринбург"


# ── JSON extraction ───────────────────────────────────────────────────────────


def _extract_json(html: str) -> dict[str, Any]:
    """Извлекает JSON-объект из ответа BrowserFetcher.

    BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
    1. Bare body: ``{"result": ...}`` напрямую в .
    2. 
-wrapped: ``
{"result": ...}
``. Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов. Raises: DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры. ValueError: если JSON не найден или не является dict. """ # Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может # стоять за пределами head в крупных challenge-страницах. html_lower = html.lower() if any(m in html_lower for m in _QRATOR_MARKERS): raise DomClickBlockedError( f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})" ) start = html.find("{") end = html.rfind("}") if start == -1 or end == -1 or end <= start: raise ValueError(f"No JSON object found in BFF response (len={len(html)})") data = json.loads(html[start : end + 1]) if not isinstance(data, dict): raise ValueError(f"BFF response JSON is not a dict: {type(data)}") return data # type: ignore[return-value] # ── URL builders ────────────────────────────────────────────────────────────── def _build_offers_url( rooms: str, price_gte: int | None, price_lte: int | None, offset: int, ) -> str: """Строит URL для GET /api/offers/v1 с пагинацией. urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject). """ params: list[tuple[str, str]] = [ ("address", _EKB_ADDRESS_GUID), ("aids", _EKB_AREA_ID), ("deal_type", "sale"), ("category", "living"), ("offer_type", "flat"), ("rooms", rooms), ("sort", "qi"), ("sort_dir", "desc"), ("offset", str(offset)), ("limit", str(PAGE_SIZE)), ] if price_gte is not None: params.append(("sale_price__gte", str(price_gte))) if price_lte is not None: params.append(("sale_price__lte", str(price_lte))) return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}" def _build_count_url( rooms: str, price_gte: int | None, price_lte: int | None, ) -> str: """Строит URL для GET /api/offers/count/v1 (без offset/limit).""" params: list[tuple[str, str]] = [ ("address", _EKB_ADDRESS_GUID), ("aids", _EKB_AREA_ID), ("deal_type", "sale"), ("category", "living"), ("offer_type", "flat"), ("rooms", rooms), ("sort", "qi"), ("sort_dir", "desc"), ] if price_gte is not None: params.append(("sale_price__gte", str(price_gte))) if price_lte is not None: params.append(("sale_price__lte", str(price_lte))) return f"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}" def _parse_publish_date(iso: str | None) -> date | None: """Парсит ISO8601 дату публикации → date. None при ошибке.""" if not iso: return None try: return date.fromisoformat(iso[:10]) except (ValueError, TypeError): return None def _extract_agency_name(seller: dict[str, Any]) -> str | None: """Извлечь читаемое имя агентства/агента из seller-блока BFF offer. seller.company / seller.agent могут быть строкой ИЛИ dict (defensive — реальная форма поля варьируется). Приоритет: company → agent. Для dict пробуем типичные name-поля. Возвращает None если ничего читаемого нет. """ for key in ("company", "agent"): val = seller.get(key) if isinstance(val, str): name = val.strip() if name: return name elif isinstance(val, dict): for name_key in ("name", "title", "fullName"): nm = val.get(name_key) if isinstance(nm, str) and nm.strip(): return nm.strip() return None # ── DomClickScraper ────────────────────────────────────────────────────────── class DomClickScraper(BaseScraper): """DomClick вторичка через JSON BFF API. Источник = 'domklik'. Использует BrowserFetcher(source="domclick") (generic provider → shared mobile proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP блокируются QRATOR. Основной метод: fetch_city(city_id, rooms, pages). fetch_around() не реализован: DomClick не поддерживает geo-radius в URL. Counters (публичные после fetch_city): parse_failures — офферы с ошибкой маппинга geo_filtered — офферы вне ЕКБ bbox или с неверным offerRegionName blocked — True если sweep был прерван QRATOR-блоком fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape) """ name = "domklik" source = "domklik" base_url = "https://domclick.ru" # Консервативная задержка между страницами (fallback до init) request_delay_sec = 8.0 def __init__(self) -> None: super().__init__() self.request_delay_sec = get_scraper_delay(self.name) self.parse_failures: int = 0 self.geo_filtered: int = 0 self.blocked: bool = False # Не-block ошибки извлечения JSON (truncated/garbled response, неверная # структура). В отличие от parse_failures (per-item), это per-fetch ошибки, # которые ограничивают сбор бакета. Учитываются в honest-status pipeline. self.fetch_errors: int = 0 async def __aenter__(self) -> DomClickScraper: await super().__aenter__() return self # ── fetch_around — stub ─────────────────────────────────────────────────── async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list[ScrapedLot]: """DomClick не поддерживает geo-radius. Используй fetch_city().""" raise NotImplementedError( "DomClick не поддерживает geo-radius; используй fetch_city(city_id=...)" ) # ── Основной метод ──────────────────────────────────────────────────────── async def fetch_city( self, city_id: int, rooms: list[int] | None = None, pages: int = 100, ) -> list[ScrapedLot]: """Citywide sweep через BFF JSON API. Аргументы city_id и rooms принимаются для совместимости сигнатуры с вызывающим кодом (run_domclick_city_sweep), но: - city_id — vestigial (EКБ захардкожен через GUID). - rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри. Args: city_id: игнорируется (EKB захардкожен). rooms: игнорируется (ROOM_BUCKETS перебирается всегда). pages: максимальное число страниц на бакет (safety cap). Returns: Дедуплицированный по source_id список ScrapedLot. """ # TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory from app.services.scrapers.browser_fetcher import BrowserFetcher out_lots: list[ScrapedLot] = [] seen_ids: set[str] = set() async with BrowserFetcher(source="domclick") as fetcher: for bucket in ROOM_BUCKETS: logger.info( "domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d", bucket, city_id, pages, ) try: await self._sweep_bucket( fetcher=fetcher, rooms=bucket, price_gte=None, price_lte=None, seen_ids=seen_ids, out_lots=out_lots, pages=pages, ) except DomClickBlockedError: self.blocked = True logger.error( "domklik: QRATOR block during rooms=%r — aborting all buckets", bucket, ) break except (ValueError, TypeError) as exc: # Defensive: bucket-level ошибка не должна убивать весь sweep. # _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++), # но если что-то всё же всплыло — переходим к следующему бакету. self.fetch_errors += 1 logger.warning( "domklik: bucket rooms=%r failed (%s) — skipping to next bucket", bucket, exc, exc_info=True, ) continue logger.info( "domklik: fetch_city done city_id=%d total=%d " "parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s", city_id, len(out_lots), self.parse_failures, self.geo_filtered, self.fetch_errors, self.blocked, ) return out_lots # ── Internal sweep helpers ──────────────────────────────────────────────── async def _count( self, fetcher: Any, rooms: str, price_gte: int | None, price_lte: int | None, ) -> int: """Запрашивает snippetsCount для данного бакета (rooms + price range). DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0 (бакет пропускается, sweep продолжается). """ url = _build_count_url(rooms, price_gte, price_lte) logger.debug("domklik: count url=%s", url) html = await fetcher.fetch(url) try: data = _extract_json(html) except DomClickBlockedError: raise except (ValueError, TypeError, AttributeError): self.fetch_errors += 1 logger.warning( "domklik: _count JSON extract failed rooms=%r price=[%s,%s] — skip bucket", rooms, price_gte, price_lte, exc_info=True, ) return 0 # {"result": null} → None, {"snippetsCount": null} → None: harden обе. res = data.get("result") or {} raw = res.get("snippetsCount") return int(raw) if raw else 0 async def _sweep_bucket( self, fetcher: Any, rooms: str, price_gte: int | None, price_lte: int | None, seen_ids: set[str], out_lots: list[ScrapedLot], pages: int, ) -> None: """Рекурсивный sweep бакета (rooms, price_gte, price_lte). Если snippetsCount > OFFSET_CAP — делим диапазон цен пополам и рекурсируем в каждую половину. Остановка рекурсии: - span <= MIN_PRICE_SPAN → пагинируем как есть (с предупреждением о truncation) - snippetsCount == 0 → пропускаем - snippetsCount <= OFFSET_CAP → пагинируем Raises: DomClickBlockedError: если QRATOR-блок — propagate наверх. """ count = await self._count(fetcher, rooms, price_gte, price_lte) if count == 0: return if count > OFFSET_CAP: _gte = price_gte if price_gte is not None else 0 _lte = price_lte if price_lte is not None else LTE_MAX span = _lte - _gte if span <= MIN_PRICE_SPAN: logger.warning( "domklik: rooms=%r price=[%s,%s] count=%d > cap=%d " "but span=%d <= min=%d — paginating as-is (bucket truncated at %d)", rooms, price_gte, price_lte, count, OFFSET_CAP, span, MIN_PRICE_SPAN, OFFSET_CAP, ) await self._paginate( fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages ) return mid = (_gte + _lte) // 2 logger.debug( "domklik: rooms=%r count=%d > cap=%d — price-split [%d,%d] → [%d,%d]+[%d,%d]", rooms, count, OFFSET_CAP, _gte, _lte, _gte, mid, mid + 1, _lte, ) await self._sweep_bucket(fetcher, rooms, _gte, mid, seen_ids, out_lots, pages) await self._sweep_bucket(fetcher, rooms, mid + 1, _lte, seen_ids, out_lots, pages) else: await self._paginate(fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages) async def _paginate( self, fetcher: Any, rooms: str, price_gte: int | None, price_lte: int | None, seen_ids: set[str], out_lots: list[ScrapedLot], pages: int, ) -> None: """Пагинирует один бакет (rooms, price range) до исчерпания или cap. DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и break (трактуем как конец бакета — уже собранные lots сохраняются). Raises: DomClickBlockedError: propagate из _extract_json при QRATOR-блоке. """ force_rooms = 0 if rooms == "st" else None page_idx = 0 while page_idx < pages: offset = page_idx * PAGE_SIZE if offset >= OFFSET_CAP: break url = _build_offers_url(rooms, price_gte, price_lte, offset) logger.debug("domklik: offers url=%s", url) html = await fetcher.fetch(url) try: data = _extract_json(html) except DomClickBlockedError: raise except (ValueError, TypeError, AttributeError): self.fetch_errors += 1 logger.warning( "domklik: _paginate JSON extract failed rooms=%r offset=%d " "— ending bucket (lots so far preserved)", rooms, offset, exc_info=True, ) break # {"result": null} → None; harden до .get("items"). items: list[dict[str, Any]] = (data.get("result") or {}).get("items") or [] if not items: break for item in items: lot = self._map_item(item, force_rooms=force_rooms) if lot is None: continue if not self._is_geo_ok(item): self.geo_filtered += 1 continue key = lot.source_id or lot.source_url if key in seen_ids: continue seen_ids.add(key) out_lots.append(lot) page_idx += 1 if page_idx < pages and len(items) == PAGE_SIZE: await self.sleep_between_requests() elif not items or len(items) < PAGE_SIZE: break def _is_geo_ok(self, item: dict[str, Any]) -> bool: """Гео-гард: пропускает только листинги ЕКБ в bbox. aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку. """ region = item.get("offerRegionName", "") if region != _EKB_REGION_NAME: return False loc = item.get("location") or {} lat = loc.get("lat") lon = loc.get("lon") if lat is None or lon is None: return False return ( _EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX ) def _map_item( self, item: dict[str, Any], *, force_rooms: int | None = None ) -> ScrapedLot | None: """Маппинг offer-item из BFF API → ScrapedLot. Возвращает None если price <= 0 или при ошибке парсинга. """ try: price = item.get("price", 0) or 0 if price <= 0: return None item_id = item.get("id") source_id = str(item_id) if item_id is not None else None source_url: str = item.get("path", "") or "" loc = item.get("location") or {} lat: float | None = loc.get("lat") lon: float | None = loc.get("lon") address_obj = item.get("address") or {} address: str | None = address_obj.get("displayName") # 0 — sentinel «неизвестно» для area/floor/total_floors/buildYear # (DomClick эмитит 0 для under-construction / unknown) → None. obj_info = item.get("objectInfo") or {} area_raw = obj_info.get("area") area_m2: float | None = float(area_raw) if area_raw else None floor_raw = obj_info.get("floor") floor: int | None = int(floor_raw) if floor_raw else None house = item.get("house") or {} floors_raw = house.get("floors") total_floors: int | None = int(floors_raw) if floors_raw else None year_raw = house.get("buildYear") year_built: int | None = int(year_raw) if year_raw else None # rooms: 0 — ВАЛИДНОЕ значение (студия), поэтому проверка is not None, # а не truthy. force_rooms=0 (st-бакет) перебивает objectInfo. if force_rooms is not None: rooms: int | None = force_rooms else: rooms_raw = obj_info.get("rooms") rooms = int(rooms_raw) if rooms_raw is not None else None square_price_raw = item.get("squarePrice") price_per_m2: int | None = int(square_price_raw) if square_price_raw else None if price_per_m2 is None and area_m2 and area_m2 > 0: price_per_m2 = int(price / area_m2) # ── Описание + repair-инференс + minhash (Layer A enrichment) ───── # DomClick search не отдаёт renovation-enum: repair_state выводим из # текста описания (как cian #622 fallback — «евроремонт»/«без отделки»). # description_minhash — sha1 от описания (mirror cian) для cross-source # дедупа. description_raw = item.get("description") description: str | None = None if isinstance(description_raw, str): description = description_raw.strip() or None repair_state: str | None = None description_minhash: str | None = None if description: repair_state = infer_repair_state_from_text(description) description_minhash = hashlib.sha1( description.lower().encode("utf-8", errors="replace") ).hexdigest()[:32] # ── Продавец / агентство ────────────────────────────────────────── seller = item.get("seller") if not isinstance(seller, dict): seller = {} agency_name = _extract_agency_name(seller) flat_complex = item.get("flatComplex") or {} raw_payload: dict[str, Any] = { "isRosreestrApproved": item.get("isRosreestrApproved"), "squarePrice": square_price_raw, "lastPriceHistoryState": item.get("lastPriceHistoryState"), "flatComplex": { "name": flat_complex.get("name"), "id": flat_complex.get("id"), "slug": flat_complex.get("slug"), } if flat_complex else None, "updatedDate": item.get("updatedDate"), } # ── Доп. SERP-поля (Layer A enrichment) — мержим в raw_payload, ──── # сбрасывая None-значения чтобы payload оставался компактным; ничего # из существующих ключей не теряем. seller_compact = { k: seller.get(k) for k in ("agent", "company") if seller.get(k) is not None } _enrich: dict[str, Any] = { "is_sber_collateral": item.get("isSberCollateral"), "has_discount": item.get("hasDiscount"), "discount_value": item.get("discountValue"), "duplicates_offer_count": item.get("duplicatesOfferCount"), "seller": seller_compact or None, } raw_payload.update({k: v for k, v in _enrich.items() if v is not None}) publish_date = _parse_publish_date(item.get("publishedDate")) return ScrapedLot( source="domklik", source_url=source_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, price_rub=int(price), price_per_m2=price_per_m2, listing_segment="vtorichka", publish_date=publish_date, description=description, repair_state=repair_state, description_minhash=description_minhash, agency_name=agency_name, raw_payload=raw_payload, ) except Exception: self.parse_failures += 1 logger.warning( "domklik: _map_item failed for item id=%r (parse_failures=%d)", item.get("id"), self.parse_failures, exc_info=True, ) return None # ── Convenience runner (для Celery tasks) ──────────────────────────────────── async def scrape_domclick_city( city_id: int, rooms: list[int] | None = None, pages: int = 100, ) -> list[ScrapedLot]: """Удобная точка входа для вызова из Celery tasks. Пример:: import asyncio lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=None)) """ async with DomClickScraper() as scraper: return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)