gendesign/tradein-mvp/backend/tests/scrapers/test_domclick.py
bot-backend 54a5d13c48
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein/domclick): enrich Layer A SERP parser — description, repair-inference, minhash, seller
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).
2026-06-27 16:35:25 +03:00

630 lines
24 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.

"""Unit-тесты для DomClick BFF scraper (#1968).
Тестируем:
- _extract_json: bare + <pre>-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 в <pre>-теге корректно парсится."""
html = (
"<html><head><title>DomClick</title></head>"
'<body><pre>{"result": {"items": [{"id": 1}]}}</pre></body></html>'
)
data = _extract_json(html)
assert data["result"]["items"][0]["id"] == 1
def test_extract_json_qrator_marker_raises() -> None:
"""HTML с QRATOR-маркером вызывает DomClickBlockedError."""
blocked_html = (
"<html><head><title>QRATOR protection</title></head>"
"<body>Access denied by bot_mitigation system</body></html>"
)
with pytest.raises(DomClickBlockedError):
_extract_json(blocked_html)
def test_extract_json_captcha_raises() -> None:
"""HTML с captcha-маркером вызывает DomClickBlockedError."""
html = "<html><body>Please solve the captcha to continue</body></html>"
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("<html><body>plain text</body></html>")
# ── _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 = "<html><body><pre>{not valid json at all</pre></body></html>"
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))