Merge pull request 'fix(tradein/cian): читать bti из offerData — BTI-персист в houses писал ноль строк (#2435)' (#2668) from feat/2435-cian-house-enrichment into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m50s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m55s

This commit is contained in:
bot-backend 2026-08-05 18:54:35 +00:00
commit ef8609d725
2 changed files with 66 additions and 4 deletions

View file

@ -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 = (
"<script>window._cianConfig['frontend-offer-card'] = "
"(window._cianConfig['frontend-offer-card'] || []).concat(["
'{"key":"defaultState","value":{"offerData":{"offer":{"cianId":777}}}},'
'{"key":"bti","value":{"houseData":{"seriesName":"1-464","flatCount":58}}}'
"]);</script>"
)
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 = [

View file

@ -4,7 +4,8 @@ Different from SERP (cian.py):
- URL: https://ekb.cian.ru/sale/flat/<offer_id>/ or https://www.cian.ru/sale/flat/<offer_id>/
- 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