gendesign/tradein-mvp/backend/tests/test_domclick_sweep.py
lekss361 7c916082e8
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 1m55s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
Deploy Trade-In / test (push) Successful in 3m46s
Deploy Trade-In / deploy-status (push) Successful in 1s
feat(tradein/domklik): ЖК в свою колонку вместо только raw_payload (#3076)
2026-08-24 16:04:24 +00:00

366 lines
16 KiB
Python
Raw Permalink 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.

"""Offline unit tests for DomClickCitySweepCounters + DomClick parser.
Legacy `run_domclick_city_sweep` orchestration behavior (sweep phases, cooperative
cancel, honest mark_done/mark_failed/mark_banned statuses) удалён вместе с
`app.services.scrape_pipeline` (#2397 Part E1) — эквивалентная regression-coverage
теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_*`
(kit `run_domclick_city_sweep`).
`app.services.scrapers.domclick` удалён (#2397 Part E2, 0 runtime-импортёров) —
`_map_item` ретаргетирован на kit `scraper_kit.providers.domclick.serp.DomClickScraper`
(единственный живой путь).
Остались:
- DomClickCitySweepCounters (dataclass, из scraper_kit) — defaults + to_dict
- DomClickScraper._map_item (kit) — BFF offer-item → ScrapedLot маппинг (не
зависит от оркестрации)
"""
from __future__ import annotations
import os
from types import SimpleNamespace
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from scraper_kit.providers.domclick.serp import DomClickScraper
# ── DomClickCitySweepCounters ───────────────────────────────────────────────
def test_domclick_sweep_counters_defaults() -> None:
from scraper_kit.orchestration.pipeline import DomClickCitySweepCounters
c = DomClickCitySweepCounters()
assert c.lots_fetched == 0
assert c.lots_inserted == 0
assert c.lots_updated == 0
assert c.pages_fetched == 0
assert c.errors_count == 0
assert c.blocked == 0
assert c.geo_filtered == 0
def test_domclick_sweep_counters_to_dict_all_keys() -> None:
from dataclasses import fields
from scraper_kit.orchestration.pipeline import DomClickCitySweepCounters
c = DomClickCitySweepCounters()
d = c.to_dict()
expected = {f.name for f in fields(c)}
assert set(d.keys()) == expected
# ── _map_item ─────────────────────────────────────────────────────────────────
def test_map_item_basic_mapping() -> None:
"""DomClickScraper._map_item маппит BFF offer-item → ScrapedLot корректно."""
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
scraper = DomClickScraper(config, delay_provider=lambda _name: 8.0)
item = {
"id": 111222333,
"path": "https://domclick.ru/card/sale__flat__111222333",
"location": {"lat": 56.838, "lon": 60.612},
"address": {"displayName": "Екатеринбург, улица Ленина, 10"},
"objectInfo": {"area": 52.3, "rooms": 2, "floor": 5},
"house": {"floors": 16, "buildYear": 2010},
"price": 6_500_000,
"squarePrice": 124282,
"flatComplex": None,
"isRosreestrApproved": False,
"publishedDate": "2026-06-01T10:00:00+05:00",
"updatedDate": None,
"lastPriceHistoryState": None,
"offerRegionName": "Екатеринбург",
}
lot = scraper._map_item(item)
assert lot is not None
assert lot.source == "domklik"
assert lot.source_id == "111222333"
assert lot.rooms == 2
assert lot.area_m2 == pytest.approx(52.3)
assert lot.floor == 5
assert lot.total_floors == 16
assert lot.price_rub == 6_500_000
assert lot.listing_segment == "vtorichka"
assert lot.lat == pytest.approx(56.838)
assert lot.lon == pytest.approx(60.612)
def test_map_item_puts_complex_slug_into_newbuilding_id() -> None:
"""flatComplex.slug доезжает до ScrapedLot.newbuilding_id (#3064).
Слаг, а не id: докстринг ScrapedLot задаёт формат «federatsiya-ekaterinburg»,
и avito — единственный источник, который это поле заполняет, кладёт туда слаг.
ЭТО НЕ привязка к дому. house_source/house_ext_id намеренно не трогаются:
все активные domklik-листинги — вторичка, а flatComplex — идентификатор ЖК,
а не здания (116 значений на 737 листингов по проду). Ключ здания
(domclick_building_guid) приходит detail-путём — отдельная развилка в #3064.
"""
import json
from pathlib import Path
fixture_path = Path(__file__).parent / "fixtures" / "domclick_bff_offers_sample.json"
items = json.loads(fixture_path.read_text(encoding="utf-8"))["result"]["items"]
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
scraper = DomClickScraper(config, delay_provider=lambda _name: 8.0)
# item 1001: flatComplex.slug='malysheva' -> слаг доезжает до колонки.
lot_with_complex = scraper._map_item(items[0])
assert lot_with_complex is not None
assert lot_with_complex.newbuilding_id == "malysheva"
# Привязка к дому НЕ появилась — это стережёт границу правки.
assert lot_with_complex.house_source is None
assert lot_with_complex.house_ext_id is None
# item 1002: flatComplex отсутствует -> None, а не пустая строка: пустая
# строка попала бы в колонку как значение и читалась бы как «ЖК есть, но без
# имени», что неотличимо от настоящего ЖК с пустым слагом.
lot_no_complex = scraper._map_item(items[1])
assert lot_no_complex is not None
assert lot_no_complex.newbuilding_id is None
def test_map_item_promotes_rosreestr_from_bff_fixture() -> None:
"""isRosreestrApproved из BFF-фикстуры доезжает до
ScrapedLot.is_rosreestr_checked (#3064 п.1).
На проде у всех 1338 активных domklik-листингов колонка была NULL, хотя поле
уже парсилось и оседало в raw_payload. Соответствие 1:1 с cian
(`offer.get("isRosreestrChecked")`, cian/serp.py:1014) — без трансформаций.
is_pro_seller здесь НЕ проверяется намеренно: колонка уже несёт два разных
смысла (cian — платная PRO-подписка, `019_listings_alter_cian.sql:43`;
yandex — категория продавца AGENCY/AGENT, `yandex/serp.py:203-220`), и
выводить её из наличия agency_name значило бы завести третий. См. #3064.
"""
import json
from pathlib import Path
fixture_path = Path(__file__).parent / "fixtures" / "domclick_bff_offers_sample.json"
items = json.loads(fixture_path.read_text(encoding="utf-8"))["result"]["items"]
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
scraper = DomClickScraper(config, delay_provider=lambda _name: 8.0)
# item 1001: isRosreestrApproved=true -> ЕГРН-сверка пройдена.
lot_checked = scraper._map_item(items[0])
assert lot_checked is not None
assert lot_checked.is_rosreestr_checked is True
# item 1003: ключа isRosreestrApproved в payload нет вовсе -> None, а НЕ
# False. Отсутствие данных не то же самое, что "проверено — не сходится":
# False поехал бы в оценщик как утверждение о квартире.
lot_unknown = scraper._map_item(items[2])
assert lot_unknown is not None
assert lot_unknown.is_rosreestr_checked is None
# ── fetch_city: report_ban на QRATOR-блок (#2600 п.1) ───────────────────────────
class _FakeFetcher:
"""Заглушка BrowserFetcher: async context manager + report_ban recorder."""
def __init__(self) -> None:
self.banned: list[str] = []
async def __aenter__(self) -> _FakeFetcher:
return self
async def __aexit__(self, *args: object) -> None:
return None
def report_ban(self, reason: str) -> None:
self.banned.append(reason)
async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.MonkeyPatch) -> None:
"""QRATOR-блок в fetch_city → fetcher.report_ban вызывается ВНУТРИ `async with
build_browser_fetcher(...) as fetcher:` (lease ещё держится) — #2600 п.1.
no-op сегодня (domclick SERP собирается БЕЗ proxy_provider — #2160 P4 wiring
gap), но сам вызов должен произойти корректно на нужном (живом) fetcher'е.
"""
from scraper_kit.domclick_exceptions import DomClickBlockedError
fake_fetcher = _FakeFetcher()
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
assert source == "domclick"
return fake_fetcher
monkeypatch.setattr(
"scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher
)
async def _raise_blocked(self: DomClickScraper, **_: object) -> None:
raise DomClickBlockedError("QRATOR block page")
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _raise_blocked)
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
scraper = DomClickScraper(config)
lots = await scraper.fetch_city(city_id=1)
assert lots == []
assert scraper.blocked is True
assert fake_fetcher.banned # report_ban был вызван на ЖИВОМ fetcher'е
assert "QRATOR" in fake_fetcher.banned[0]
# ── #2854: точка входа в ROOM_BUCKETS сдвигается от прогона к прогону ───────────
#
# Блок QRATOR прилетает ВНУТРИ первой корзины — на проде buckets_completed=0 во всех
# прогонах, включая тот, где собрано 389 лотов. Обход при этом всегда стартовал со
# студий, поэтому комнатности 2+ не собирались никогда. Замер 19.08 по активным
# объявлениям: у Домклика 575 студий, 408 однушек, 1 двушка, 0 трёшек; у Циана в тех
# же категориях 6 461 / 7 352 / 4 581.
class _OrderRecordingScraper:
"""Ничего не делает, только запоминает порядок корзин."""
async def _make_scraper_recording(monkeypatch: pytest.MonkeyPatch) -> tuple[DomClickScraper, list]:
seen: list[str] = []
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
return _FakeFetcher()
monkeypatch.setattr(
"scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher
)
async def _record(self: DomClickScraper, *, rooms: str, **_: object) -> None:
seen.append(rooms)
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _record)
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
return DomClickScraper(config), seen
async def test_fetch_city_rotates_bucket_order(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сдвиг меняет ПОРЯДОК корзин, а не их состав — ни одна не теряется."""
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
scraper, seen = await _make_scraper_recording(monkeypatch)
await scraper.fetch_city(city_id=1, start_bucket_index=2)
assert seen == list(ROOM_BUCKETS[2:]) + list(ROOM_BUCKETS[:2])
assert sorted(seen) == sorted(ROOM_BUCKETS), "состав корзин изменился — потеряли охват"
async def test_fetch_city_start_index_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None:
"""Вызывающий передаёт остаток от run_id — он ничем не ограничен сверху."""
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
scraper, seen = await _make_scraper_recording(monkeypatch)
await scraper.fetch_city(city_id=1, start_bucket_index=len(ROOM_BUCKETS) + 1)
assert seen[0] == ROOM_BUCKETS[1]
assert scraper.bucket_start_index == 1
async def test_blocked_run_collects_shifted_bucket_not_studios(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Главный сценарий: блок в ПЕРВОЙ же корзине — собранной оказывается сдвинутая.
Ровно так ведёт себя прод: одна корзина за прогон и обрыв. До #2854 этой
единственной корзиной всегда были студии, из-за чего двушки и трёшки не
появлялись в listings вовсе.
"""
from scraper_kit.domclick_exceptions import DomClickBlockedError
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
attempted: list[str] = []
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
return _FakeFetcher()
monkeypatch.setattr(
"scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher
)
async def _block(self: DomClickScraper, *, rooms: str, **_: object) -> None:
attempted.append(rooms)
raise DomClickBlockedError("QRATOR block page")
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _block)
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
scraper = DomClickScraper(config)
await scraper.fetch_city(city_id=1, start_bucket_index=ROOM_BUCKETS.index("2"))
assert attempted == ["2"], "обход всё ещё начинается не со сдвинутой корзины"
assert scraper.blocked is True
assert scraper.bucket_start_index == ROOM_BUCKETS.index("2")
async def test_default_start_index_keeps_previous_behaviour(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Контроль: без явного сдвига порядок прежний — правка не меняет вызовы без него."""
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
scraper, seen = await _make_scraper_recording(monkeypatch)
await scraper.fetch_city(city_id=1)
assert seen == list(ROOM_BUCKETS)
# ── проводка: pipeline обязан ПЕРЕДАВАТЬ сдвиг, а не просто уметь его принимать ──
async def test_pipeline_passes_rotating_start_index() -> None:
"""Без этого теста передачу сдвига можно снять, и проверки выше останутся зелёными:
скрейпер по-прежнему умеет сдвигать, просто его об этом никто не просит.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
pfx = "scraper_kit.orchestration.pipeline"
run_id = 17
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_city = AsyncMock(return_value=[])
scraper.blocked = False
scraper.geo_filtered = 0
scraper.fetch_errors = 0
scraper.buckets_completed = 0
scraper.buckets_total = len(ROOM_BUCKETS)
scraper.bucket_start_index = run_id % len(ROOM_BUCKETS)
runs_stub = MagicMock()
runs_stub.is_cancelled.return_value = False
with (
patch(f"{pfx}.DomClickScraper", return_value=scraper),
patch(f"{pfx}.save_listings", MagicMock(return_value=(0, 0))),
patch(f"{pfx}.runs", runs_stub),
):
await run_domclick_city_sweep(
MagicMock(),
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
run_id=run_id,
city_id=4,
pages=1,
request_delay_sec=0.0,
)
passed = scraper.fetch_city.await_args.kwargs.get("start_bucket_index")
assert passed == run_id % len(ROOM_BUCKETS), (
f"pipeline не передал сдвиг корзины (получено {passed!r}) — "
"обход снова всегда начнётся со студий"
)