"""Parity-proof: admin.py Yandex debug-роуты (#2305, Group A) — legacy vs scraper_kit. `app/api/v1/admin.py::scrape_yandex_detail` / `scrape_yandex_valuation` теперь зовут `scraper_kit.providers.yandex.{detail,valuation}` вместо `app.services.scrapers.yandex_{detail,valuation}` (тот же `/scrape` root-роут — `scraper_kit.providers.yandex.serp.YandexRealtyScraper`). Каждая функция ниже — чистое, offline-тестируемое parsing-ядро (см. `tests/support/README.md`: "Live network/DB в parity-тестах избегайте"). - `_parse_card_fields` — `scrape_yandex_detail` → `YandexDetailScraper.fetch_detail` извлекает (rooms, area, living, kitchen, ceiling, floor, total_floors) этой функцией. - `YandexValuationScraper._parse_house_meta` (@staticmethod) — `scrape_yandex_valuation` → `fetch_house_history` извлекает house-level метаданные этой функцией. - `_entity_to_lot` — `/scrape` root-роут → `YandexRealtyScraper.fetch_around` конвертирует gate-API entity → ScrapedLot этой функцией (также доказывает ScrapedLot-контракт, общий с `save_listings`/`scripts/ingest_domclick_jsonl.py`). """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.providers.yandex.detail import _parse_card_fields as kit_parse_card_fields from scraper_kit.providers.yandex.serp import _entity_to_lot as kit_entity_to_lot from scraper_kit.providers.yandex.valuation import ( YandexValuationScraper as KitYandexValuationScraper, ) from app.services.scrapers.yandex_detail import _parse_card_fields as legacy_parse_card_fields from app.services.scrapers.yandex_realty import _entity_to_lot as legacy_entity_to_lot from app.services.scrapers.yandex_valuation import ( YandexValuationScraper as LegacyYandexValuationScraper, ) from tests.support.parity import assert_parity _CARD = { "roomsTotal": 2, "area": {"value": 54.3}, "livingSpace": {"value": 32.1}, "kitchenSpace": {"value": 9.5}, "ceilingHeight": 2.7, "floorsOffered": [5], "floorsTotal": 9, } _CARD_STUDIO = { "house": {"studio": True}, "area": {"value": 25.0}, "floorsOffered": [1], "floorsTotal": 5, } _VALUATION_BODY_TEXT = ( "Дом 2015 года. 9 этажей. 2,7 м потолки. Лифт есть. 120 объектов продажи. Панорама двора." ) # gate-API entity (realty.yandex.ru), без creationDate/author — исключает # time-dependent поля (days_on_market) из фикстуры (deterministic offline). _YANDEX_ENTITY = { "offerId": "12345", "url": "//realty.yandex.ru/offer/12345", "price": {"value": 8500000, "valuePerPart": 157000}, "area": {"value": 54.3}, "livingSpace": {"value": 32.1}, "kitchenSpace": {"value": 9.5}, "roomsTotal": 2, "floorsOffered": [5], "floorsTotal": 9, "ceilingHeight": 2.7, "building": {"builtYear": 2015, "buildingType": "MONOLIT"}, "location": { "point": {"latitude": 56.83, "longitude": 60.6}, "geocoderAddress": "Екатеринбург, ул. Тестовая, 1", }, "mainImages": ["//avatars.mds.yandex.net/img1.jpg"], } def test_parity_yandex_detail_card_fields() -> None: """`scrape_yandex_detail` (admin.py) → fetch_detail → _parse_card_fields.""" assert_parity( legacy_fn=legacy_parse_card_fields, kit_fn=kit_parse_card_fields, fixtures=[(_CARD,), (_CARD_STUDIO,)], ) def test_yandex_valuation_house_meta_are_class_distinct() -> None: """Sanity: legacy/kit ValuationHouseMeta — разные классы, обычный `==` даёт False.""" legacy_result = LegacyYandexValuationScraper._parse_house_meta(_VALUATION_BODY_TEXT) kit_result = KitYandexValuationScraper._parse_house_meta(_VALUATION_BODY_TEXT) assert type(legacy_result) is not type(kit_result) assert legacy_result.model_dump() == kit_result.model_dump() def test_parity_yandex_valuation_house_meta() -> None: """`scrape_yandex_valuation` (admin.py) → fetch_house_history → _parse_house_meta.""" assert_parity( legacy_fn=LegacyYandexValuationScraper._parse_house_meta, kit_fn=KitYandexValuationScraper._parse_house_meta, fixtures=[_VALUATION_BODY_TEXT], ) def test_yandex_entity_to_lot_are_class_distinct() -> None: """Sanity: legacy/kit ScrapedLot — разные классы (pydantic), обычный `==` даёт False.""" legacy_result = legacy_entity_to_lot(_YANDEX_ENTITY) kit_result = kit_entity_to_lot(_YANDEX_ENTITY) assert legacy_result is not None assert kit_result is not None assert type(legacy_result) is not type(kit_result) assert legacy_result.model_dump() == kit_result.model_dump() def test_parity_yandex_serp_entity_to_lot() -> None: """`/scrape` root-роут (admin.py) → YandexRealtyScraper.fetch_around → _entity_to_lot. Также доказывает ScrapedLot-контракт, общий с `save_listings` (используется и `/scrape`, и `scripts/ingest_domclick_jsonl.py`). """ assert_parity( legacy_fn=legacy_entity_to_lot, kit_fn=kit_entity_to_lot, fixtures=[(_YANDEX_ENTITY,)], ) def test_parity_harness_catches_yandex_entity_divergence() -> None: """Harness реально ЛОВИТ расхождение — мутируем price_rub в kit-результате.""" import pytest from tests.support.parity import ParityMismatchError legacy_result = legacy_entity_to_lot(_YANDEX_ENTITY) kit_result = kit_entity_to_lot(_YANDEX_ENTITY) assert legacy_result is not None assert kit_result is not None # ScrapedLot — pydantic BaseModel (НЕ dataclass) → model_copy, не dataclasses.replace. mutated_kit_result = kit_result.model_copy(update={"price_rub": kit_result.price_rub + 1}) with pytest.raises(ParityMismatchError) as exc_info: assert_parity( legacy_fn=lambda: legacy_result, kit_fn=lambda: mutated_kit_result, fixtures=[()], ) assert "price_rub" in str(exc_info.value)