gendesign/tradein-mvp/backend/tests/test_domclick_sweep.py
bot-backend 9b9f299922
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
CI Trade-In / backend-tests (pull_request) Successful in 2m43s
fix(tradein/scraper): блок QRATOR у Домклика больше не помечает прогон успешным (#2657)
Honest-status в run_domclick_city_sweep требовал ОДНОВРЕМЕННО блок И ноль лотов,
поэтому распознанный QRATOR-блок после первых собранных лотов уходил в `done`.
На проде это 13 из 13 прогонов с blocked=1 (39-464 лота вместо ~6300) — ни один
распознанный блок ни разу не дал не-`done` статус.

Домклик структурно отличается от cian/yandex (#2625/#2642): там независимые
anchor'ы и провал одного среди успешных — не бан (анти-флап). Здесь anchor'ов нет,
sweep линейный по ROOM_BUCKETS, и первый же блок делает break — оставшиеся бакеты
не пробуются вовсе. Значит блок = прогон оборван, сколько бы лотов он ни успел
взять до этого.

Теперь: blocked → mark_banned (external constraint, не наш баг; тот же статус,
что #2642 дал cian/yandex — доступен как триггер ротации IP #2611, сама ротация
не вызывается). Ноль лотов с fetch-ошибками, но БЕЗ блока → по-прежнему failed.
Честная пустота → по-прежнему done.

Пометка прокси-пула (fetcher.report_ban, #2600 п.1) не тронута — живёт в
providers/domclick/serp.py и срабатывает раньше и независимо от статуса прогона.

Refs #2657
2026-08-05 23:37:48 +05:00

144 lines
5.6 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)
# ── 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) -> _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]