Capture extra bff-search fields in _map_item (verified live): - description → ScrapedLot.description + infer_repair_state_from_text → repair_state (DomClick search has no renovation-enum; text inference like cian #622) - sha1 description_minhash (cross-source dedup) - agency_name from seller (defensive str|dict) - raw_payload: is_sber_collateral, has_discount, discount_value, duplicates_offer_count, seller Additive only; existing Layer A behavior unchanged. Deep card detail (renovation-enum, wallType, full price-history, owners_count) is card-only = QRATOR-blocked on prod proxy → separate effort (needs dedicated residential IP).
667 lines
28 KiB
Python
667 lines
28 KiB
Python
"""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 (<pre> или 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": ...}`` напрямую в <body>.
|
||
2. <pre>-wrapped: ``<html><body><pre>{"result": ...}</pre></body></html>``.
|
||
|
||
Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов.
|
||
|
||
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)
|