"""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)