fix(tradein): свести высоту потолков к одной колонке ceiling_height_m #2779

Merged
bot-backend merged 2 commits from fix/2699-ceiling-height into main 2026-08-07 09:18:49 +00:00
12 changed files with 393 additions and 41 deletions

View file

@ -2585,7 +2585,8 @@ _DQ_LISTING_FIELDS: list[tuple[str, str]] = [
("lon", "lon IS NOT NULL"),
("kitchen_area_m2", "kitchen_area_m2 IS NOT NULL"),
("living_area_m2", "living_area_m2 IS NOT NULL"),
("ceiling_height", "ceiling_height IS NOT NULL"),
# #2699: одна колонка вместо двух. ceiling_height (019) DEPRECATED — писатели
# переведены на ceiling_height_m, исторические значения перенесены (мигр. 238).
("ceiling_height_m", "ceiling_height_m IS NOT NULL"),
("metro_stations", "metro_stations IS NOT NULL AND metro_stations <> '[]'::jsonb"),
]
@ -2599,7 +2600,7 @@ def get_data_quality(
Один проход per source через COUNT(*)...FILTER не N запросов.
Поля listings: description, photo_urls, address, lat/lon, kitchen_area_m2,
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
living_area_m2, ceiling_height_m (все источники, #2699), metro_stations.
houses: total, avito_validated_at%, rating_score%, house_type%.
house_reviews: общий count.

View file

@ -75,7 +75,10 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
# Yandex Valuation enrichment (existing house attrs)
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
"ceiling_height": ["cian_detail", "yandex_valuation"],
# #2699: запись "ceiling_height" удалена — колонки с таким именем в `houses`
# никогда не было (проверено на проде: 0 колонок LIKE '%ceiling%'), правило не
# могло сработать ни разу. Высота потолков — атрибут ОБЪЯВЛЕНИЯ
# (listings.ceiling_height_m), см. LISTING_FIELD_PRIORITY ниже.
}
# ---------------------------------------------------------------------------
@ -88,7 +91,10 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
"area_m2": ["cian_serp", "avito_detail"],
"living_area_m2": ["cian_serp"],
"kitchen_area_m2": ["cian_serp", "avito_detail"],
"ceiling_height": ["cian_detail"],
# #2699: ключ — имя РЕАЛЬНОЙ колонки. Был "ceiling_height" (019), которую
# эстиматор не читает; канон — ceiling_height_m, и её пишут все три источника,
# а не только cian_detail.
"ceiling_height_m": ["cian_detail", "avito_detail", "yandex_detail"],
"floor": ["cian_serp", "avito_detail"],
"total_floors": ["cian_serp", "avito_detail"],
"year_built": ["cian_serp"],

View file

@ -0,0 +1,90 @@
-- 238_listings_ceiling_height_unify.sql
-- Purpose (#2699): свести высоту потолков к ОДНОЙ колонке — listings.ceiling_height_m.
--
-- Dependencies: 019_listings_alter_cian.sql (ceiling_height numeric(3,2)),
-- 111_listings_avito_detail_fields.sql (ceiling_height_m numeric(5,2)).
-- Apply after: 234_scrape_runs_ban_kind_unknown.sql
-- Идемпотентно: UPDATE'ы по предикату «уже приведено» + COMMENT ON COLUMN.
--
-- ── ЧТО БЫЛО ─────────────────────────────────────────────────────────────────
-- Две колонки одного смысла, источники разложены по ним НЕ ПЕРЕСЕКАЯСЬ, а
-- потребитель (эстиматор, estimator._apply_kitchen_ceiling_signal) читает одну.
--
-- Прод-замер 2026-08-07, listings (всего 94 373):
-- source | всего | ceiling_height | ceiling_height_m | обе | расходятся
-- avito | 48 592 | 0 | 7 149 | 0 | 0
-- cian | 21 951 | 855 | 0 | 0 | 0
-- yandex | 16 854 | 7 699 | 7 675 | 7 675 | 0
-- domklik | 6 594 | 0 | 0 | 0 | 0
-- n1 | 382 | 0 | 0 | 0 | 0
--
-- РАСХОЖДЕНИЙ НЕТ: там, где заполнены обе (7 675 строк), значения СОВПАДАЮТ до
-- последнего знака. Значит это не «две разные правды», а одна правда в двух
-- ящиках — задача не про неверную цену, а про лишнюю колонку и потерянное
-- покрытие: 855 циановских + 24 яндексовых значения не видит ни один потребитель.
--
-- ── ВЫБОР КАНОНА: ceiling_height_m ──────────────────────────────────────────
-- 1. Единицы в имени. Так этот же признак назван ВЕЗДЕ в проекте:
-- domrf_kn_flats.ceiling_height_m, domrf_kn_objects.ceiling_height_m,
-- фронтовый ObjectSpecsTable. listings.ceiling_height — единственное исключение.
-- 2. Её читает единственный потребитель, влияющий на цену (эстиматор).
-- 3. numeric(5,2) против numeric(3,2): у 019-колонки потолок 9.99, и запись
-- out-of-range роняет ВЕСЬ батч DataError'ом (per-lot SAVEPOINT ловит только
-- IntegrityError) — это уже приходилось обходить инлайн-гейтом в yandex SERP.
-- 4. После переноса она — надмножество: 15 591 против 8 554 (посчитано
-- симуляцией на проде: 14 824 110 мусорных + 877 перенесённых).
--
-- ── ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ ─────────────────────────────────────────────────
-- 1. Чистит невозможные значения в ОБЕИХ колонках (вне 2.06.0 м).
-- 2. Переносит уникальные значения ceiling_height → ceiling_height_m.
-- 3. Помечает ceiling_height как DEPRECATED (писателей у неё больше нет).
-- DROP COLUMN намеренно НЕ здесь: сначала прод должен подтвердить, что колонку
-- никто не пишет и не читает. Снос — отдельным шагом, за database-expert.
BEGIN;
-- ── 1. Невозможные значения → NULL ──────────────────────────────────────────
-- ceiling_height_m: 26 строк > 6 м (максимум 29.90) + 84 строки < 2 м (83 из них
-- ровно 0.00) — все из avito detail, где парсер брал первое число строки без
-- проверки. Корень починен в scraper_kit.ceiling_height.plausible_ceiling_m;
-- здесь — исторический хвост. NULL, а не clamp: 0.00 и 29.90 это не потолок, а
-- мис-парс, и «починить» их в правдоподобное число мы не можем — сырой текст
-- остаётся в raw_payload / на карточке.
UPDATE listings
SET ceiling_height_m = NULL
WHERE ceiling_height_m IS NOT NULL
AND ceiling_height_m NOT BETWEEN 2.0 AND 6.0;
-- ceiling_height: 2 строки cian (0.00 и 1.70). Чистим до переноса, чтобы шаг 2
-- не затащил мусор в канон.
UPDATE listings
SET ceiling_height = NULL
WHERE ceiling_height IS NOT NULL
AND ceiling_height NOT BETWEEN 2.0 AND 6.0;
-- ── 2. Перенос уникальных значений в канон ──────────────────────────────────
-- Ожидаемо 877 строк: 855 cian (писал только в 019-колонку) 2 мусорных,
-- отсеянных шагом 1, + 24 yandex (detail записал, а SERP по этой карточке
-- значения не дал). Пересечение переносить нечего — значения там тождественны
-- (см. замер выше), поэтому только IS NULL.
-- Итог после миграции (симуляция на проде 2026-08-07): ceiling_height_m = 15 591
-- непустых, из них 0 вне диапазона 2.06.0.
UPDATE listings
SET ceiling_height_m = ceiling_height
WHERE ceiling_height_m IS NULL
AND ceiling_height IS NOT NULL;
-- ── 3. Пометка устаревшей колонки ───────────────────────────────────────────
COMMENT ON COLUMN listings.ceiling_height IS
'DEPRECATED (#2699, 2026-08-07). Канон — listings.ceiling_height_m. '
'Писателей нет: base.save_listings / cian_detail / yandex_detail переведены '
'на ceiling_height_m, coverage-дашборд строку убрал. Значения перенесены этой '
'миграцией. Оставлена временно как страховка отката; снос — отдельным шагом '
'после прод-подтверждения, что никто не пишет и не читает.';
COMMENT ON COLUMN listings.ceiling_height_m IS
'Высота потолков, метры. КАНОН (#2699) — единственная колонка этого признака. '
'Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m '
'(гейт правдоподобия 2.06.0 м). Читает estimator (comp-scoring #2012).';
COMMIT;

View file

@ -101,9 +101,17 @@ class TestYandexHousePriority:
out = resolve_house_field("has_lift", {"cian_bti": True, "yandex_valuation": True})
assert out is True
def test_house_ceiling_height_cian_detail_preferred(self) -> None:
out = resolve_house_field("ceiling_height", {"cian_detail": 2.7, "yandex_valuation": 2.5})
assert out == 2.7
def test_house_has_no_ceiling_height_rule(self) -> None:
"""#2699: правила для houses.ceiling_height быть не должно — колонки нет.
Раньше здесь стояло `resolve_house_field("ceiling_height", ...) == 2.7`.
Тест зеленел, но проверял ФАНТОМ: колонки `ceiling_height` в таблице
`houses` не существует (прод: 0 колонок LIKE '%ceiling%'), правило не
могло сработать ни разу. Высота потолков атрибут объявления.
"""
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
def test_house_has_panorama_yandex_valuation_only(self) -> None:
out = resolve_house_field("has_panorama", {"yandex_valuation": True})

View file

@ -0,0 +1,193 @@
"""#2699 — высота потолков сведена к одной колонке listings.ceiling_height_m.
Что было (прод-замер 2026-08-07, 94 373 объявления):
* ``ceiling_height`` (мигр. 019, numeric(3,2)) 8 554 значения: cian 855,
yandex 7 699. ``ceiling_height_m`` (мигр. 111, numeric(5,2)) 14 824:
avito 7 149, yandex 7 675.
* Пересечение 7 675 строк, РАСХОЖДЕНИЙ НОЛЬ одно и то же значение в двух
ящиках, а не два конфликтующих источника.
* Эстиматор (``_apply_kitchen_ceiling_signal``) читает только ``_m`` 855
циановских + 24 яндексовых значения не видит никто.
* Гейт правдоподобия был только у yandex SERP avito detail нагнал 26 значений
> 6 м (максимум 29.90) и 83 ровных 0.00.
Каждый тест ниже КРАСНЫЙ на коде до #2699.
"""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import MagicMock
import pytest
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.providers.avito.detail import _parse_height_m
from scraper_kit.providers.cian.detail import DetailEnrichment as CianEnrichment
from scraper_kit.providers.cian.detail import save_detail_enrichment as cian_save
from scraper_kit.providers.yandex.detail import DetailEnrichment as YandexEnrichment
from scraper_kit.providers.yandex.detail import save_detail_enrichment as yandex_save
from app.services.matching.conflict_resolution import (
HOUSE_FIELD_PRIORITY,
LISTING_FIELD_PRIORITY,
)
# ── гейт правдоподобия ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
("raw", "expected"),
[
(2.7, 2.7), # типовая панелька
(2.0, 2.0), # нижняя граница включительно
(6.0, 6.0), # верхняя граница включительно (двухуровневая карточка)
(5.97, 5.97), # реальный максимум cian на проде
("2,7".replace(",", "."), 2.7), # строковый вход от парсера
(29.90, None), # прод-максимум мусора avito
(18.0, None), # мусор yandex SERP из #2007
(0.0, None), # 83 строки avito на проде
(1.70, None), # ниже жилого потолка
(9.99, None), # влезает в numeric(3,2), но потолком не является
(None, None),
("не число", None),
],
)
def test_plausible_ceiling_m_band(raw: object, expected: float | None) -> None:
assert plausible_ceiling_m(raw) == expected # type: ignore[arg-type]
def test_avito_parse_height_rejects_impossible() -> None:
"""КОРЕНЬ мусора: парсер avito брал первое число строки без проверки.
Красный до #2699: возвращал 29.9 / 0.0 и они уезжали в ceiling_height_m,
который читает эстиматор.
"""
assert _parse_height_m("2,7 м") == pytest.approx(2.7)
assert _parse_height_m("3 м") == pytest.approx(3.0)
assert _parse_height_m("29,9 м") is None
assert _parse_height_m("0 м") is None
assert _parse_height_m("нет данных") is None
# ── писатели пишут В КАНОН ───────────────────────────────────────────────────
def _mock_db() -> MagicMock:
"""Session-заглушка, копящая (sql, params) каждого execute."""
db = MagicMock()
calls: list[tuple[str, dict]] = []
@contextmanager
def _nested(): # type: ignore[no-untyped-def]
yield MagicMock()
db.begin_nested.side_effect = _nested
def _execute(sql, params=None): # type: ignore[no-untyped-def]
calls.append((str(sql), params or {}))
result = MagicMock()
result.fetchone.return_value = None
result.scalar_one_or_none.return_value = None
result.rowcount = 1
return result
db.execute.side_effect = _execute
db._calls = calls
return db
def _yandex_enrichment(ceiling_height: float) -> YandexEnrichment:
return YandexEnrichment(
offer_id="1234567890",
source_url="https://realty.yandex.ru/offer/1234567890/",
ceiling_height=ceiling_height,
)
def _listings_update_sql(db: MagicMock) -> str:
for sql, _params in db._calls:
if "UPDATE listings" in sql:
return sql
raise AssertionError("не нашли UPDATE listings")
def test_cian_detail_writes_canonical_column() -> None:
"""855 значений Циана уходили в колонку, которую эстиматор не читает.
Красный до #2699: UPDATE присваивал `ceiling_height`, не `ceiling_height_m`.
"""
db = _mock_db()
cian_save(db, 55, CianEnrichment(ceiling_height=2.7))
sql = _listings_update_sql(db)
assert "ceiling_height_m = COALESCE" in sql
assert "ceiling_height = COALESCE" not in sql
def test_yandex_detail_writes_canonical_column() -> None:
"""Красный до #2699: yandex detail тоже писал только в 019-колонку."""
db = _mock_db()
yandex_save(db, 77, _yandex_enrichment(2.55))
sql = _listings_update_sql(db)
assert "ceiling_height_m = COALESCE" in sql
assert "ceiling_height = COALESCE" not in sql
def test_detail_writers_gate_impossible_values() -> None:
"""Невозможная высота не доезжает до БД ни одним из detail-путей."""
db_cian = _mock_db()
cian_save(db_cian, 55, CianEnrichment(ceiling_height=29.9))
assert next(p for s, p in db_cian._calls if "UPDATE listings" in s)["ch"] is None
db_yandex = _mock_db()
yandex_save(db_yandex, 77, _yandex_enrichment(0.0))
assert next(p for s, p in db_yandex._calls if "UPDATE listings" in s)["ceiling_height"] is None
def test_base_upsert_no_longer_writes_deprecated_column() -> None:
"""save_listings писал ОДИН param в ОБЕ колонки — источник дубля.
Красный до #2699: SQL содержал `ceiling_height,` в списке колонок INSERT.
Проверяем по исходнику модуля: собрать SQL иначе можно только выполнив
upsert против живой БД.
"""
import re
from pathlib import Path
import scraper_kit.base as base
src = Path(base.__file__).read_text(encoding="utf-8")
# SQL-упоминания колонки: `ceiling_height` не как префикс `ceiling_height_m`
# и не внутри комментария (`--`).
offenders = [
line
for line in src.splitlines()
# `ceiling_height` в позиции SQL-колонки: перед `,` (список INSERT) или
# перед `=` (SET). Импорт модуля-гейта и комментарии не считаются.
if re.search(r"\bceiling_height\b(?!_m)\s*[,=]", line) and "--" not in line
]
assert offenders == [], f"base.py всё ещё пишет DEPRECATED-колонку: {offenders}"
# ── карта приоритетов больше не указывает в пустоту ──────────────────────────
def test_house_priority_has_no_phantom_ceiling_rule() -> None:
"""Колонки `ceiling_height` в таблице houses нет и не было.
Красный до #2699: правило существовало и не могло сработать ни разу.
"""
assert not [k for k in HOUSE_FIELD_PRIORITY if "ceiling" in k]
def test_listing_priority_names_the_real_column() -> None:
"""Красный до #2699: ключ назывался `ceiling_height` — колонка есть, но её
не читает ни один потребитель, влияющий на цену."""
assert "ceiling_height" not in LISTING_FIELD_PRIORITY
assert LISTING_FIELD_PRIORITY["ceiling_height_m"] == [
"cian_detail",
"avito_detail",
"yandex_detail",
]

View file

@ -373,7 +373,6 @@ def _make_dq_db_mock() -> MagicMock:
"f_lon": 950,
"f_kitchen_area_m2": 600,
"f_living_area_m2": 100,
"f_ceiling_height": 50,
"f_ceiling_height_m": 300,
"f_metro_stations": 700,
}
@ -388,8 +387,10 @@ def _make_dq_db_mock() -> MagicMock:
"f_lon": 480,
"f_kitchen_area_m2": 400,
"f_living_area_m2": 400,
"f_ceiling_height": 350,
"f_ceiling_height_m": 0,
# #2699: cian тоже пишет в канон ceiling_height_m — раньше в фикстуре
# стояло f_ceiling_height=350 / f_ceiling_height_m=0, ровно тот прод-раскол,
# из-за которого 855 циановских значений не видел ни один потребитель.
"f_ceiling_height_m": 350,
"f_metro_stations": 100,
}
@ -451,8 +452,8 @@ def test_data_quality_shape(client: TestClient) -> None:
cian = sources["cian"]
assert cian["active_count"] == 500
assert cian["stale_count"] == 300 # #2660: 60% «активных» на деле не виделись
assert cian["fields"]["ceiling_height"] == pytest.approx(70.0, abs=0.1)
assert cian["fields"]["ceiling_height_m"] == pytest.approx(0.0, abs=0.1)
assert cian["fields"]["ceiling_height_m"] == pytest.approx(70.0, abs=0.1)
assert "ceiling_height" not in cian["fields"] # #2699: DEPRECATED, убрана
houses = body["houses"]
assert houses["total"] == 2000

View file

@ -40,6 +40,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from tenacity import retry, stop_after_attempt, wait_exponential
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.snapshot_writer import upsert_listing_snapshot
if TYPE_CHECKING:
@ -462,7 +463,9 @@ def save_listings(
"repair_state": lot.repair_state,
"has_balcony": lot.has_balcony,
"kitchen_area_m2": lot.kitchen_area_m2,
"ceiling_height_m": lot.ceiling_height_m,
# #2699: единый гейт правдоподобия на границе записи — какой бы
# провайдер ни наполнил lot, невозможная высота в БД не попадёт.
"ceiling_height_m": plausible_ceiling_m(lot.ceiling_height_m),
"mortgage_available": lot.mortgage_available,
"is_apartments": lot.is_apartments,
"is_rosreestr_checked": lot.is_rosreestr_checked,
@ -513,7 +516,7 @@ def save_listings(
address, city, lat, lon, region_code,
rooms, area_m2, floor, total_floors, year_built,
house_type, repair_state, has_balcony,
kitchen_area_m2, ceiling_height, ceiling_height_m,
kitchen_area_m2, ceiling_height_m,
mortgage_available, is_apartments, is_rosreestr_checked,
house_source, house_ext_id, house_url, listing_segment,
newbuilding_id, newbuilding_url,
@ -534,10 +537,10 @@ def save_listings(
:address, :city, :lat, :lon, :region_code,
:rooms, :area_m2, :floor, :total_floors, :year_built,
:house_type, :repair_state, :has_balcony,
-- ceiling: один param :ceiling_height_m пишем в ОБЕ колонки
-- ceiling_height (019, читает coverage-дашборд + yandex_detail/cian_detail)
-- и ceiling_height_m (111, живая avito-колонка). См. #2007.
:kitchen_area_m2, :ceiling_height_m, :ceiling_height_m,
-- ceiling: одна колонка ceiling_height_m (#2699). До этого тот же
-- param писался ещё и в ceiling_height (019) дубль, из-за которого
-- источники разъехались по двум колонкам, а эстиматор читал одну.
:kitchen_area_m2, :ceiling_height_m,
:mortgage_available, :is_apartments, :is_rosreestr_checked,
:house_source, :house_ext_id, :house_url, :listing_segment,
:newbuilding_id, :newbuilding_url,
@ -607,9 +610,6 @@ def save_listings(
kitchen_area_m2 = COALESCE(
EXCLUDED.kitchen_area_m2, listings.kitchen_area_m2
),
ceiling_height = COALESCE(
EXCLUDED.ceiling_height, listings.ceiling_height
),
ceiling_height_m = COALESCE(
EXCLUDED.ceiling_height_m, listings.ceiling_height_m
),
@ -716,7 +716,6 @@ def save_listings(
area_m2 = COALESCE(:area_m2, area_m2),
city = COALESCE(:city, city),
kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
ceiling_height = COALESCE(:ceiling_height_m, ceiling_height),
ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),
mortgage_available = COALESCE(
:mortgage_available, mortgage_available

View file

@ -0,0 +1,46 @@
"""Единый гейт правдоподобия для высоты потолков (#2699).
Канон колонка ``listings.ceiling_height_m`` (метры). Единицы вынесены в имя,
как и во всём остальном коде проекта (``domrf_kn_flats.ceiling_height_m``,
``domrf_kn_objects.ceiling_height_m``, фронтовый ``ceiling_height_m``);
``listings.ceiling_height`` (миграция 019, numeric(3,2)) единственное
исключение, помеченное DEPRECATED в миграции 238.
Гейт был только у yandex SERP (инлайн, #2007) — остальные писатели гнали в БД
что распарсили. Прод 2026-08-07: в ``ceiling_height_m`` 26 строк > 6 м (максимум
29.90) и 84 строки < 2 м (83 из них ровно 0.00), все из avito detail. Теперь
гейт один на всех писателей.
Диапазон 2.06.0 м: снизу ниже жилой потолок не бывает (СП 54.13330 требует
от 2.5 м; 2.0 оставлено с запасом на мис-парс округлений), сверху сталинки и
лофты редко переваливают за 5 м, 6.0 покрывает двухуровневые карточки, где
продавец указывает высоту помещения целиком. Тот же диапазон уже год работает у
yandex SERP; прод-подтверждение: все 7 675 yandex-значений лежат в [2.00, 6.00].
Второй эффект гейта: ``ceiling_height`` numeric(3,2), max 9.99, и запись
out-of-range роняла бы весь батч DataError'ом (per-lot SAVEPOINT ловит только
IntegrityError). Гейт снимает и этот класс отказа.
"""
from __future__ import annotations
CEILING_MIN_M = 2.0
CEILING_MAX_M = 6.0
def plausible_ceiling_m(value: float | int | str | None) -> float | None:
"""Вернуть высоту потолка в метрах, если она физически возможна, иначе None.
Нечисловой вход (``None``, пустая строка, мусор) ``None`` без исключения:
вызывающие парсеры чужого HTML/JSON, для них «не разобралось» это норма,
а не ошибка. Сырое значение остаётся в ``raw_payload`` карточки.
"""
if value is None:
return None
try:
height = float(value)
except (TypeError, ValueError):
return None
if CEILING_MIN_M <= height <= CEILING_MAX_M:
return height
return None

View file

@ -40,6 +40,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError,
AvitoSidecarUnavailableError,
)
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.geo import is_within_oblast66_bbox
from scraper_kit.providers._base import build_document_session
from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page
@ -946,13 +947,15 @@ def _parse_float_m2(val: str) -> float | None:
def _parse_height_m(val: str) -> float | None:
"""'2.7 м' / '2,7\xa0м' / '3 м' → float метры. Junk → None."""
"""'2.7 м' / '2,7\xa0м' / '3 м' → float метры. Junk / невозможная высота → None.
#2699: гейт правдоподобия. Без него первое же число строки уходило в БД как
есть на проде 2026-08-07 в ceiling_height_m лежало 26 avito-значений > 6 м
(максимум 29.90 похоже, подхваченная площадь/этажность) и 83 ровных 0.00.
"""
m = _FLOAT_RE.search(val)
if m:
try:
return float(m.group().replace(",", "."))
except ValueError:
return None
return plausible_ceiling_m(m.group().replace(",", "."))
return None

View file

@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.providers._base import build_curl_cffi_session
@ -344,7 +345,7 @@ def save_detail_enrichment(
) -> None:
"""Persist DetailEnrichment to DB:
- UPDATE listings SET windows_view_type, ceiling_height, ... WHERE id = listing_id
- UPDATE listings SET windows_view_type, ceiling_height_m, ... WHERE id = listing_id
- INSERT INTO offer_price_history rows (if price_changes non-empty)
- INSERT INTO agents (if agent_profile) and link via agent_id_fk
- bti_data канонический houses-ряд (#2435), best-effort, только если `matcher`
@ -361,7 +362,10 @@ def save_detail_enrichment(
windows_view_type = COALESCE(:wvt, windows_view_type),
separate_wcs_count = COALESCE(:swc, separate_wcs_count),
combined_wcs_count = COALESCE(:cwc, combined_wcs_count),
ceiling_height = COALESCE(:ch, ceiling_height),
-- #2699: канон — ceiling_height_m. Раньше cian писал ТОЛЬКО в
-- ceiling_height (019), которую эстиматор не читает 855 значений
-- не видел никто.
ceiling_height_m = COALESCE(CAST(:ch AS numeric), ceiling_height_m),
repair_type = COALESCE(:rt, repair_type),
repair_state = COALESCE(:rs, repair_state),
kitchen_area_m2 = COALESCE(CAST(:ka AS double precision), kitchen_area_m2),
@ -376,7 +380,7 @@ def save_detail_enrichment(
"wvt": enrichment.windows_view_type,
"swc": enrichment.separate_wcs_count,
"cwc": enrichment.combined_wcs_count,
"ch": enrichment.ceiling_height,
"ch": plausible_ceiling_m(enrichment.ceiling_height),
"rt": enrichment.repair_type,
"rs": enrichment.repair_state,
"ka": enrichment.kitchen_area_m2,

View file

@ -26,6 +26,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.base import BaseScraper
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
from scraper_kit.yandex_helpers import (
RE_AGENCY_FOUNDED,
@ -592,9 +593,11 @@ def save_detail_enrichment(db: Session, listing_id: int, e: DetailEnrichment) ->
CAST(:kitchen_area_m2 AS numeric),
kitchen_area_m2
),
ceiling_height = COALESCE(
-- #2699: канон — ceiling_height_m (единицы в имени, её читает
-- эстиматор). ceiling_height (019) DEPRECATED, больше не пишем.
ceiling_height_m = COALESCE(
CAST(:ceiling_height AS numeric),
ceiling_height
ceiling_height_m
),
floor = COALESCE(CAST(:floor AS int), floor),
total_floors = COALESCE(CAST(:total_floors AS int), total_floors),
@ -641,7 +644,7 @@ def save_detail_enrichment(db: Session, listing_id: int, e: DetailEnrichment) ->
"area_m2": e.area_m2,
"living_area_m2": e.living_area_m2,
"kitchen_area_m2": e.kitchen_area_m2,
"ceiling_height": e.ceiling_height,
"ceiling_height": plausible_ceiling_m(e.ceiling_height),
"floor": e.floor,
"total_floors": e.total_floors,
"address": e.address,

View file

@ -49,6 +49,7 @@ from curl_cffi.requests import AsyncSession as _CurlCffiSession
from scraper_kit.base import BaseScraper, ScrapedLot
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.house_type_normalizer import normalize_house_type
from scraper_kit.price_brackets import get_price_seed_brackets
from scraper_kit.pricing import BisectionConfig, ProbeFailPolicy, ProbeResult, walk_price_range
@ -377,14 +378,11 @@ 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 — numeric(3,2), max 9.99. Yandex SERP отдаёт мусор
# (видели 18 м) → запись out-of-range уронила бы весь батч DataError'ом
# (per-lot SAVEPOINT ловит только IntegrityError). Берём только правдоподобный
# диапазон 2.06.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
)
# Yandex SERP отдаёт мусор (видели 18 м). Гейт правдоподобия теперь общий
# для всех провайдеров (#2699) — раньше был инлайн только здесь, из-за чего
# avito detail нагнал в БД 26 значений > 6 м. Сырое значение остаётся в
# raw_payload. (#2007)
ceiling_height_m = plausible_ceiling_m(ceiling_height)
building = entity.get("building") or {}
year_built_raw = building.get("builtYear")