"""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 = ( '

Купить квартиру в ' 'ЖК «Парковый квартал»

' ) assert old_extract_zhk_url(serp_html) == new_extract_zhk_url(serp_html) # ── strangler-guard: kit-провайдер не импортирует app.* ────────────────────── def test_kit_cian_has_no_app_imports() -> None: """Ни один модуль scraper_kit.providers.cian.* не импортирует app.* (развязка).""" import inspect from scraper_kit.providers.cian import detail, newbuilding, serp, session, valuation for mod in (serp, detail, newbuilding, valuation, session): source = inspect.getsource(mod) for line in source.splitlines(): stripped = line.strip() assert not stripped.startswith("from app"), f"{mod.__name__}: {line!r}" assert not stripped.startswith("import app"), f"{mod.__name__}: {line!r}"