diff --git a/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py new file mode 100644 index 00000000..5ca9ace7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_scraper_kit_cian_golden_parity.py @@ -0,0 +1,492 @@ +"""Golden-parity: `scraper_kit.providers.cian.*` парсинг ≡ `app.services.scrapers.cian*`. + +Strangler-инвариант (#2133 cian): новая scraper_kit-копия cian-провайдера должна давать +БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. Развязка +(settings→ScraperConfig, get_scraper_delay→delay_provider, cian_session→локальная копия) +НЕ меняет распарсенные данные. + +Гоняем ОБА модуля на одних фикстурах и сравниваем результат: + - SERP `_parse_serp_html` (Redux state → list[ScrapedLot]) на синтетическом _cianConfig + push-HTML (вторичка + новостройка + студия), сравнение model_dump(); + - SERP `_offer_to_lot` пооферно + `_format_address` (pure, восстановление дома ЖК); + - `_extract_total_offers` (totalOffers из state); + - `extract_state` — app-версия ≡ kit-версия на одном HTML (общий парсер state); + - detail `fetch_detail` (defaultState + bti sister → DetailEnrichment, dataclasses.asdict); + - valuation `_parse_valuation_state` (estimation/chart/houseInfo → CianValuationResult); + - newbuilding pure-экстракторы (_extract_chart / _extract_reliability_checks / + _extract_nested_offers / _extract_zhk_url_from_serp). + +Идентичность результата = kit-парсер верно скопирован (развязка не задела логику). +""" + +from __future__ import annotations + +import dataclasses +import json +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Старый app.services.scrapers.cian* импортирует app.core.config.settings=Settings(), +# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +# НОВЫЙ (scraper_kit) cian-провайдер. +from scraper_kit.cian_state_parser import extract_state as new_extract_state +from scraper_kit.providers.cian.detail import fetch_detail as new_fetch_detail +from scraper_kit.providers.cian.newbuilding import _extract_chart as new_extract_chart +from scraper_kit.providers.cian.newbuilding import ( + _extract_nested_offers as new_extract_nested_offers, +) +from scraper_kit.providers.cian.newbuilding import ( + _extract_reliability_checks as new_extract_reliability, +) +from scraper_kit.providers.cian.newbuilding import ( + _extract_zhk_url_from_serp as new_extract_zhk_url, +) +from scraper_kit.providers.cian.serp import CianScraper as NewCianScraper +from scraper_kit.providers.cian.serp import _format_address as new_format_address +from scraper_kit.providers.cian.valuation import ( + _parse_valuation_state as new_parse_valuation, +) + +# СТАРЫЙ (app.services.scrapers) cian-провайдер. +from app.services.scrapers.cian import CianScraper as OldCianScraper +from app.services.scrapers.cian import _format_address as old_format_address +from app.services.scrapers.cian_detail import fetch_detail as old_fetch_detail +from app.services.scrapers.cian_newbuilding import _extract_chart as old_extract_chart +from app.services.scrapers.cian_newbuilding import ( + _extract_nested_offers as old_extract_nested_offers, +) +from app.services.scrapers.cian_newbuilding import ( + _extract_reliability_checks as old_extract_reliability, +) +from app.services.scrapers.cian_newbuilding import ( + _extract_zhk_url_from_serp as old_extract_zhk_url, +) +from app.services.scrapers.cian_state_parser import extract_state as old_extract_state +from app.services.scrapers.cian_valuation import ( + _parse_valuation_state as old_parse_valuation, +) + +# ── Fixtures: SERP offers → _cianConfig push HTML ──────────────────────────── + +_CONFIG = SimpleNamespace(glitchtip_dsn=None) + + +def _serp_html(offers: list[dict[str, Any]]) -> str: + """Обернуть offers в валидный _cianConfig['frontend-serp'].push() HTML.""" + state = {"results": {"totalOffers": len(offers), "offers": offers}} + state_json = json.dumps(state, ensure_ascii=False) + return ( + "
" + ) + + +def _vtorichka_offer() -> dict[str, Any]: + return { + "cianId": 328739308, + "id": 328739308, + "fullUrl": "https://ekb.cian.ru/sale/flat/328739308/", + "offerType": "flat", + "flatType": "rooms", + "category": "flatSale", + "isApartments": False, + "roomsCount": 2, + "totalArea": "52.0", + "livingArea": "35.0", + "kitchenArea": "11.5", + "balconiesCount": 1, + "loggiasCount": 0, + "bedroomsCount": None, + "hasFurniture": False, + "isSoldFurnished": False, + "decoration": "euro", + "floorNumber": 7, + "building": { + "floorsCount": 16, + "materialType": "monolithBrick", + "buildYear": 2010, + "parking": {"type": "underground"}, + "totalArea": "3400", + }, + "cadastralNumber": "66:41:0204016:1234", + "buildingCadastralNumber": "66:41:0204016:100", + "bargainTerms": { + "priceRur": 6500000, + "price": 6500000, + "saleType": "free", + "mortgageAllowed": True, + "bargainAllowed": True, + }, + "geo": { + "coordinates": {"lat": 56.8385, "lng": 60.5940}, + "address": [ + {"type": "location", "fullName": "Свердловская область"}, + {"type": "raion", "fullName": "Кировский"}, + {"type": "street", "fullName": "улица Постовского"}, + {"type": "house", "fullName": "17А"}, + {"type": "metro", "fullName": "Геологическая"}, + ], + "undergrounds": [ + { + "name": "Геологическая", + "time": 10, + "transportType": "walk", + "lineId": 10, + "lineColor": "FF0000", + "isDefault": True, + } + ], + }, + "newbuilding": {"id": 0, "name": ""}, + "phones": [{"number": "9012345678", "countryCode": "7"}], + "userId": 13332612, + "publishedUserId": 13332612, + "isByHomeowner": True, + "isPro": False, + "isRosreestrChecked": True, + "description": "Продаётся отличная квартира с евроремонтом", + "descriptionMinhash": "abc123def456abc1", + "addedTimestamp": 1716451200, + "photos": [{"fullUrl": "https://images.cdn-cian.ru/images/328739308-1.jpg"}], + } + + +def _newbuilding_offer() -> dict[str, Any]: + """Новостройка без части house в geo.address[] — жмёт _recover_newbuilding_house.""" + return { + "cianId": 411112222, + "id": 411112222, + "fullUrl": "https://ekb.cian.ru/sale/flat/411112222/", + "offerType": "flat", + "flatType": "rooms", + "isApartments": False, + "roomsCount": 1, + "totalArea": "38.0", + "floorNumber": 12, + "building": { + "floorsCount": 25, + "materialType": "monolith", + "buildYear": None, + "deadline": {"year": 2026}, + }, + "buildingCadastralNumber": "66:41:0204016:11396", + "bargainTerms": {"priceRur": 5200000, "price": 5200000, "mortgageAllowed": True}, + "geo": { + "coordinates": {"lat": 56.8000, "lng": 60.6000}, + "address": [ + {"type": "location", "fullName": "Свердловская область"}, + {"type": "street", "fullName": "улица Новая"}, + ], + }, + "newbuilding": {"id": 48853, "name": "ЖК Парковый квартал 29А"}, + "isFromDeveloper": True, + "photos": [], + } + + +def _studio_offer() -> dict[str, Any]: + return { + "cianId": 500011111, + "id": 500011111, + "offerType": "flat", + "flatType": "studio", + "roomsCount": None, + "totalArea": "24.5", + "floorNumber": 3, + "building": {"floorsCount": 9, "materialType": "panel", "buildYear": 1980}, + "bargainTerms": {"priceRur": 3100000, "price": 3100000}, + "geo": { + "coordinates": {"lat": 56.81, "lng": 60.61}, + "address": [ + {"type": "street", "fullName": "улица Ленина"}, + {"type": "house", "fullName": "5"}, + ], + }, + "newbuilding": {"id": 0}, + "photos": [], + } + + +_OFFERS = [_vtorichka_offer(), _newbuilding_offer(), _studio_offer()] + + +def _make_old() -> OldCianScraper: + with patch("app.services.scrapers.cian.get_scraper_delay", return_value=5.0): + return OldCianScraper() + + +def _make_new() -> NewCianScraper: + return NewCianScraper(config=_CONFIG) + + +def _dump(lots: list[Any]) -> list[dict[str, Any]]: + return [lot.model_dump() for lot in lots] + + +# ── SERP _parse_serp_html parity ───────────────────────────────────────────── + + +def test_serp_parse_serp_html_parity() -> None: + """Redux offers → list[ScrapedLot] идентичны (вторичка + новостройка + студия).""" + html = _serp_html(_OFFERS) + old_lots = _make_old()._parse_serp_html(html) + new_lots = _make_new()._parse_serp_html(html) + + assert len(old_lots) == len(new_lots) == 3 + assert _dump(old_lots) == _dump(new_lots) + + +def test_serp_offer_to_lot_parity_per_offer() -> None: + """Пооферный _offer_to_lot: model_dump идентичен для каждого offer.""" + old, new = _make_old(), _make_new() + for offer in _OFFERS: + old_lot = old._offer_to_lot(offer) + new_lot = new._offer_to_lot(offer) + assert (old_lot is None) == (new_lot is None) + if old_lot is not None and new_lot is not None: + assert old_lot.model_dump() == new_lot.model_dump() + + +def test_serp_extract_total_offers_parity() -> None: + html = _serp_html(_OFFERS) + assert _make_old()._extract_total_offers(html) == _make_new()._extract_total_offers(html) + + +def test_format_address_parity() -> None: + """_format_address (pure) — вкл. восстановление дома для ЖК без части house.""" + building = {"houseNumber": None} + nb = {"name": "ЖК Парковый квартал 29А к1"} + cases = [ + # (address_parts, is_newbuilding kwargs) + ([], {}), + ( + [ + {"type": "location", "fullName": "Свердловская область"}, + {"type": "street", "fullName": "улица Мира"}, + {"type": "house", "fullName": "12"}, + ], + {}, + ), + ( + [ + {"type": "location", "fullName": "Свердловская область"}, + {"type": "street", "fullName": "улица Новая"}, + ], + { + "building": building, + "building_cadastral_number": "66:41:0204016:11396", + "newbuilding": nb, + "is_newbuilding": True, + }, + ), + ] + for parts, kwargs in cases: + assert old_format_address(parts, **kwargs) == new_format_address(parts, **kwargs) + + +def test_extract_state_parity() -> None: + """app.extract_state ≡ scraper_kit.extract_state на SERP-фикстуре.""" + html = _serp_html(_OFFERS) + old_state = old_extract_state(html, mfe="frontend-serp", key="initialState") + new_state = new_extract_state(html, mfe="frontend-serp", key="initialState") + assert old_state == new_state + assert new_state is not None + + +# ── detail fetch_detail parity ─────────────────────────────────────────────── + + +def _detail_html() -> str: + """defaultState + bti sister → _cianConfig['frontend-offer-card'] push HTML.""" + default_state = { + "offerData": { + "offer": { + "cianId": 327830237, + "windowsViewType": "yardAndStreet", + "separateWcsCount": 1, + "combinedWcsCount": 0, + "building": {"ceilingHeight": 2.65}, + "repairType": "cosmetic", + "kitchenArea": "11.5", + "description": "Отличная квартира", + "priceChanges": [ + {"changeTime": "2026-04-01T00:00:00Z", "price": 5500000, "diffPercent": -1.8} + ], + "agent": { + "id": 7654321, + "companyName": "Первичка Плюс", + "isPro": True, + "skills": [], + "accountType": "specialist", + }, + "newbuilding": {"id": 0}, + }, + "stats": { + "totalViewsFormattedString": "1 042", + "todayViewsFormattedString": "7", + }, + } + } + bti_state = {"houseData": {"series": "1-464", "wallMaterial": "brick"}} + ds_json = json.dumps(default_state, ensure_ascii=False) + bti_json = json.dumps(bti_state, ensure_ascii=False) + return ( + "" + ) + + +def _mock_session(html: str) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.text = html + session = MagicMock() + session.get = AsyncMock(return_value=response) + session.close = AsyncMock() + return session + + +@pytest.mark.asyncio +async def test_detail_fetch_parity() -> None: + """fetch_detail (defaultState + bti) → DetailEnrichment идентичен (все поля).""" + html = _detail_html() + url = "https://ekb.cian.ru/sale/flat/327830237/" + + old_res = await old_fetch_detail(url, session=_mock_session(html)) + new_res = await new_fetch_detail(url, session=_mock_session(html)) + + assert old_res is not None + assert new_res is not None + assert dataclasses.asdict(old_res) == dataclasses.asdict(new_res) + + +# ── valuation _parse_valuation_state parity ────────────────────────────────── + + +def _valuation_state() -> dict[str, Any]: + return { + "user": {"isAuthenticated": True, "userId": 42}, + "estimation": { + "sale": { + "data": { + "price": 6800000, + "accuracy": 87, + "priceFrom": 6400000, + "priceTo": 7200000, + "priceSqm": 130000, + "filtersHash": "hash-abc", + } + }, + "rent": { + "data": { + "price": 42000, + "accuracy": 80, + "priceFrom": 39000, + "priceTo": 45000, + "taxPrice": 5460, + } + }, + }, + "estimationChart": { + "data": { + "title": {"change": "increase", "changeValue": "8,3%"}, + "chartData": { + "data": [ + {"date": 1714521600000, "price": 6700000, "priceFormatted": "6,7 млн"}, + {"date": 1717200000000, "price": 6800000, "priceFormatted": "6,8 млн"}, + ] + }, + } + }, + "houseInfo": { + "data": { + "items": [ + {"title": "Год постройки", "value": "2010"}, + {"title": "Тип дома", "value": "монолит"}, + ] + } + }, + "filters": {"houseId": 998877}, + "managementCompany": {"data": {"name": "УК Ромашка", "phones": ["+73430000000"]}}, + } + + +def test_valuation_parse_state_parity() -> None: + """_parse_valuation_state → CianValuationResult идентичен (все поля).""" + state = _valuation_state() + old_res = old_parse_valuation(state) + new_res = new_parse_valuation(state) + assert dataclasses.asdict(old_res) == dataclasses.asdict(new_res) + + +# ── newbuilding pure-экстракторы parity ────────────────────────────────────── + + +def test_newbuilding_extractors_parity() -> None: + rv_state = { + "data": { + "priceDynamics": { + "chart": { + "data": {"labels": ["2026-01-01", "2026-02-01"], "values": [120000, 125000]} + } + } + } + } + assert old_extract_chart(rv_state) == new_extract_chart(rv_state) + + rel_state = { + "checkStatus": {"status": "reliable", "title": "Надёжный застройщик"}, + "details": [{"title": "Проектная декларация", "type": "doc"}], + } + assert old_extract_reliability(rel_state) == new_extract_reliability(rel_state) + + offers_state = { + "data": { + "total": { + "fromDeveloperRooms": [ + {"roomType": "1", "layouts": [{"id": 1, "area": 38}]}, + {"roomType": "2", "layouts": [{"id": 2, "area": 55}]}, + ] + } + } + } + assert old_extract_nested_offers(offers_state) == new_extract_nested_offers(offers_state) + + serp_html = ( + '