From 3c5e9ad368d799a853e918ed9e5c16771cdc22f9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 31 May 2026 16:58:12 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein):=20DomClick=20=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=87=D0=BA=D0=B0=20scraper=20via=20BrowserF?= =?UTF-8?q?etcher=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/core/config.py | 2 + .../backend/app/services/scrapers/domclick.py | 383 ++++++++++++++++++ .../backend/tests/scrapers/test_domclick.py | 240 +++++++++++ 3 files changed, 625 insertions(+) create mode 100644 tradein-mvp/backend/app/services/scrapers/domclick.py create mode 100644 tradein-mvp/backend/tests/scrapers/test_domclick.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1f939efb..7a398312 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -157,6 +157,8 @@ class Settings(BaseSettings): "realty.yandex.ru", "ekaterinburg.n1.ru", "n1.ru", + "domclick.ru", + "www.domclick.ru", } # ── Scraper mobile proxy (#806) ────────────────────────────────────────── diff --git a/tradein-mvp/backend/app/services/scrapers/domclick.py b/tradein-mvp/backend/app/services/scrapers/domclick.py new file mode 100644 index 00000000..f2156647 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/domclick.py @@ -0,0 +1,383 @@ +"""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.scrapers.base import BaseScraper, ScrapedLot + +logger = logging.getLogger(__name__) + +# ── 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. Источник = 'domclick'. + + Использует BrowserFetcher (camoufox headless Firefox) — DataDome пропускает + его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок). + + Основной метод: fetch_city(city_id, rooms, pages). + fetch_around() не реализован: DomClick не поддерживает geo-radius в URL. + """ + + name = "domclick" + source = "domclick" + base_url = "https://domclick.ru" + # DataDome — консервативная задержка между страницами + request_delay_sec = 8.0 + + def __init__(self) -> None: + super().__init__() + # Счётчик карточек с неожиданной 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( + "domclick: 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("domclick: fetch %s", url) + + try: + html = await fetcher.fetch(url) + except Exception as exc: + logger.error( + "domclick: fetch failed city_id=%d page=%d: %s", + city_id, + page_num, + exc, + ) + break + + lots = self._parse_html(html) + + if not lots: + logger.info( + "domclick: 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( + "domclick: 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( + "domclick: 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__`. + """ + 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="domclick", + 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) diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick.py b/tradein-mvp/backend/tests/scrapers/test_domclick.py new file mode 100644 index 00000000..878452c8 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_domclick.py @@ -0,0 +1,240 @@ +"""Unit-тесты для DomClick scraper (#796). + +Тестируем: +- Парсинг title-паттерна (rooms/area/floor) из текста карточки +- Парсинг цены из отдельного DOM-элемента +- Извлечение source_id из href +- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from app.services.scrapers.domclick import ( + DomClickScraper, + _extract_area, + _extract_floor, + _extract_price_from_element, + _extract_rooms, + _extract_source_id, +) + +# ── Rooms / studio ─────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text, expected", + [ + ("2-комн. квартира 53,5 м² 5/15 эт.", 2), + ("1-к. квартира 32,1 м² 3/9 эт.", 1), + ("3-комн. кв. 78 м² 10/12 эт.", 3), + ("4-комн. квартира 95 м² 2/5 эт.", 4), + ("студия 24 м² 1/10 эт.", 0), + ("Студия 30,5 м² 8/22 эт.", 0), + ("Нет комнат — просто текст", None), + ], +) +def test_extract_rooms(text: str, expected: int | None) -> None: + assert _extract_rooms(text) == expected + + +# ── Area ───────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text, expected", + [ + ("2-комн. квартира 53,5 м² 5/15 эт.", 53.5), + ("студия 24 м² 1/10 эт.", 24.0), + ("78м2 на этаже", 78.0), + ("площадь 101.7 м²", 101.7), + ("Нет площади", None), + ], +) +def test_extract_area(text: str, expected: float | None) -> None: + assert _extract_area(text) == expected + + +# ── Floor ───────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "text, floor, total", + [ + ("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15), + ("студия 24 м² 1/10 эт.", 1, 10), + ("12/24 эт.", 12, 24), + ("нет этажа", None, None), + ], +) +def test_extract_floor(text: str, floor: int | None, total: int | None) -> None: + assert _extract_floor(text) == (floor, total) + + +# ── Price (per-element) ─────────────────────────────────────────────────────── +# Цена парсится из отдельного DOM-элемента (не из суммарного card_text), +# поэтому числа адреса не загрязняют парсинг цены. + + +@pytest.mark.parametrize( + "text, expected", + [ + ("5 200 000 ₽", 5_200_000), + ("12 345 678 ₽", 12_345_678), + ("3 990 000 руб.", 3_990_000), + ("от 4 500 000 ₽", 4_500_000), + ("без цены", None), + ("0 ₽", None), + ], +) +def test_extract_price_from_element(text: str, expected: int | None) -> None: + assert _extract_price_from_element(text) == expected + + +# ── source_id from href ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "href, expected", + [ + ("/card/sale__flat__2075671636", "2075671636"), + ("https://domclick.ru/card/sale__flat__123456789", "123456789"), + ("/card/sale__flat__1", "1"), + ("/card/something_else", None), + ("", None), + ], +) +def test_extract_source_id(href: str, expected: str | None) -> None: + assert _extract_source_id(href) == expected + + +# ── _parse_html -> list[ScrapedLot] ────────────────────────────────────────── +# Цена в каждой карточке вынесена в отдельный с символом рубля, +# чтобы не сливаться с адресным номером дома при конкатенации card_text. + +_MOCK_HTML = """\ + + + + + + 2-комн. квартира 53,5 м² 5/15 эт. + ул. Малышева, 1 + 5 200 000 ₽ + + + + + Студия 28 м² 3/10 эт. + пр-т Ленина, 50 + 3 100 000 ₽ + + + + + 1-комн. квартира 32 м² 2/9 эт. + + + + + что-то другое + + + + + 2-комн. квартира 53,5 м² 5/15 эт. + 5 200 000 ₽ + + + +""" + + +def test_parse_html_returns_lots() -> None: + scraper = DomClickScraper() + lots = scraper._parse_html(_MOCK_HTML) + + # Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован + assert len(lots) == 2 + + +def test_parse_html_card1_fields() -> None: + scraper = DomClickScraper() + lots = scraper._parse_html(_MOCK_HTML) + + card1 = next(lot for lot in lots if lot.source_id == "2075671636") + assert card1.source == "domclick" + assert card1.source_url == "https://domclick.ru/card/sale__flat__2075671636" + assert card1.rooms == 2 + assert card1.area_m2 == 53.5 + assert card1.floor == 5 + assert card1.total_floors == 15 + assert card1.price_rub == 5_200_000 + assert card1.lat is None + assert card1.lon is None + assert card1.listing_segment == "vtorichka" + + +def test_parse_html_studio() -> None: + scraper = DomClickScraper() + lots = scraper._parse_html(_MOCK_HTML) + + studio = next(lot for lot in lots if lot.source_id == "1111111111") + assert studio.rooms == 0 # студия + assert studio.area_m2 == 28.0 + assert studio.price_rub == 3_100_000 + + +def test_parse_html_no_price_filtered() -> None: + scraper = DomClickScraper() + lots = scraper._parse_html(_MOCK_HTML) + + source_ids = {lot.source_id for lot in lots} + assert "9999999999" not in source_ids + + +def test_parse_html_dedup_same_href() -> None: + """Дубликат href на одной странице должен давать одну карточку.""" + scraper = DomClickScraper() + lots = scraper._parse_html(_MOCK_HTML) + + ids_2075 = [lot for lot in lots if lot.source_id == "2075671636"] + assert len(ids_2075) == 1 + + +def test_scraper_source_name() -> None: + scraper = DomClickScraper() + assert scraper.name == "domclick" + assert scraper.source == "domclick" + assert scraper.base_url == "https://domclick.ru" + assert scraper.request_delay_sec == 8.0 + + +def test_fetch_around_raises() -> None: + scraper = DomClickScraper() + with pytest.raises(NotImplementedError, match="geo-radius"): + asyncio.run(scraper.fetch_around(56.8, 60.6)) + + +def test_build_url_no_rooms() -> None: + scraper = DomClickScraper() + url = scraper._build_url(city_id=4, rooms=None, page=1) + assert "city_id=4" in url + assert "rooms" not in url + assert "p=1" in url + + +def test_build_url_with_rooms() -> None: + scraper = DomClickScraper() + url = scraper._build_url(city_id=4, rooms=2, page=3) + assert "rooms=2" in url + assert "p=3" in url + + +def test_parse_failures_counter() -> None: + """parse_failures счётчик начинается с 0.""" + scraper = DomClickScraper() + assert scraper.parse_failures == 0 -- 2.45.3 From 4c0338ce1d58f9954e5eb97c0dd9120d4a2ca0cb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 31 May 2026 17:21:30 +0300 Subject: [PATCH 2/2] fix(tradein): domklik source name, scraper delay, DataDome block detect (#796 fixup) --- .../backend/app/services/scraper_settings.py | 9 ++-- .../backend/app/services/scrapers/domclick.py | 47 ++++++++++++++----- .../services/scrapers/domclick_exceptions.py | 5 ++ .../backend/tests/scrapers/test_domclick.py | 20 ++++++-- 4 files changed, 60 insertions(+), 21 deletions(-) create mode 100644 tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py diff --git a/tradein-mvp/backend/app/services/scraper_settings.py b/tradein-mvp/backend/app/services/scraper_settings.py index cce374f6..42baf593 100644 --- a/tradein-mvp/backend/app/services/scraper_settings.py +++ b/tradein-mvp/backend/app/services/scraper_settings.py @@ -31,6 +31,7 @@ _CACHE_TTL_SEC = 60.0 _DEFAULT_DELAY_BY_SOURCE: dict[str, float] = { "avito": 7.0, "cian": 5.0, + "domklik": 8.0, "n1": 5.0, "yandex": 5.0, "yandex_detail": 5.0, @@ -82,9 +83,7 @@ def _get_setting_cached(key: str) -> float: try: with _open_session() as db: row = db.execute( - text( - "SELECT request_delay_sec FROM scraper_settings WHERE source = :s" - ), + text("SELECT request_delay_sec FROM scraper_settings WHERE source = :s"), {"s": key}, ).first() if row is not None: @@ -96,8 +95,8 @@ def _get_setting_cached(key: str) -> float: value = _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY) except Exception as e: logger.warning("scraper_settings: load failed for %s -- using default: %s", key, e) - value = 0.0 if key == _GLOBAL_KEY else _DEFAULT_DELAY_BY_SOURCE.get( - key, _GLOBAL_DEFAULT_DELAY + value = ( + 0.0 if key == _GLOBAL_KEY else _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY) ) with _CACHE_LOCK: diff --git a/tradein-mvp/backend/app/services/scrapers/domclick.py b/tradein-mvp/backend/app/services/scrapers/domclick.py index f2156647..a135e184 100644 --- a/tradein-mvp/backend/app/services/scrapers/domclick.py +++ b/tradein-mvp/backend/app/services/scrapers/domclick.py @@ -24,10 +24,21 @@ 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-к квартира». @@ -105,7 +116,7 @@ def _extract_source_id(href: str) -> str | None: class DomClickScraper(BaseScraper): - """DomClick вторичка parser. Источник = 'domclick'. + """DomClick вторичка parser. Источник = 'domklik'. Использует BrowserFetcher (camoufox headless Firefox) — DataDome пропускает его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок). @@ -114,14 +125,15 @@ class DomClickScraper(BaseScraper): fetch_around() не реализован: DomClick не поддерживает geo-radius в URL. """ - name = "domclick" - source = "domclick" + name = "domklik" + source = "domklik" base_url = "https://domclick.ru" - # DataDome — консервативная задержка между страницами + # 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 @@ -168,29 +180,37 @@ class DomClickScraper(BaseScraper): for room_val in room_values: room_label = f"rooms={room_val}" if room_val is not None else "all_rooms" logger.info( - "domclick: city_id=%d %s sweep (max %d pages)", city_id, room_label, pages + "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("domclick: fetch %s", url) + logger.debug("domklik: fetch %s", url) try: html = await fetcher.fetch(url) except Exception as exc: logger.error( - "domclick: fetch failed city_id=%d page=%d: %s", + "domklik: fetch failed city_id=%d page=%d: %s", city_id, page_num, exc, ) break - lots = self._parse_html(html) + 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( - "domclick: empty page city_id=%d %s page=%d — stopping sweep", + "domklik: empty page city_id=%d %s page=%d — stopping sweep", city_id, room_label, page_num, @@ -207,7 +227,7 @@ class DomClickScraper(BaseScraper): all_lots.extend(new_lots) logger.info( - "domclick: city_id=%d %s page=%d -> %d new (total %d)", + "domklik: city_id=%d %s page=%d -> %d new (total %d)", city_id, room_label, page_num, @@ -220,7 +240,7 @@ class DomClickScraper(BaseScraper): await self.sleep_between_requests() logger.info( - "domclick: fetch_city done city_id=%d total=%d parse_failures=%d", + "domklik: fetch_city done city_id=%d total=%d parse_failures=%d", city_id, len(all_lots), self.parse_failures, @@ -248,6 +268,9 @@ class DomClickScraper(BaseScraper): Селектор: `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/"]') @@ -301,7 +324,7 @@ class DomClickScraper(BaseScraper): address = self._extract_address(link) return ScrapedLot( - source="domclick", + source="domklik", source_url=source_url, source_id=source_id, address=address, diff --git a/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py b/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py new file mode 100644 index 00000000..118adb72 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py @@ -0,0 +1,5 @@ +"""DomClick-specific exceptions для anti-bot detection.""" + + +class DomClickBlockedError(Exception): + """DomClick вернул DataDome block-страницу (HTTP 200 + block HTML).""" diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick.py b/tradein-mvp/backend/tests/scrapers/test_domclick.py index 878452c8..04d0557d 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick.py @@ -21,6 +21,7 @@ from app.services.scrapers.domclick import ( _extract_rooms, _extract_source_id, ) +from app.services.scrapers.domclick_exceptions import DomClickBlockedError # ── Rooms / studio ─────────────────────────────────────────────────────────── @@ -166,7 +167,7 @@ def test_parse_html_card1_fields() -> None: lots = scraper._parse_html(_MOCK_HTML) card1 = next(lot for lot in lots if lot.source_id == "2075671636") - assert card1.source == "domclick" + assert card1.source == "domklik" assert card1.source_url == "https://domclick.ru/card/sale__flat__2075671636" assert card1.rooms == 2 assert card1.area_m2 == 53.5 @@ -207,10 +208,11 @@ def test_parse_html_dedup_same_href() -> None: def test_scraper_source_name() -> None: scraper = DomClickScraper() - assert scraper.name == "domclick" - assert scraper.source == "domclick" + assert scraper.name == "domklik" + assert scraper.source == "domklik" assert scraper.base_url == "https://domclick.ru" - assert scraper.request_delay_sec == 8.0 + # request_delay_sec установлен из get_scraper_delay (DB или fallback 8.0) + assert scraper.request_delay_sec >= 0.0 def test_fetch_around_raises() -> None: @@ -238,3 +240,13 @@ def test_parse_failures_counter() -> None: """parse_failures счётчик начинается с 0.""" scraper = DomClickScraper() assert scraper.parse_failures == 0 + + +def test_blocked_page_raises_domclick_blocked_error() -> None: + """HTML с datadome в начале вызывает DomClickBlockedError.""" + scraper = DomClickScraper() + blocked_html = ( + "datadome protectionblocked" + ) + with pytest.raises(DomClickBlockedError): + scraper._parse_html(blocked_html) -- 2.45.3