"""Unit-тесты для DomClick BFF scraper (#1968). Тестируем: - _extract_json: bare +
-wrapped → dict; QRATOR-маркер → DomClickBlockedError
- _build_offers_url / _build_count_url: корректные параметры, "5+" → "5%2B",
  offers имеет offset/limit, count — нет
- _map_item: маппинг realistic offer dict → ScrapedLot; price=0 → None; studio
- _is_geo_ok: гео-гард по bbox + offerRegionName
- _sweep_bucket: price-split при count > OFFSET_CAP
- DomClickScraper: source/name, fetch_around raises NotImplementedError
"""

from __future__ import annotations

import asyncio
from typing import Any

import pytest

from app.services.scrapers.domclick import (
    LTE_MAX,
    OFFSET_CAP,
    DomClickScraper,
    _build_count_url,
    _build_offers_url,
    _extract_json,
)
from app.services.scrapers.domclick_exceptions import DomClickBlockedError

# ── _extract_json ─────────────────────────────────────────────────────────────


def test_extract_json_bare_body() -> None:
    """Bare JSON body (без HTML-обёртки) корректно парсится."""
    html = '{"result": {"items": [], "snippetsCount": 42}}'
    data = _extract_json(html)
    assert data["result"]["snippetsCount"] == 42


def test_extract_json_pre_wrapped() -> None:
    """JSON в 
-теге корректно парсится."""
    html = (
        "DomClick"
        '
{"result": {"items": [{"id": 1}]}}
' ) data = _extract_json(html) assert data["result"]["items"][0]["id"] == 1 def test_extract_json_qrator_marker_raises() -> None: """HTML с QRATOR-маркером вызывает DomClickBlockedError.""" blocked_html = ( "QRATOR protection" "Access denied by bot_mitigation system" ) with pytest.raises(DomClickBlockedError): _extract_json(blocked_html) def test_extract_json_captcha_raises() -> None: """HTML с captcha-маркером вызывает DomClickBlockedError.""" html = "Please solve the captcha to continue" with pytest.raises(DomClickBlockedError): _extract_json(html) def test_extract_json_no_json_raises() -> None: """HTML без JSON-объекта вызывает ValueError.""" with pytest.raises(ValueError, match="No JSON"): _extract_json("plain text") # ── _build_offers_url ──────────────────────────────────────────────────────── def test_build_offers_url_basic_params() -> None: url = _build_offers_url("1", None, None, 0) assert "deal_type=sale" in url assert "category=living" in url assert "offer_type=flat" in url assert "rooms=1" in url assert "offset=0" in url assert "limit=20" in url assert "sale_price__gte" not in url assert "sale_price__lte" not in url def test_build_offers_url_5plus_encoded() -> None: """rooms='5+' должен кодироваться как '5%2B' в URL.""" url = _build_offers_url("5+", None, None, 0) assert "5%2B" in url # literal '+' в URL не должно быть для значения rooms assert "rooms=5+" not in url def test_build_offers_url_with_price_range() -> None: url = _build_offers_url("2", 3_000_000, 6_000_000, 40) assert "sale_price__gte=3000000" in url assert "sale_price__lte=6000000" in url assert "offset=40" in url def test_build_offers_url_ekb_guid_present() -> None: url = _build_offers_url("3", None, None, 0) assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url assert "aids=20561" in url def test_build_offers_url_bff_host() -> None: url = _build_offers_url("1", None, None, 0) assert url.startswith("https://bff-search-web.domclick.ru/api/offers/v1?") # ── _build_count_url ───────────────────────────────────────────────────────── def test_build_count_url_no_offset_limit() -> None: """count url не должен содержать offset и limit.""" url = _build_count_url("2", None, None) assert "offset" not in url assert "limit" not in url def test_build_count_url_5plus_encoded() -> None: url = _build_count_url("5+", None, None) assert "5%2B" in url assert "rooms=5+" not in url def test_build_count_url_with_price() -> None: url = _build_count_url("1", 1_000_000, 4_000_000) assert "sale_price__gte=1000000" in url assert "sale_price__lte=4000000" in url def test_build_count_url_bff_host() -> None: url = _build_count_url("1", None, None) assert url.startswith("https://bff-search-web.domclick.ru/api/offers/count/v1?") # ── _map_item → ScrapedLot ──────────────────────────────────────────────────── _REALISTIC_ITEM: dict[str, Any] = { "id": 2075671636, "path": "https://domclick.ru/card/sale__flat__2075671636", "location": {"lat": 56.838, "lon": 60.612}, "address": {"displayName": "Екатеринбург, улица Малышева, 1"}, "objectInfo": {"area": 53.5, "rooms": 2, "floor": 5, "isApartment": False}, "house": {"floors": 15, "buildYear": 2003}, "price": 5_200_000, "squarePrice": 97196, "flatComplex": {"id": 12345, "name": "ЖК Тест", "slug": "zhk-test"}, "isRosreestrApproved": True, "publishedDate": "2026-06-01T10:00:00+05:00", "updatedDate": "2026-06-15T12:00:00+05:00", "lastPriceHistoryState": {"direction": "DECREASED"}, "offerRegionName": "Екатеринбург", } def test_map_item_basic_fields() -> None: scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 lot = scraper._map_item(_REALISTIC_ITEM) assert lot is not None assert lot.source == "domklik" assert lot.source_id == "2075671636" assert lot.source_url == "https://domclick.ru/card/sale__flat__2075671636" assert lot.lat == pytest.approx(56.838) assert lot.lon == pytest.approx(60.612) assert lot.address == "Екатеринбург, улица Малышева, 1" assert lot.rooms == 2 assert lot.area_m2 == pytest.approx(53.5) assert lot.floor == 5 assert lot.total_floors == 15 assert lot.year_built == 2003 assert lot.price_rub == 5_200_000 assert lot.price_per_m2 == 97196 assert lot.listing_segment == "vtorichka" def test_map_item_publish_date() -> None: scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 from datetime import date lot = scraper._map_item(_REALISTIC_ITEM) assert lot is not None assert lot.publish_date == date(2026, 6, 1) def test_map_item_raw_payload() -> None: scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 lot = scraper._map_item(_REALISTIC_ITEM) assert lot is not None assert lot.raw_payload is not None assert lot.raw_payload["isRosreestrApproved"] is True assert lot.raw_payload["flatComplex"]["name"] == "ЖК Тест" assert lot.raw_payload["lastPriceHistoryState"]["direction"] == "DECREASED" def test_map_item_studio_force_rooms_zero() -> None: """force_rooms=0 (студия-бакет) перебивает objectInfo.rooms.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = {**_REALISTIC_ITEM, "objectInfo": {"area": 28.0, "rooms": 1, "floor": 3}} lot = scraper._map_item(item, force_rooms=0) assert lot is not None assert lot.rooms == 0 def test_map_item_zero_price_returns_none() -> None: scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = {**_REALISTIC_ITEM, "price": 0} assert scraper._map_item(item) is None def test_map_item_zero_sentinels_become_none() -> None: """buildYear/area/floor/total_floors == 0 (sentinel «неизвестно») → None. rooms=0 НЕ затрагивается (валидная студия), но здесь force_rooms не задан и objectInfo.rooms=2, так что rooms остаётся 2. """ scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = { **_REALISTIC_ITEM, "objectInfo": {"area": 0, "rooms": 2, "floor": 0}, "house": {"floors": 0, "buildYear": 0}, } lot = scraper._map_item(item) assert lot is not None assert lot.area_m2 is None assert lot.floor is None assert lot.total_floors is None assert lot.year_built is None assert lot.rooms == 2 # rooms 0-sentinel НЕ применяется def test_map_item_rooms_zero_studio_preserved() -> None: """force_rooms=0 даёт rooms=0 (студия) — 0 здесь валиден, не sentinel.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = {**_REALISTIC_ITEM, "objectInfo": {"area": 24.0, "rooms": 0, "floor": 2}} lot = scraper._map_item(item, force_rooms=0) assert lot is not None assert lot.rooms == 0 def test_map_item_price_per_m2_fallback() -> None: """Если squarePrice отсутствует, price_per_m2 вычисляется из price / area_m2.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = {**_REALISTIC_ITEM, "squarePrice": None} lot = scraper._map_item(item) assert lot is not None assert lot.price_per_m2 == int(5_200_000 / 53.5) # ── _map_item Layer A enrichment (description / repair / minhash / seller) ───── _VALID_REPAIR_ENUM = {"needs_repair", "standard", "good", "excellent"} def test_map_item_description_repair_minhash() -> None: """Offer с описанием → description set, repair_state выведен, minhash len 32.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = { **_REALISTIC_ITEM, "description": "Продаётся светлая квартира с евроремонтом, рядом метро.", } lot = scraper._map_item(item) assert lot is not None assert lot.description == "Продаётся светлая квартира с евроремонтом, рядом метро." # «евроремонт» → good (см. repair_state_normalizer._TEXT_PATTERNS) assert lot.repair_state == "good" assert lot.repair_state in _VALID_REPAIR_ENUM assert lot.description_minhash is not None assert len(lot.description_minhash) == 32 def test_map_item_description_stripped() -> None: """description триммится; пустой/whitespace → None.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = {**_REALISTIC_ITEM, "description": " Без отделки, требуется ремонт. "} lot = scraper._map_item(item) assert lot is not None assert lot.description == "Без отделки, требуется ремонт." # «без отделк» → needs_repair assert lot.repair_state == "needs_repair" def test_map_item_enrichment_raw_payload() -> None: """Новые SERP-поля попадают в raw_payload (is_sber_collateral и т.д.).""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = { **_REALISTIC_ITEM, "isSberCollateral": True, "hasDiscount": True, "discountValue": 2.3, "duplicatesOfferCount": 3, "seller": {"company": "Этажи", "agent": "Иван Петров"}, } lot = scraper._map_item(item) assert lot is not None assert lot.raw_payload is not None assert lot.raw_payload["is_sber_collateral"] is True assert lot.raw_payload["has_discount"] is True assert lot.raw_payload["discount_value"] == 2.3 assert lot.raw_payload["duplicates_offer_count"] == 3 assert lot.raw_payload["seller"] == {"company": "Этажи", "agent": "Иван Петров"} # agency_name извлекается из seller.company (приоритет company → agent) assert lot.agency_name == "Этажи" def test_map_item_agency_name_from_agent_dict() -> None: """seller.company отсутствует, agent — dict с name → agency_name = name.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 item = {**_REALISTIC_ITEM, "seller": {"agent": {"name": "Мария С."}}} lot = scraper._map_item(item) assert lot is not None assert lot.agency_name == "Мария С." assert lot.raw_payload is not None assert lot.raw_payload["seller"] == {"agent": {"name": "Мария С."}} def test_map_item_no_description_no_crash() -> None: """Offer без description: не падает, description/repair_state/minhash = None.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 # _REALISTIC_ITEM не содержит description/seller/enrichment-полей lot = scraper._map_item(_REALISTIC_ITEM) assert lot is not None assert lot.description is None assert lot.repair_state is None assert lot.description_minhash is None assert lot.agency_name is None def test_map_item_enrichment_preserves_existing_raw_payload_and_drops_none() -> None: """Существующие raw_payload-ключи не теряются; None-enrich-ключи отброшены.""" scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 lot = scraper._map_item(_REALISTIC_ITEM) assert lot is not None assert lot.raw_payload is not None # Существующие ключи на месте assert lot.raw_payload["isRosreestrApproved"] is True assert lot.raw_payload["flatComplex"]["name"] == "ЖК Тест" assert lot.raw_payload["lastPriceHistoryState"]["direction"] == "DECREASED" assert "updatedDate" in lot.raw_payload # None-значные enrich-ключи отсутствуют (компактный payload) assert "is_sber_collateral" not in lot.raw_payload assert "has_discount" not in lot.raw_payload assert "discount_value" not in lot.raw_payload assert "duplicates_offer_count" not in lot.raw_payload assert "seller" not in lot.raw_payload # ── _is_geo_ok — гео-гард ──────────────────────────────────────────────────── def test_is_geo_ok_valid_ekb_item() -> None: scraper = DomClickScraper.__new__(DomClickScraper) assert scraper._is_geo_ok(_REALISTIC_ITEM) is True def test_is_geo_ok_wrong_region_name() -> None: scraper = DomClickScraper.__new__(DomClickScraper) item = {**_REALISTIC_ITEM, "offerRegionName": "Москва"} assert scraper._is_geo_ok(item) is False def test_is_geo_ok_out_of_bbox_lat() -> None: scraper = DomClickScraper.__new__(DomClickScraper) item = {**_REALISTIC_ITEM, "location": {"lat": 55.5, "lon": 60.6}} assert scraper._is_geo_ok(item) is False def test_is_geo_ok_out_of_bbox_lon() -> None: scraper = DomClickScraper.__new__(DomClickScraper) item = {**_REALISTIC_ITEM, "location": {"lat": 56.8, "lon": 61.5}} assert scraper._is_geo_ok(item) is False def test_is_geo_ok_missing_coords() -> None: scraper = DomClickScraper.__new__(DomClickScraper) item = {**_REALISTIC_ITEM, "location": {"lat": None, "lon": None}} assert scraper._is_geo_ok(item) is False # ── _sweep_bucket price-split ──────────────────────────────────────────────── async def test_sweep_bucket_price_split_boundaries(monkeypatch: pytest.MonkeyPatch) -> None: """_sweep_bucket: top-level unbounded split даёт contiguous non-overlapping ranges. rooms="2" count>cap один раз, потом ≤cap → ровно 2 leaf-бакета, покрывающих [0, LTE_MAX] без перекрытия и без зазора: [(0, mid), (mid+1, LTE_MAX)]. """ call_count: dict[str, int] = {"n": 0} paginate_calls: list[tuple[str, int | None, int | None]] = [] async def fake_count( self: DomClickScraper, fetcher: object, rooms: str, price_gte: int | None, price_lte: int | None, ) -> int: call_count["n"] += 1 # Первый вызов (unbounded) → over cap → price-split; остальные → под cap return OFFSET_CAP + 1 if call_count["n"] == 1 else 1 async def fake_paginate( self: DomClickScraper, fetcher: object, rooms: str, price_gte: int | None, price_lte: int | None, seen_ids: set[str], out_lots: list, pages: int, ) -> None: paginate_calls.append((rooms, price_gte, price_lte)) monkeypatch.setattr(DomClickScraper, "_count", fake_count) monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate) scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 scraper.geo_filtered = 0 scraper.blocked = False scraper.fetch_errors = 0 scraper.request_delay_sec = 0.0 await scraper._sweep_bucket( fetcher=object(), rooms="2", price_gte=None, price_lte=None, seen_ids=set(), out_lots=[], pages=10, ) # 1 count call (over cap) + 2 recursive count calls (≤ cap) = 3 total assert call_count["n"] == 3 # Both sub-buckets paginated, с точными границами split'а unbounded-диапазона. mid = (0 + LTE_MAX) // 2 assert paginate_calls == [("2", 0, mid), ("2", mid + 1, LTE_MAX)] # Sanity: contiguous (нет зазора) + non-overlapping + покрывают весь хвост. assert paginate_calls[0][2] + 1 == paginate_calls[1][1] # mid + 1 assert paginate_calls[0][1] == 0 assert paginate_calls[1][2] == LTE_MAX async def test_sweep_bucket_zero_count_no_paginate(monkeypatch: pytest.MonkeyPatch) -> None: """_sweep_bucket при count==0 не вызывает _paginate.""" paginate_called = False async def fake_count(self: DomClickScraper, *_args: object, **_kwargs: object) -> int: return 0 async def fake_paginate(self: DomClickScraper, *_args: object, **_kwargs: object) -> None: nonlocal paginate_called paginate_called = True monkeypatch.setattr(DomClickScraper, "_count", fake_count) monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate) scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 scraper.geo_filtered = 0 scraper.blocked = False scraper.fetch_errors = 0 scraper.request_delay_sec = 0.0 await scraper._sweep_bucket( fetcher=object(), rooms="3", price_gte=None, price_lte=None, seen_ids=set(), out_lots=[], pages=5, ) assert paginate_called is False # ── _paginate resilience — mid-bucket parse error ──────────────────────────── class _FakeFetcher: """Стаб BrowserFetcher: возвращает заранее заданные ответы по порядку вызовов.""" def __init__(self, responses: list[str]) -> None: self._responses = responses self._idx = 0 async def fetch(self, _url: str) -> str: resp = self._responses[min(self._idx, len(self._responses) - 1)] self._idx += 1 return resp async def test_paginate_mid_bucket_parse_error_preserves_lots() -> None: """Garbled JSON на page 2 → bucket breaks, page-1 lots survive, fetch_errors++. Page 1 — валидный JSON с 20 items (PAGE_SIZE) → продолжаем; page 2 — мусор (ValueError из _extract_json) → fetch_errors++ и break, уже собранные lots целы. """ import json page1_items = [ { "id": 1000 + i, "path": f"https://domclick.ru/card/sale__flat__{1000 + i}", "location": {"lat": 56.84, "lon": 60.6}, "address": {"displayName": "Екатеринбург, тест"}, "objectInfo": {"area": 40.0, "rooms": 1, "floor": 3}, "house": {"floors": 9, "buildYear": 2005}, "price": 4_000_000 + i, "squarePrice": 100000, "offerRegionName": "Екатеринбург", } for i in range(20) ] page1 = json.dumps({"result": {"items": page1_items}}) garbled = "
{not valid json at all
" fetcher = _FakeFetcher([page1, garbled]) scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 scraper.geo_filtered = 0 scraper.blocked = False scraper.fetch_errors = 0 scraper.request_delay_sec = 0.0 out_lots: list = [] await scraper._paginate( fetcher=fetcher, rooms="1", price_gte=None, price_lte=None, seen_ids=set(), out_lots=out_lots, pages=10, ) assert len(out_lots) == 20 # page-1 lots сохранены assert scraper.fetch_errors == 1 # page-2 garbled посчитан async def test_fetch_city_bucket_error_continues_to_next_bucket( monkeypatch: pytest.MonkeyPatch, ) -> None: """Ошибка в одном бакете НЕ убивает весь sweep — остальные бакеты отрабатывают.""" swept: list[str] = [] async def fake_sweep_bucket( self: DomClickScraper, fetcher: object, rooms: str, price_gte: int | None, price_lte: int | None, seen_ids: set[str], out_lots: list, pages: int, ) -> None: swept.append(rooms) if rooms == "2": raise ValueError("garbled bucket") # Заглушка BrowserFetcher через async context manager class _BFStub: async def __aenter__(self) -> _BFStub: return self async def __aexit__(self, *_a: object) -> None: return None async def fetch(self, _url: str) -> str: return "{}" import app.services.scrapers.browser_fetcher as bf_mod monkeypatch.setattr(DomClickScraper, "_sweep_bucket", fake_sweep_bucket) monkeypatch.setattr(bf_mod, "BrowserFetcher", lambda **_k: _BFStub()) scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 scraper.geo_filtered = 0 scraper.blocked = False scraper.fetch_errors = 0 scraper.request_delay_sec = 0.0 lots = await scraper.fetch_city(city_id=4, rooms=None, pages=5) # Все 6 бакетов посещены несмотря на ошибку в "2" assert swept == ["st", "1", "2", "3", "4", "5+"] assert scraper.fetch_errors == 1 assert scraper.blocked is False assert lots == [] # ── DomClickScraper — class attrs ───────────────────────────────────────────── def test_scraper_source_name() -> None: scraper = DomClickScraper.__new__(DomClickScraper) scraper.parse_failures = 0 scraper.geo_filtered = 0 scraper.blocked = False scraper.request_delay_sec = 0.0 assert scraper.name == "domklik" assert scraper.source == "domklik" assert scraper.base_url == "https://domclick.ru" def test_fetch_around_raises() -> None: scraper = DomClickScraper.__new__(DomClickScraper) with pytest.raises(NotImplementedError, match="geo-radius"): asyncio.run(scraper.fetch_around(56.8, 60.6))