From c232772e703335ef6e7ee251dfe2a84438f1e76f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:39:40 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/cian):=20=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20bti=20=D0=B8=D0=B7=20offerData=20=E2=80=94=20BTI?= =?UTF-8?q?-=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81=D1=82=20=D0=B2=20houses?= =?UTF-8?q?=20=D0=BF=D0=B8=D1=81=D0=B0=D0=BB=20=D0=BD=D0=BE=D0=BB=D1=8C=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=20(#2435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2435 (PR #2437) завёл запись BTI-полей дома через match_or_create_house, но на проде она не дала ни одной строки: 9361 дом, 0 с series_name/entrances/flat_count/ is_emergency/heat_supply_type/gas_supply_type/overlap_type — при 628 detail- обогащённых Cian-листингах за Jul 5-22 и 5188 домах, которых Cian вообще касался. Причина: bti читался только как соседний с defaultState ключ контейнера frontend-offer-card, а Cian отдаёт его ВНУТРИ defaultState — offerData.bti. extract_all_states() исправно возвращает 143 ключа, но bti среди них нет, поэтому bti_data всегда оставался None и весь write-path был мёртвым. Существующие тесты этого не ловили: они кормят bti_data прямо в save_detail_enrichment, минуя fetch_detail. Новый тест гоняет реальный сохранённый HTML (fixtures/cian_flat_330982715.html) через настоящий fetch_detail — без фикса краснеет. Старое место оставлено фоллбэком. --- .../tests/test_cian_bti_house_persist.py | 60 ++++++++++++++++++- .../src/scraper_kit/providers/cian/detail.py | 10 +++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py index e41a2790..1c44a0e6 100644 --- a/tradein-mvp/backend/tests/test_cian_bti_house_persist.py +++ b/tradein-mvp/backend/tests/test_cian_bti_house_persist.py @@ -21,9 +21,13 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from contextlib import contextmanager -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock -from scraper_kit.providers.cian.detail import DetailEnrichment, save_detail_enrichment +from scraper_kit.providers.cian.detail import ( + DetailEnrichment, + fetch_detail, + save_detail_enrichment, +) from scraper_kit.providers.cian.valuation import CianValuationResult, _save_to_cache # ── Part 1: bti_data → houses (detail.py) ──────────────────────────────────── @@ -190,6 +194,58 @@ def test_bti_missing_listing_address_skips_resolve(): db.commit.assert_called_once() +# ── Part 1b: bti_data реально извлекается с живой страницы (регрессия) ─────── +# +# Тесты выше кормят `bti_data` напрямую в `save_detail_enrichment`, поэтому они +# оставались зелёными, пока `fetch_detail` на живых страницах НИКОГДА его не +# заполнял: bti читался только как соседний с `defaultState` ключ контейнера +# frontend-offer-card, а Cian отдаёт его ВНУТРИ defaultState — `offerData.bti`. +# Итог на проде: 628 detail-обогащённых Cian-листингов, 0 домов с BTI-полями. +# Здесь прогоняем настоящий сохранённый HTML через настоящий `fetch_detail`. + + +def _detail_fixture_html() -> str: + path = os.path.join(os.path.dirname(__file__), "fixtures", "cian_flat_330982715.html") + with open(path, encoding="utf-8") as fh: + return fh.read() + + +async def test_fetch_detail_extracts_bti_from_offer_data_on_real_page(): + """Реальная страница Cian: bti лежит в offerData → bti_data должен заполниться.""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=_detail_fixture_html()) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/330982715/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data, "bti_data пуст — BTI-персист (#2435) в проде остаётся no-op" + # Поля, которые пишет _persist_cian_bti_house (см. Part 1). + assert result.bti_data["entrances"] == 3 + assert result.bti_data["flatCount"] == 24 + assert result.bti_data["isEmergency"] is False + assert result.bti_data["houseHeatSupplyType"] == "central" + assert result.bti_data["houseGasSupplyType"] == "central" + assert result.bti_data["houseOverlapType"] == "concrete" + + +async def test_fetch_detail_bti_legacy_sibling_location_still_read(): + """Фоллбэк: если Cian вернёт bti соседним ключом контейнера — читаем и оттуда.""" + html = ( + "" + ) + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=html) + + result = await fetch_detail("https://ekb.cian.ru/sale/flat/777/", browser_fetcher=fetcher) + + assert result is not None + assert result.bti_data == {"seriesName": "1-464", "flatCount": 58} + + # ── Part 2: valuation house_info/managementCompany → houses (valuation.py) ── _HOUSE_INFO_SAMPLE = [ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py index 1b9ec683..65edcdf4 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/detail.py @@ -4,7 +4,8 @@ Different from SERP (cian.py): - URL: https://ekb.cian.ru/sale/flat// or https://www.cian.ru/sale/flat// - MFE: 'frontend-offer-card' - State KEY: 'defaultState' (NOT 'initialState' — SERP uses initialState) -- Sister containers in same _cianConfig: bti, priceChanges, stats, agent, newObject +- Sister data (bti, priceChanges, stats, agent, newObject) живёт ВНУТРИ defaultState + (`offerData.*`), а не отдельными ключами _cianConfig рядом с defaultState Parses ~88 offer fields + sister data → DetailEnrichment dataclass. Stage 5 of CianScraper v1. @@ -176,7 +177,12 @@ async def fetch_detail( all_states = extract_all_states(html) offer_card_states = all_states.get("frontend-offer-card", {}) - bti_state = offer_card_states.get("bti") + # BTI лежит ВНУТРИ defaultState (`offerData.bti`), а не отдельным ключом рядом с + # `defaultState` в контейнере frontend-offer-card. Пока читали только второе место, + # `bti_data` всегда был None и #2435 писал в houses ноль строк (прод: 0 домов с + # series_name/entrances/... при 628 detail-обогащённых листингах). Соседний ключ + # оставлен фоллбэком — на случай, если Cian вернёт старую раскладку. + bti_state = offer_data.get("bti") or offer_card_states.get("bti") if bti_state: result.bti_data = bti_state.get("houseData") result.raw_sister_states["bti"] = bti_state