gendesign/tradein-mvp/backend/tests/scrapers/test_domclick.py
bot-backend dbef242716 feat(tradein/scrapers): domclick вторичка через bff-search-web JSON API (Layer A)
- DomClickScraper переписан на GET bff-search-web.domclick.ru/api/offers/v1
  с рекурсивным бинарным price-band bucketing (OFFSET_CAP=2000, open-band split
  через _HIGH_ANCHOR=30M); geo-guard по offerRegionName==ЕКБ / bbox
- scrape_pipeline.py: rooms=[0..5] (добавлен 5+), pages default=100,
  _DOMCLICK_PER_FETCH_S=4.0, честный статус mark_failed при 0 лотов + errors
- config.py: bff-search-web.domclick.ru добавлен в scrape_allowed_hosts
- data/sql/132_update_domclick_sweep_params.sql: UPDATE default_params (DORMANT)
2026-06-27 13:51:33 +03:00

432 lines
17 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 JSON API scraper (Layer A rewrite).
Offline, без сети и БД. Монкейпатчим BrowserFetcher / _fetch_json на canned JSON.
Coverage:
- _offer_to_lot: поля ScrapedLot (source_id, source_url, lat/lon, address,
rooms, area, floor, total_floors, year_built, price, listing_segment, source)
- Studio bucket (room_token="st"): rooms=0
- Гео-guard: Челябинск оффер отбрасывается, EKB оффер проходит
- Block detection: не-JSON body → DomClickBlockedError
- URL building: rooms=5%2B в URL; sale_price__gte/lte в banded URL;
offset/limit в list URL и отсутствует в count URL
- Bucketing: snippetsCount>2000 → рекурсивный split; offset не достигает 2000
"""
from __future__ import annotations
import json
import os
from datetime import date
from urllib.parse import parse_qs, urlparse
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.scrapers.domclick import DomClickScraper, _is_ekb, _parse_publish_date
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
# ── Фикстуры ─────────────────────────────────────────────────────────────────
def _make_scraper() -> DomClickScraper:
"""Создаём DomClickScraper без DB (монкейпатчим get_scraper_delay)."""
from unittest.mock import patch
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=0.0):
scraper = DomClickScraper()
scraper._browser = object() # не None — assertion в _fetch_json проверяет
return scraper
_SAMPLE_OFFER: dict = {
"id": 2075671636,
"path": "/card/sale__flat__2075671636",
"location": {"lat": 56.83, "lon": 60.61},
"address": {"displayName": "Екатеринбург, улица Ленина, 10"},
"objectInfo": {
"area": 52.3,
"rooms": 2,
"floor": 5,
"isApartment": False,
},
"house": {"floors": 16, "buildYear": 2005},
"price": 6500000,
"squarePrice": 124283,
"flatComplex": {"id": 123, "name": "ЖК Тест", "slug": "zhk-test"},
"isRosreestrApproved": True,
"publishedDate": "2026-06-01",
"updatedDate": "2026-06-15",
"lastPriceHistoryState": None,
"offerRegionName": "Екатеринбург",
}
_STUDIO_OFFER: dict = {
"id": 9001,
"path": "/card/sale__flat__9001",
"location": {"lat": 56.84, "lon": 60.60},
"address": {"displayName": "Екатеринбург, пр-т Ленина, 50"},
"objectInfo": {"area": 24.5, "rooms": None, "floor": 3, "isApartment": False},
"house": {"floors": 10, "buildYear": 2020},
"price": 3100000,
"squarePrice": 126531,
"flatComplex": None,
"isRosreestrApproved": False,
"publishedDate": None,
"updatedDate": None,
"lastPriceHistoryState": None,
"offerRegionName": "Екатеринбург",
}
_CHELYABINSK_OFFER: dict = {
"id": 99999,
"path": "/card/sale__flat__99999",
"location": {"lat": 55.15, "lon": 61.39}, # вне EKB bbox
"address": {"displayName": "Челябинск, улица Ленина, 1"},
"objectInfo": {"area": 40.0, "rooms": 1, "floor": 3, "isApartment": False},
"house": {"floors": 9, "buildYear": 1990},
"price": 3000000,
"squarePrice": 75000,
"flatComplex": None,
"isRosreestrApproved": False,
"publishedDate": None,
"updatedDate": None,
"lastPriceHistoryState": None,
"offerRegionName": "Челябинск", # не Екатеринбург
}
# ── _offer_to_lot — базовый маппинг ──────────────────────────────────────────
def test_offer_to_lot_basic_fields() -> None:
"""Репрезентативный оффер → ScrapedLot с корректными полями."""
scraper = _make_scraper()
lot = scraper._offer_to_lot(_SAMPLE_OFFER, room_token="2")
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.83)
assert lot.lon == pytest.approx(60.61)
assert lot.address == "Екатеринбург, улица Ленина, 10"
assert lot.rooms == 2
assert lot.area_m2 == pytest.approx(52.3)
assert lot.floor == 5
assert lot.total_floors == 16
assert lot.year_built == 2005
assert lot.price_rub == 6_500_000
assert lot.price_per_m2 == 124_283
assert lot.listing_segment == "vtorichka"
assert lot.listing_date == date(2026, 6, 1)
assert lot.publish_date == date(2026, 6, 1)
assert lot.raw_payload is not None
assert lot.raw_payload["is_rosreestr_approved"] is True
assert lot.raw_payload["flat_complex"]["name"] == "ЖК Тест"
def test_offer_to_lot_studio_rooms_zero() -> None:
"""Studio bucket (room_token='st') → rooms=0 независимо от objectInfo.rooms."""
scraper = _make_scraper()
lot = scraper._offer_to_lot(_STUDIO_OFFER, room_token="st")
assert lot is not None
assert lot.rooms == 0
assert lot.area_m2 == pytest.approx(24.5)
assert lot.price_rub == 3_100_000
# publishedDate=None → None
assert lot.listing_date is None
def test_offer_to_lot_missing_price_returns_none() -> None:
"""Оффер без цены → None."""
scraper = _make_scraper()
offer = {**_SAMPLE_OFFER, "price": None}
assert scraper._offer_to_lot(offer, "2") is None
def test_offer_to_lot_zero_price_returns_none() -> None:
"""Оффер с price=0 → None."""
scraper = _make_scraper()
offer = {**_SAMPLE_OFFER, "price": 0}
assert scraper._offer_to_lot(offer, "2") is None
def test_offer_to_lot_missing_id_returns_none() -> None:
"""Оффер без id → None."""
scraper = _make_scraper()
offer = {k: v for k, v in _SAMPLE_OFFER.items() if k != "id"}
assert scraper._offer_to_lot(offer, "2") is None
# ── Гео-guard ─────────────────────────────────────────────────────────────────
def test_geo_guard_ekb_by_region_name() -> None:
"""offerRegionName='Екатеринбург' → EKB."""
assert _is_ekb({"offerRegionName": "Екатеринбург"}) is True
def test_geo_guard_ekb_by_bbox_coords() -> None:
"""Координаты внутри EKB bbox → EKB (даже без offerRegionName)."""
assert _is_ekb({"location": {"lat": 56.83, "lon": 60.61}}) is True
def test_geo_guard_chelyabinsk_dropped() -> None:
"""Челябинск (имя и bbox) → не EKB."""
assert _is_ekb(_CHELYABINSK_OFFER) is False
def test_geo_guard_out_of_bbox_no_name() -> None:
"""Координаты вне EKB bbox и нет offerRegionName → не EKB."""
assert _is_ekb({"location": {"lat": 55.0, "lon": 73.0}}) is False
def test_parse_page_drops_non_ekb() -> None:
"""_parse_page: Челябинск оффер отброшен, EKB проходит."""
scraper = _make_scraper()
dropped_ref = [0]
items = [_SAMPLE_OFFER, _CHELYABINSK_OFFER]
lots = scraper._parse_page(items, "2", dropped_ref)
assert len(lots) == 1
assert lots[0].source_id == "2075671636"
assert dropped_ref[0] == 1 # один дроп
# ── Block detection ───────────────────────────────────────────────────────────
async def test_fetch_json_html_body_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None:
"""Если BrowserFetcher.fetch возвращает HTML → DomClickBlockedError."""
class _FakeBrowser:
async def fetch(self, url: str) -> str:
return "<html><head><title>QRATOR protection</title></head></html>"
scraper = _make_scraper()
scraper._browser = _FakeBrowser()
with pytest.raises(DomClickBlockedError):
await scraper._fetch_json("https://bff-search-web.domclick.ru/api/offers/count/v1?x=1")
async def test_fetch_json_non_json_body_raises_blocked(monkeypatch: pytest.MonkeyPatch) -> None:
"""Не-JSON body (не HTML, просто мусор) → DomClickBlockedError."""
class _FakeBrowser:
async def fetch(self, url: str) -> str:
return "not valid json at all |||"
scraper = _make_scraper()
scraper._browser = _FakeBrowser()
with pytest.raises(DomClickBlockedError):
await scraper._fetch_json("https://bff-search-web.domclick.ru/api/offers/count/v1?x=1")
async def test_fetch_json_valid_json_returns_dict(monkeypatch: pytest.MonkeyPatch) -> None:
"""Валидный JSON → dict без исключений."""
class _FakeBrowser:
async def fetch(self, url: str) -> str:
return json.dumps({"result": {"snippetsCount": 42}})
scraper = _make_scraper()
scraper._browser = _FakeBrowser()
data = await scraper._fetch_json("https://bff-search-web.domclick.ru/api/offers/count/v1?x=1")
assert data["result"]["snippetsCount"] == 42
# ── URL building ──────────────────────────────────────────────────────────────
def test_build_count_url_no_offset_limit() -> None:
"""count URL не содержит offset и limit."""
scraper = _make_scraper()
url = scraper._build_count_url("2", lo=0, hi=None)
qs = parse_qs(urlparse(url).query)
assert "offset" not in qs
assert "limit" not in qs
assert "/count/v1" in url
def test_build_list_url_has_offset_limit() -> None:
"""list URL содержит offset и limit."""
scraper = _make_scraper()
url = scraper._build_list_url("2", lo=0, hi=None, offset=40)
qs = parse_qs(urlparse(url).query)
assert qs["offset"] == ["40"]
assert qs["limit"] == ["20"]
assert "/offers/v1" in url
def test_build_list_url_5plus_encoded() -> None:
"""rooms=5+ корректно URL-кодируется (5%2B или эквивалент)."""
scraper = _make_scraper()
url = scraper._build_list_url("5+", lo=0, hi=None, offset=0)
# urlencode кодирует '+' как '%2B в query string
assert "rooms=5%2B" in url or "rooms=5+" in url # оба варианта корректны
# Проверяем через parse_qs
qs = parse_qs(urlparse(url).query)
assert qs.get("rooms") == ["5+"]
def test_build_list_url_price_band() -> None:
"""Бандовый URL содержит sale_price__gte и sale_price__lte."""
scraper = _make_scraper()
url = scraper._build_list_url("2", lo=2_000_000, hi=5_000_000, offset=0)
qs = parse_qs(urlparse(url).query)
assert qs["sale_price__gte"] == ["2000000"]
assert qs["sale_price__lte"] == ["5000000"]
def test_build_list_url_no_price_params_when_open() -> None:
"""Открытый бакет (lo=0, hi=None) → нет ценовых параметров."""
scraper = _make_scraper()
url = scraper._build_list_url("1", lo=0, hi=None, offset=0)
qs = parse_qs(urlparse(url).query)
assert "sale_price__gte" not in qs
assert "sale_price__lte" not in qs
def test_build_url_contains_ekb_params() -> None:
"""URL содержит EKB region GUID и aids."""
scraper = _make_scraper()
url = scraper._build_count_url("1", lo=0, hi=None)
assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url
qs = parse_qs(urlparse(url).query)
assert qs["aids"] == ["20561"]
assert qs["deal_type"] == ["sale"]
# ── Bucketing — split при snippetsCount > 2000 ────────────────────────────────
async def test_bucketing_split_on_overflow(monkeypatch: pytest.MonkeyPatch) -> None:
"""snippetsCount>2000 для открытого бакета → split; offset never ≥ 2000."""
call_log: list[str] = []
def _make_ekb_offer(offer_id: str) -> dict:
return {
"id": offer_id,
"path": f"/card/sale__flat__{offer_id}",
"location": {"lat": 56.83, "lon": 60.61},
"address": {"displayName": "Екатеринбург, ул. Тест, 1"},
"objectInfo": {"area": 45.0, "rooms": 1, "floor": 3, "isApartment": False},
"house": {"floors": 9, "buildYear": 2000},
"price": 4000000,
"squarePrice": 88888,
"flatComplex": None,
"isRosreestrApproved": False,
"publishedDate": None,
"updatedDate": None,
"lastPriceHistoryState": None,
"offerRegionName": "Екатеринбург",
}
async def mock_fetch_json(self: DomClickScraper, url: str) -> dict:
call_log.append(url)
parsed = urlparse(url)
qs = parse_qs(parsed.query)
if "/count/v1" in url:
has_gte = "sale_price__gte" in qs
has_lte = "sale_price__lte" in qs
if not has_gte and not has_lte:
# Открытый бакет [0, None] → overflow, trigger split
return {"result": {"snippetsCount": 2001, "offersCount": 2001}}
elif has_lte and int(qs["sale_price__lte"][0]) == 30_000_000:
# [0, 30M] → within cap
return {"result": {"snippetsCount": 20, "offersCount": 20}}
else:
# Хвост [30M+1, None] → empty
return {"result": {"snippetsCount": 0, "offersCount": 0}}
# list URL
if "/offers/v1" in url:
offset_val = int(qs.get("offset", ["0"])[0])
if offset_val == 0:
return {"result": {"items": [_make_ekb_offer("101")]}}
return {"result": {"items": []}}
return {"result": {}}
import asyncio as _asyncio
monkeypatch.setattr(DomClickScraper, "_fetch_json", mock_fetch_json)
# sleep_between_requests is inherited from BaseScraper → shadow on subclass
monkeypatch.setattr(DomClickScraper, "sleep_between_requests", lambda self: _asyncio.sleep(0))
from unittest.mock import patch
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=0.0):
scraper = DomClickScraper()
scraper._browser = object() # не None
seen: dict = {}
geo_ref = [0]
await scraper._walk_price_range(
room_token="1",
lo=0,
hi=None,
seen=seen,
pages=100,
geo_dropped_ref=geo_ref,
)
# Split произошёл: как минимум 2 count-вызова (open band + [0, 30M])
count_urls = [u for u in call_log if "/count/v1" in u]
assert len(count_urls) >= 2, f"Expected split, got count_urls={count_urls}"
# Ни один list-запрос не должен иметь offset >= 2000
list_urls = [u for u in call_log if "/offers/v1" in u]
for lu in list_urls:
offset_val = int(parse_qs(urlparse(lu).query).get("offset", ["0"])[0])
assert offset_val < 2000, f"offset={offset_val} >= OFFSET_CAP in url={lu}"
# Лот прошёл и дедуплицирован
assert "101" in seen
# ── _parse_publish_date ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
"val, expected",
[
("2026-06-01", date(2026, 6, 1)),
("2026-06-01T12:00:00Z", date(2026, 6, 1)),
(1748736000, date(2025, 6, 1)), # 2025-06-01 00:00 UTC epoch
("1748736000", date(2025, 6, 1)),
(None, None),
("invalid-date", None),
("", None),
],
)
def test_parse_publish_date(val: object, expected: date | None) -> None:
result = _parse_publish_date(val)
assert result == expected
# ── Scraper meta ─────────────────────────────────────────────────────────────
def test_scraper_attributes() -> None:
scraper = _make_scraper()
assert scraper.name == "domklik"
assert scraper.source == "domklik"
assert scraper.base_url == "https://bff-search-web.domclick.ru"
assert scraper.request_delay_sec >= 0.0
assert scraper.parse_failures == 0
def test_fetch_around_raises() -> None:
import asyncio
scraper = _make_scraper()
with pytest.raises(NotImplementedError, match="geo-radius"):
asyncio.run(scraper.fetch_around(56.8, 60.6))