"""DomClick.ru scraper — вторичка через headless BrowserFetcher (#796). Стратегия: HTML scrape через camoufox (AsyncCamoufox) — единственный рабочий путь: DataDome пропускает headless Playwright, curl_cffi -> 401. URL шаблон: https://domclick.ru/search?deal_type=sale&category=living&offer_type=flat &city_id={city_id}&rooms={r}&p={page} Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__`. Координаты SERP не отдаёт (lat = lon = None). Важно: selectolax `.text()` объединяет все дочерние текстовые узлы без разделителей. Числа из адреса (номер дома) могут слипнуться с ценой. Поэтому цену извлекаем из каждого дочернего элемента отдельно (а не из суммарного card_text), где элемент содержит символ рубля. """ from __future__ import annotations import logging import re from urllib.parse import urljoin from selectolax.parser import HTMLParser, Node 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__) # ── DataDome block detection ───────────────────────────────────────────────── _DATADOME_MARKERS = ("datadome", "blocked", "access denied", "bot detected") def _is_blocked_page(html: str) -> bool: head = html[:2048].lower() return any(m in head for m in _DATADOME_MARKERS) # ── Regex helpers ──────────────────────────────────────────────────────────── # _RE_ROOMS расширен относительно avito.py: DomClick пишет «2-комн. квартира» # (с «комн.») — avito.py-вариант ловит только «N-к. кв.» / «N-к квартира». _RE_ROOMS = re.compile( r"(\d)-(?:комн\.?\s*(?:квартира|кв\.?)?|к\.?\s*(?:квартира|кв\.?))", re.IGNORECASE, ) _RE_STUDIO = re.compile(r"\bстуди[яиюей]\b", re.IGNORECASE) _RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м[2²]", re.IGNORECASE) _RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт\.?", re.IGNORECASE) # Цена: парсим из одного элемента DOM (не из суммарного card_text) — # иначе число дома может слипнуться с ценой. Цена всегда в элементе с «₽». _RE_PRICE_EL = re.compile(r"([\d\s  ]+)\s*[₽р](?:уб\.?)?", re.IGNORECASE) # source_id из href вида /card/sale__flat__2075671636 _RE_SOURCE_ID = re.compile(r"sale__flat__(\d+)") # Адресные ключевые слова (эвристика) _RE_ADDR_KW = re.compile( r"ул\.|улица|пер\.|переулок|пр-т|проспект" r"|бул\.|бульвар|шоссе|наб\.|набережная" r"|пл\.|площадь|тракт|д\.\s*\d", re.IGNORECASE, ) # ── Вспомогательные функции ────────────────────────────────────────────────── def _extract_rooms(text: str) -> int | None: if _RE_STUDIO.search(text): return 0 m = _RE_ROOMS.search(text) return int(m.group(1)) if m else None def _extract_area(text: str) -> float | None: m = _RE_AREA.search(text) return float(m.group(1).replace(",", ".")) if m else None def _extract_floor(text: str) -> tuple[int | None, int | None]: m = _RE_FLOOR.search(text) if m: return int(m.group(1)), int(m.group(2)) return None, None def _extract_price_from_element(text: str) -> int | None: """Извлекаем цену из текста ОДНОГО элемента DOM. Парсим из одного span/div — не из суммарного card_text. Это исключает случай, когда адресный номер дома стоит вплотную перед ценой в объединённом тексте карточки («Ленина, 503 100 000 руб.»). """ m = _RE_PRICE_EL.search(text) if m: # Нормализуем пробелы любого вида (обычный, неразрывный, узкий) raw = re.sub(r"[\s  ]", "", m.group(1)) if raw.isdigit(): val = int(raw) if val > 0: return val return None def _extract_source_id(href: str) -> str | None: """Числовой ID из href типа /card/sale__flat__2075671636.""" m = _RE_SOURCE_ID.search(href) return m.group(1) if m else None # ── DomClickScraper ────────────────────────────────────────────────────────── class DomClickScraper(BaseScraper): """DomClick вторичка parser. Источник = 'domklik'. Использует BrowserFetcher (camoufox headless Firefox) — DataDome пропускает его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок). Основной метод: fetch_city(city_id, rooms, pages). fetch_around() не реализован: DomClick не поддерживает geo-radius в URL. """ name = "domklik" source = "domklik" base_url = "https://domclick.ru" # DataDome — консервативная задержка между страницами (fallback до init) request_delay_sec = 8.0 def __init__(self) -> None: super().__init__() self.request_delay_sec = get_scraper_delay(self.name) # Счётчик карточек с неожиданной DOM-структурой (для observability) self.parse_failures: 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 = 20, ) -> list[ScrapedLot]: """Citywide sweep: все страницы SERP для city_id. Args: city_id: числовой ID города в DomClick (например, 4 = Екатеринбург). rooms: список значений комнатности (0=студия, 1, 2, 3, 4, ...). None -> без фильтра комнатности (все квартиры сразу). pages: максимальное число страниц на один sweep. Break on empty. Returns: Дедуплицированный по source_id список ScrapedLot. """ from app.services.scrapers.browser_fetcher import BrowserFetcher all_lots: list[ScrapedLot] = [] seen_ids: set[str] = set() # Формируем список room-значений для sweep'ов room_values: list[int | None] = [None] if not rooms else list(rooms) async with BrowserFetcher() as fetcher: for room_val in room_values: room_label = f"rooms={room_val}" if room_val is not None else "all_rooms" logger.info( "domklik: city_id=%d %s sweep (max %d pages)", city_id, room_label, pages ) for page_num in range(1, pages + 1): url = self._build_url(city_id, room_val, page_num) logger.debug("domklik: fetch %s", url) try: html = await fetcher.fetch(url) except Exception as exc: logger.error( "domklik: fetch failed city_id=%d page=%d: %s", city_id, page_num, exc, ) break try: lots = self._parse_html(html) except DomClickBlockedError: logger.warning( "domklik: DataDome block at city_id=%d page=%d — stopping sweep", city_id, page_num, ) break if not lots: logger.info( "domklik: empty page city_id=%d %s page=%d — stopping sweep", city_id, room_label, page_num, ) break # Дедупликация по source_id (кросс-sweep и кросс-страница) new_lots: list[ScrapedLot] = [] for lot in lots: key = lot.source_id or lot.source_url if key not in seen_ids: seen_ids.add(key) new_lots.append(lot) all_lots.extend(new_lots) logger.info( "domklik: city_id=%d %s page=%d -> %d new (total %d)", city_id, room_label, page_num, len(new_lots), len(all_lots), ) # Пауза между страницами (anti-DataDome) if page_num < pages: await self.sleep_between_requests() logger.info( "domklik: fetch_city done city_id=%d total=%d parse_failures=%d", city_id, len(all_lots), self.parse_failures, ) return all_lots # ── URL builder ─────────────────────────────────────────────────────────── def _build_url(self, city_id: int, rooms: int | None, page: int) -> str: """Строит URL SERP DomClick.""" url = ( f"{self.base_url}/search" f"?deal_type=sale&category=living&offer_type=flat" f"&city_id={city_id}" ) if rooms is not None: url += f"&rooms={rooms}" url += f"&p={page}" return url # ── HTML parsing ────────────────────────────────────────────────────────── def _parse_html(self, html: str) -> list[ScrapedLot]: """Парсим карточки из HTML через selectolax. Селектор: `a[href*="/card/"]` с паттерном `/card/sale__flat__`. """ if _is_blocked_page(html): logger.warning("domklik: DataDome block page detected, 0 cards returned") raise DomClickBlockedError("DomClick returned DataDome block page") tree = HTMLParser(html) card_links = tree.css('a[href*="/card/"]') seen_hrefs: set[str] = set() lots: list[ScrapedLot] = [] for link in card_links: href = link.attributes.get("href", "") # Только карточки вторичного жилья if "sale__flat__" not in href: continue if href in seen_hrefs: continue seen_hrefs.add(href) lot = self._card_link_to_lot(link, href) if lot is not None: lots.append(lot) return lots def _card_link_to_lot(self, link: Node, href: str) -> ScrapedLot | None: """Парсинг одной карточки-ссылки -> ScrapedLot. DomClick SERP оборачивает карточку в `` — вся информация (title, цена, адрес) внутри этого элемента. Цену извлекаем из отдельных дочерних элементов (не из объединённого card_text) чтобы исключить слипание числа дома с ценой. """ try: source_id = _extract_source_id(href) source_url = urljoin(self.base_url, href) # Суммарный текст — для rooms/area/floor (эти поля не подвержены # проблеме слипания, т.к. используют специфичные маркеры: «м²», «эт.») card_text = link.text(strip=True) if not card_text: return None rooms = _extract_rooms(card_text) area = _extract_area(card_text) floor, total_floors = _extract_floor(card_text) # Цена: ищем в каждом дочернем элементе отдельно чтобы не слипались # числа дома с ценой при конкатенации card_text price = self._extract_price_from_children(link) if not price or price <= 0: return None address = self._extract_address(link) return ScrapedLot( source="domklik", source_url=source_url, source_id=source_id, address=address, lat=None, # DomClick SERP координаты не отдаёт lon=None, rooms=rooms, area_m2=area, floor=floor, total_floors=total_floors, price_rub=price, listing_segment="vtorichka", raw_payload={"card_text": card_text[:500]}, ) except Exception: self.parse_failures += 1 logger.warning( "domclick _card_link_to_lot: parse failed href=%r (parse_failures=%d)", href, self.parse_failures, exc_info=True, ) return None def _extract_price_from_children(self, link: Node) -> int | None: """Ищем цену в дочерних элементах карточки по символу рубля. Итерируем по всем потомкам: первый элемент с рублёвым символом в тексте — источник цены. """ for selector in ("span", "div", "p", "strong", "b"): for el in link.css(selector): text = el.text(strip=True) price = _extract_price_from_element(text) if price: return price # Fallback: суммарный текст карточки (если структура нестандартная) return _extract_price_from_element(link.text(strip=True)) def _extract_address(self, link: Node) -> str | None: """Эвристика извлечения адреса из DOM карточки. DomClick помещает адрес в отдельный текстовый блок внутри карточки. Обходим вложенные элементы и ищем первый текст с адресными ключевыми словами, пропуская строки с площадью/этажом/ценой. Возвращает None если адрес не найден. """ for selector in ("span", "p", "div"): for el in link.css(selector): text = el.text(strip=True) if not text or len(text) < 5 or len(text) > 200: continue # Пропускаем блоки с площадью / этажом / ценой if _RE_AREA.search(text) or _RE_FLOOR.search(text): continue if _RE_PRICE_EL.search(text): continue if _RE_ADDR_KW.search(text): return text return None # ── Convenience runner (для Celery tasks) ──────────────────────────────────── async def scrape_domclick_city( city_id: int, rooms: list[int] | None = None, pages: int = 20, ) -> 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)