gendesign/tradein-mvp/backend/app/services/scrapers/domclick.py
bot-backend dbef242716 feat(tradein/scrapers): domclick вторичка через bff-search-web JSON API (Layer A)
- DomClickScraper переписан на GET bff-search-web.domclick.ru/api/offers/v1
  с рекурсивным бинарным price-band bucketing (OFFSET_CAP=2000, open-band split
  через _HIGH_ANCHOR=30M); geo-guard по offerRegionName==ЕКБ / bbox
- scrape_pipeline.py: rooms=[0..5] (добавлен 5+), pages default=100,
  _DOMCLICK_PER_FETCH_S=4.0, честный статус mark_failed при 0 лотов + errors
- config.py: bff-search-web.domclick.ru добавлен в scrape_allowed_hosts
- data/sql/132_update_domclick_sweep_params.sql: UPDATE default_params (DORMANT)
2026-06-27 13:51:33 +03:00

666 lines
27 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.

"""DomClick.ru scraper — вторичка через bff-search-web JSON API (Layer A).
Стратегия (JSON API rewrite, replaces dead HTML/camoufox scrape):
GET https://bff-search-web.domclick.ru/api/offers/v1?... → list (up to 20 items)
GET https://bff-search-web.domclick.ru/api/offers/count/v1?... → snippetsCount
Транспорт: BrowserFetcher(source="domclick") — существующий браузерный пул
(tradein-browser), роутит bff-search-web.domclick.ru → generic provider →
shared RU mobile proxy. fetch() возвращает raw JSON body как строку.
Bucketing:
OFFSET_CAP=2000 (max 100 страниц × 20) — hardlimit DomClick API.
Когда snippetsCount > 2000 — рекурсивная бинарная partition по цене.
EKB вторичка ≈6400 лотов; только rooms=2 превышает cap (≈2215).
Layer B (detail-backfill) и Layer C (analytics XHR) — не реализованы.
"""
from __future__ import annotations
import json
import logging
from datetime import UTC, date, datetime
from typing import Any
from urllib.parse import urlencode, urljoin
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.domclick_exceptions import DomClickBlockedError
logger = logging.getLogger(__name__)
# ── Константы bucketing ──────────────────────────────────────────────────────
# Верхняя граница первого деления открытого (hi=None) бакета (30 млн ₽)
_HIGH_ANCHOR: int = 30_000_000
# Минимальная ширина ценового диапазона — ниже этого не бисектируем
_MIN_BAND: int = 100_000
# Максимальная глубина рекурсии
_MAX_DEPTH: int = 12
# ── Маппинг комнатности int → API token ─────────────────────────────────────
_ROOMS_TOKEN: dict[int, str] = {
0: "st",
1: "1",
2: "2",
3: "3",
4: "4",
5: "5+",
}
# ── Гео-guard: ЕКБ bbox ─────────────────────────────────────────────────────
_EKB_LAT_MIN: float = 56.7
_EKB_LAT_MAX: float = 56.95
_EKB_LON_MIN: float = 60.4
_EKB_LON_MAX: float = 60.8
def _is_ekb(offer: dict[str, Any]) -> bool:
"""Гео-guard: офер принадлежит ЕКБ по offerRegionName или bbox координат."""
if offer.get("offerRegionName") == "Екатеринбург":
return True
loc: dict[str, Any] = offer.get("location") or {}
lat_raw = loc.get("lat")
lon_raw = loc.get("lon")
if lat_raw is not None and lon_raw is not None:
try:
lat = float(lat_raw)
lon = float(lon_raw)
return _EKB_LAT_MIN <= lat <= _EKB_LAT_MAX and _EKB_LON_MIN <= lon <= _EKB_LON_MAX
except (TypeError, ValueError):
pass
return False
def _parse_publish_date(val: Any) -> date | None:
"""Парсим publishedDate: epoch int/str или ISO8601 строка. None при ошибке."""
if val is None:
return None
try:
if isinstance(val, int | float):
return datetime.fromtimestamp(int(val), tz=UTC).date()
s = str(val).strip()
if s.isdigit():
return datetime.fromtimestamp(int(s), tz=UTC).date()
# ISO8601 — берём первые 10 символов (YYYY-MM-DD)
return date.fromisoformat(s[:10])
except Exception:
return None
# ── DomClickScraper ──────────────────────────────────────────────────────────
class DomClickScraper(BaseScraper):
"""DomClick вторичка через bff-search-web JSON API. Источник = 'domklik'.
Layer A: SERP list (offers/v1) → ScrapedLot. Layer B/C не реализованы.
Транспорт: одна BrowserFetcher(source="domclick") сессия в __aenter__.
fetch_around() → NotImplementedError (DomClick не поддерживает geo-radius).
"""
name = "domklik"
source = "domklik"
base_url = "https://bff-search-web.domclick.ru"
request_delay_sec = 8.0 # overwritten from get_scraper_delay in __init__
EKB_REGION_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
EKB_AIDS = 20561
OFFSET_CAP = 2000
LIMIT = 20
def __init__(self) -> None:
super().__init__()
self.request_delay_sec = get_scraper_delay(self.name)
self.parse_failures: int = 0
self._browser: Any | None = None
async def __aenter__(self) -> DomClickScraper:
from app.services.scrapers.browser_fetcher import BrowserFetcher
await super().__aenter__()
self._browser = BrowserFetcher(source="domclick")
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)
# ── 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: rooms × recursive price-bands → дедуплицированный список.
Args:
city_id: числовой ID города (4 = ЕКБ). Только ЕКБ поддерживается;
для других значений логируем warning, продолжаем с EKB GUID.
rooms: список комнатностей (0=студия, 1, 2, 3, 4, 5=5+).
None → [0,1,2,3,4,5] (все).
pages: максимум страниц на leaf-bucket (default 100 = OFFSET_CAP/LIMIT).
Returns:
Дедуплицированный по source_id список ScrapedLot.
"""
if city_id != 4:
logger.warning(
"domklik: city_id=%d не поддерживается — используем EKB GUID/aids",
city_id,
)
_rooms = rooms if rooms is not None else list(_ROOMS_TOKEN.keys())
seen: dict[str, ScrapedLot] = {}
total_geo_dropped: int = 0
for room_int in _rooms:
room_token = _ROOMS_TOKEN.get(room_int, str(room_int))
logger.info(
"domklik: room=%s (%d) sweep start (max %d pages per leaf)",
room_token,
room_int,
pages,
)
geo_dropped_ref = [0]
await self._walk_price_range(
room_token=room_token,
lo=0,
hi=None,
seen=seen,
pages=pages,
geo_dropped_ref=geo_dropped_ref,
)
total_geo_dropped += geo_dropped_ref[0]
logger.info(
"domklik: room=%s done — unique_total=%d geo_dropped_this_room=%d",
room_token,
len(seen),
geo_dropped_ref[0],
)
if total_geo_dropped:
logger.info("domklik: fetch_city geo_dropped_total=%d", total_geo_dropped)
logger.info(
"domklik: fetch_city done city_id=%d total=%d parse_failures=%d",
city_id,
len(seen),
self.parse_failures,
)
return list(seen.values())
# ── Рекурсивное адаптивное деление ───────────────────────────────────────
async def _walk_price_range(
self,
*,
room_token: str,
lo: int,
hi: int | None,
seen: dict[str, ScrapedLot],
pages: int,
geo_dropped_ref: list[int],
_depth: int = 0,
) -> None:
"""Рекурсивная бинарная partition ценового диапазона [lo, hi].
Open band (hi is None):
snippetsCount ≤ OFFSET_CAP → пагинируем leaf.
snippetsCount > OFFSET_CAP → разбиваем через HIGH_ANCHOR:
recurse [lo, HIGH_ANCHOR] + [HIGH_ANCHOR+1, None].
Closed band (hi is int):
snippetsCount ≤ OFFSET_CAP → пагинируем leaf.
snippetsCount > OFFSET_CAP и (depth > MAX_DEPTH или band < MIN_BAND):
пагинируем как есть (tail-loss WARNING).
Иначе → mid=(lo+hi)//2, recurse [lo, mid] и [mid+1, hi].
Raises DomClickBlockedError при QRATOR-блоке (propagate к вызывающему).
"""
hi_repr = "open" if hi is None else str(hi)
# ── 1. COUNT ─────────────────────────────────────────────────────────
count_url = self._build_count_url(room_token, lo, hi)
count_data = await self._fetch_json(count_url) # raises DomClickBlockedError on block
snippets: int = (count_data.get("result") or {}).get("snippetsCount", 0)
logger.info(
"domklik: %s [%d, %s] snippetsCount=%d depth=%d",
room_token,
lo,
hi_repr,
snippets,
_depth,
)
if snippets == 0:
return
await self.sleep_between_requests()
# ── 2a. Open band — нельзя бисектировать напрямую ────────────────────
if hi is None:
if snippets <= self.OFFSET_CAP:
await self._paginate_leaf(
room_token, lo, None, snippets, seen, pages, geo_dropped_ref
)
else:
# Делим через HIGH_ANCHOR; хвост [HIGH_ANCHOR+1, None] обычно мал
await self._walk_price_range(
room_token=room_token,
lo=lo,
hi=_HIGH_ANCHOR,
seen=seen,
pages=pages,
geo_dropped_ref=geo_dropped_ref,
_depth=_depth + 1,
)
await self._walk_price_range(
room_token=room_token,
lo=_HIGH_ANCHOR + 1,
hi=None,
seen=seen,
pages=pages,
geo_dropped_ref=geo_dropped_ref,
_depth=_depth + 1,
)
return
# ── 2b. Closed band ───────────────────────────────────────────────────
if snippets <= self.OFFSET_CAP:
await self._paginate_leaf(room_token, lo, hi, snippets, seen, pages, geo_dropped_ref)
return
# Нужно деление — проверяем ограничения
band = hi - lo
if _depth >= _MAX_DEPTH or band < _MIN_BAND:
tail_loss = max(0, snippets - self.OFFSET_CAP)
logger.warning(
"domklik: %s [%d, %d] snippets=%d > cap=%d band=%d depth=%d "
"— paginating as-is (tail-loss ~%d)",
room_token,
lo,
hi,
snippets,
self.OFFSET_CAP,
band,
_depth,
tail_loss,
)
await self._paginate_leaf(room_token, lo, hi, snippets, seen, pages, geo_dropped_ref)
return
mid = (lo + hi) // 2
await self._walk_price_range(
room_token=room_token,
lo=lo,
hi=mid,
seen=seen,
pages=pages,
geo_dropped_ref=geo_dropped_ref,
_depth=_depth + 1,
)
await self._walk_price_range(
room_token=room_token,
lo=mid + 1,
hi=hi,
seen=seen,
pages=pages,
geo_dropped_ref=geo_dropped_ref,
_depth=_depth + 1,
)
# ── Пагинация leaf-bucket ─────────────────────────────────────────────────
async def _paginate_leaf(
self,
room_token: str,
lo: int,
hi: int | None,
snippets: int,
seen: dict[str, ScrapedLot],
pages: int,
geo_dropped_ref: list[int],
) -> None:
"""Последовательная пагинация одного leaf-bucket [lo, hi] по offset."""
max_offset = min(snippets, self.OFFSET_CAP, pages * self.LIMIT)
offset = 0
while offset < max_offset:
list_url = self._build_list_url(room_token, lo, hi, offset)
list_data = await self._fetch_json(list_url) # DomClickBlockedError propagates
items: list[dict[str, Any]] = (list_data.get("result") or {}).get("items") or []
if not items:
logger.debug(
"domklik: empty items at %s [%s, %s] offset=%d — stopping",
room_token,
lo,
hi or "open",
offset,
)
break
page_lots = self._parse_page(items, room_token, geo_dropped_ref)
for lot in page_lots:
key = lot.source_id or lot.source_url
if key:
seen[key] = lot
logger.debug(
"domklik: %s [%s, %s] offset=%d items=%d parsed=%d total_unique=%d",
room_token,
lo,
hi or "open",
offset,
len(items),
len(page_lots),
len(seen),
)
offset += self.LIMIT
if offset < max_offset:
await self.sleep_between_requests()
# ── Парсинг страницы офферов ──────────────────────────────────────────────
def _parse_page(
self,
items: list[dict[str, Any]],
room_token: str,
geo_dropped_ref: list[int],
) -> list[ScrapedLot]:
"""Парсинг списка офферов → ScrapedLot с гео-guard.
Эмитирует Sentry-сообщение если raw_count > 0 но 0 прошло парсинг
(guard от silent schema regression).
"""
result: list[ScrapedLot] = []
local_geo_dropped = 0
for offer in items:
if not _is_ekb(offer):
local_geo_dropped += 1
geo_dropped_ref[0] += 1
continue
lot = self._offer_to_lot(offer, room_token)
if lot is not None:
result.append(lot)
raw_count = len(items)
ekb_count = raw_count - local_geo_dropped
saved = len(result)
# Schema regression guard
if raw_count > 0 and saved == 0 and ekb_count > 0:
logger.error(
"domklik: 0/%d offers прошли _offer_to_lot (ekb=%d) "
"— возможна schema regression",
raw_count,
ekb_count,
)
try:
if settings.glitchtip_dsn:
sentry_sdk.capture_message(
f"domklik SERP: {raw_count} offers, 0 parsed (ekb={ekb_count})"
" — possible schema regression",
level="error",
)
except Exception:
pass # sentry_sdk не инициализирован в dev
return result
def _offer_to_lot(self, offer: dict[str, Any], room_token: str) -> ScrapedLot | None:
"""Парсинг одного DomClick bff-JSON оффера → ScrapedLot.
Маппинг полей per спецификации Layer A.
source='domklik', listing_segment='vtorichka' всегда.
"""
try:
# ── Identity ─────────────────────────────────────────────────────
offer_id = offer.get("id")
if not offer_id:
return None
source_id = str(offer_id)
path = offer.get("path") or ""
source_url = urljoin("https://domclick.ru", path)
# ── Цена ─────────────────────────────────────────────────────────
price_raw = offer.get("price")
if price_raw is None:
return None
try:
price_rub = int(price_raw)
except (TypeError, ValueError):
return None
if price_rub <= 0:
return None
# ── squarePrice (price_per_m2) ────────────────────────────────────
sq_price_raw = offer.get("squarePrice")
price_per_m2: int | None = None
if sq_price_raw is not None:
try:
price_per_m2 = int(sq_price_raw)
except (TypeError, ValueError):
pass
# ── Координаты ───────────────────────────────────────────────────
location: dict[str, Any] = offer.get("location") or {}
lat: float | None = None
lon: float | None = None
try:
if location.get("lat") is not None:
lat = float(location["lat"])
if location.get("lon") is not None:
lon = float(location["lon"])
except (TypeError, ValueError):
lat = lon = None
# ── Адрес ────────────────────────────────────────────────────────
address_obj: dict[str, Any] = offer.get("address") or {}
address: str | None = address_obj.get("displayName") or None
# ── Параметры квартиры ────────────────────────────────────────────
obj_info: dict[str, Any] = offer.get("objectInfo") or {}
area_raw = obj_info.get("area")
area_m2: float | None = None
if area_raw is not None:
try:
area_m2 = float(area_raw)
except (TypeError, ValueError):
pass
# Комнатность: студии → 0; числовые → из objectInfo.rooms или токена
if room_token == "st":
rooms: int | None = 0
else:
rooms_raw = obj_info.get("rooms")
if rooms_raw is not None:
try:
rooms = int(rooms_raw)
except (TypeError, ValueError):
rooms = None
else:
# fallback: числовой токен
try:
rooms = int(room_token)
except ValueError:
rooms = None
floor_raw = obj_info.get("floor")
floor: int | None = None
if floor_raw is not None:
try:
floor = int(floor_raw)
except (TypeError, ValueError):
pass
# ── Здание ───────────────────────────────────────────────────────
house: dict[str, Any] = offer.get("house") or {}
total_floors_raw = house.get("floors")
total_floors: int | None = None
if total_floors_raw is not None:
try:
total_floors = int(total_floors_raw)
except (TypeError, ValueError):
pass
year_built_raw = house.get("buildYear")
year_built: int | None = None
if year_built_raw is not None:
try:
year_built = int(year_built_raw)
except (TypeError, ValueError):
pass
# ── Дата публикации ───────────────────────────────────────────────
published_date = _parse_publish_date(offer.get("publishedDate"))
# ── Raw payload (компактный) ───────────────────────────────────────
flat_complex: dict[str, Any] = offer.get("flatComplex") or {}
raw_payload: dict[str, Any] = {
"is_apartments": obj_info.get("isApartment"),
"is_rosreestr_approved": offer.get("isRosreestrApproved"),
"offer_region_name": offer.get("offerRegionName"),
"updated_date": offer.get("updatedDate"),
"last_price_history_state": offer.get("lastPriceHistoryState"),
"flat_complex": (
{
"id": flat_complex.get("id"),
"name": flat_complex.get("name"),
"slug": flat_complex.get("slug"),
}
if flat_complex
else None
),
}
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=price_rub,
price_per_m2=price_per_m2,
listing_segment="vtorichka",
listing_date=published_date,
publish_date=published_date,
raw_payload=raw_payload,
)
except Exception:
self.parse_failures += 1
logger.warning(
"domklik _offer_to_lot: parse failed offer_id=%r (failures=%d)",
offer.get("id"),
self.parse_failures,
exc_info=True,
)
return None
# ── URL builders ──────────────────────────────────────────────────────────
def _base_params(self, room_token: str, lo: int, hi: int | None) -> list[tuple[str, Any]]:
"""Базовые query params (без offset/limit). Используется count и list URL."""
params: list[tuple[str, Any]] = [
("address", self.EKB_REGION_GUID),
("aids", self.EKB_AIDS),
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
("rooms", room_token),
("sort", "qi"),
]
if lo > 0:
params.append(("sale_price__gte", lo))
if hi is not None:
params.append(("sale_price__lte", hi))
return params
def _build_count_url(self, room_token: str, lo: int, hi: int | None) -> str:
"""URL для count/v1 — без offset/limit."""
params = self._base_params(room_token, lo, hi)
return f"{self.base_url}/api/offers/count/v1?{urlencode(params)}"
def _build_list_url(self, room_token: str, lo: int, hi: int | None, offset: int) -> str:
"""URL для offers/v1 — с offset/limit."""
params = self._base_params(room_token, lo, hi)
params.append(("offset", offset))
params.append(("limit", self.LIMIT))
return f"{self.base_url}/api/offers/v1?{urlencode(params)}"
# ── JSON fetch helper ─────────────────────────────────────────────────────
async def _fetch_json(self, url: str) -> dict[str, Any]:
"""Fetch URL через BrowserFetcher → parse JSON.
Raises:
DomClickBlockedError: ответ не JSON (QRATOR challenge HTML),
или BrowserFetcher вернул HTML-страницу.
"""
assert self._browser is not None, "Используй DomClickScraper как async context manager"
raw: str = await self._browser.fetch(url)
# QRATOR block / captcha возвращает HTML — детектируем по первому символу
stripped = raw.lstrip()
if stripped.startswith("<"):
logger.warning(
"domklik: HTML challenge url=%s (первые 300 символов): %r",
url,
raw[:300],
)
raise DomClickBlockedError("DomClick вернул HTML вместо JSON (QRATOR block)")
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
logger.warning("domklik: JSONDecodeError url=%s: %s", url, exc)
raise DomClickBlockedError(f"DomClick: не JSON ответ: {exc}") from exc
# ── 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=[1, 2, 3]))
"""
async with DomClickScraper() as scraper:
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)