diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py index 9a4c618a..a2344ff9 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py @@ -325,7 +325,14 @@ def _entity_to_lot( total_floors = int(floors_total_raw) if floors_total_raw is not None else None ceiling_height = entity.get("ceilingHeight") - ceiling_height_m = float(ceiling_height) if ceiling_height is not None else None + # Колонка ceiling_height — numeric(3,2), max 9.99. Yandex SERP отдаёт мусор + # (видели 18 м) → запись out-of-range уронила бы весь батч DataError'ом + # (per-lot SAVEPOINT ловит только IntegrityError). Берём только правдоподобный + # диапазон 2.0–6.0 м, иначе None; сырое значение остаётся в raw_payload. (#2007) + _ceiling_raw = float(ceiling_height) if ceiling_height is not None else None + ceiling_height_m = ( + _ceiling_raw if _ceiling_raw is not None and 2.0 <= _ceiling_raw <= 6.0 else None + ) building = entity.get("building") or {} year_built_raw = building.get("builtYear") diff --git a/tradein-mvp/backend/data/sql/140_yandex_house_type_backfill.sql b/tradein-mvp/backend/data/sql/140_yandex_house_type_backfill.sql index 4ccd15f7..9f1168b7 100644 --- a/tradein-mvp/backend/data/sql/140_yandex_house_type_backfill.sql +++ b/tradein-mvp/backend/data/sql/140_yandex_house_type_backfill.sql @@ -13,9 +13,14 @@ -- MONOLIT->monolith, BRICK->brick, PANEL->panel, MONOLIT_BRICK->monolith_brick, -- BLOCK->block, WOOD->wood. -- --- Idempotency: после первого прогона SCREAMING-значений не остаётся, WHERE-фильтр --- пуст → повторный apply = no-op (0 rows). Scope строго source='yandex' — --- cian/avito не трогаем (их канон чинится в коде / отдельных PR, напр. #2008). +-- Плюс 'other' (не-канон легаси-значение, 299 строк) → NULL: паритет с +-- normalize_house_type (unknown→None). В estimator soft-penalty NULL нейтрально, +-- а 'other' != target всегда даёт ложный штраф. Лоуэркейс 'brick'/'panel' уже +-- каноничны — не трогаем. +-- +-- Idempotency: после первого прогона SCREAMING/'other'-значений не остаётся, +-- WHERE-фильтры пусты → повторный apply = no-op (0 rows). Scope строго +-- source='yandex' — cian/avito не трогаем (канон в коде / отдельных PR, напр. #2008). BEGIN; @@ -32,4 +37,10 @@ UPDATE listings WHERE source = 'yandex' AND house_type IN ('MONOLIT', 'BRICK', 'PANEL', 'MONOLIT_BRICK', 'BLOCK', 'WOOD'); +-- 'other' → NULL (паритет с normalize_house_type: unknown→None, нейтрально в estimator). +UPDATE listings + SET house_type = NULL + WHERE source = 'yandex' + AND house_type = 'other'; + COMMIT; diff --git a/tradein-mvp/backend/tests/test_yandex_realty_serp.py b/tradein-mvp/backend/tests/test_yandex_realty_serp.py index 81e49409..87d8a37c 100644 --- a/tradein-mvp/backend/tests/test_yandex_realty_serp.py +++ b/tradein-mvp/backend/tests/test_yandex_realty_serp.py @@ -239,6 +239,19 @@ def test_entity_to_lot_full_entity(): assert lot.raw_payload["raw_building_type"] == "MONOLIT" # сырой SCREAMING сохранён +def test_entity_to_lot_ceiling_out_of_range_dropped(): + """ceiling_height — numeric(3,2) (max 9.99); неправдоподобный yandex SERP мусор + (видели 18 м) отсекаем в None, иначе DataError уронил бы весь батч. Сырое + значение остаётся в raw_payload. (#2007)""" + lot_hi = _entity_to_lot({**_ENTITY_FULL, "ceilingHeight": 18}) + assert lot_hi is not None + assert lot_hi.ceiling_height_m is None + assert lot_hi.raw_payload["ceiling_height"] == 18 # сырое сохранено + lot_lo = _entity_to_lot({**_ENTITY_FULL, "ceilingHeight": 1.6}) + assert lot_lo is not None + assert lot_lo.ceiling_height_m is None + + def test_entity_to_lot_segment_vtorichka_default(): """Default new_flat='NO' -> listing_segment='vtorichka'.""" lot = _entity_to_lot(_ENTITY_FULL)