From 869079a86f1a4b8bcd8753e2f845552449aa28da Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 13:54:59 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=81=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=8B=D1=81=D0=BE=D1=82=D1=83=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D0=BA=D0=BE=D0=B2=20=D0=BA=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B5?= =?UTF-8?q?=20ceiling=5Fheight=5Fm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод-замер 2026-08-07 (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 Расхождений НЕТ: где заполнены обе (7 675 строк), значения совпадают до последнего знака. Значит это не «две правды», а одна правда в двух ящиках: 855 циановских + 24 яндексовых значения не видел ни один потребитель. Канон — ceiling_height_m: единицы в имени (так этот признак назван везде в проекте: domrf_kn_flats/objects, фронт), её читает эстиматор, numeric(5,2) против numeric(3,2) у 019-колонки, чей потолок 9.99 роняет весь батч DataError'ом на out-of-range. Что сделано: - scraper_kit.ceiling_height.plausible_ceiling_m — единый гейт 2.0–6.0 м. Раньше гейт был инлайн только у yandex SERP, поэтому avito detail нагнал 26 значений > 6 м (максимум 29.90) и 83 ровных 0.00 в колонку, которую читает эстиматор. Корень — _parse_height_m брал первое число строки. - base.save_listings больше не пишет ceiling_height (писал один param в обе колонки — источник дубля); cian_detail и yandex_detail переведены на канон. - Миграция 238: чистка невозможных значений + перенос 879 уникальных. DROP COLUMN намеренно НЕ здесь — сначала прод должен подтвердить, что колонку никто не пишет; снос отдельным шагом. - HOUSE_FIELD_PRIORITY["ceiling_height"] удалён: колонки с таким именем в houses нет, правило не могло сработать ни разу. Тест на него зеленел, проверяя фантом. - Coverage-дашборд показывает одну строку вместо двух. Денежный эффект замерен, а не предположен: backtest (200 сделок, полный ценовой спайн, --resolve-house-id) в трёх конфигурациях — флаг OFF (прод), флаг ON на текущих данных, флаг ON на данных после этой правки. Оценка не сдвинулась НИ У ОДНОЙ сделки. Причина структурная: сигнал только переупорядочивает аналоги, а медиана ₽/м² к порядку безразлична; состав меняется лишь когда пул перевалит за 50 (медиана пула — 2, у 1 из 200). Refs #2699 --- tradein-mvp/backend/app/api/v1/admin.py | 5 +- .../services/matching/conflict_resolution.py | 10 +- .../sql/238_listings_ceiling_height_unify.sql | 86 ++++++++ .../matching/test_conflict_resolution.py | 14 +- .../tests/test_ceiling_height_unify_2699.py | 193 ++++++++++++++++++ .../backend/tests/test_scraper_admin_apis.py | 11 +- .../scraper-kit/src/scraper_kit/base.py | 19 +- .../src/scraper_kit/ceiling_height.py | 46 +++++ .../src/scraper_kit/providers/avito/detail.py | 13 +- .../src/scraper_kit/providers/cian/detail.py | 10 +- .../scraper_kit/providers/yandex/detail.py | 9 +- .../src/scraper_kit/providers/yandex/serp.py | 14 +- 12 files changed, 389 insertions(+), 41 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql create mode 100644 tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/ceiling_height.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 82c0467a..86a5539a 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -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. diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index 712ece35..89d71531 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -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"], diff --git a/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql b/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql new file mode 100644 index 00000000..b9247c2e --- /dev/null +++ b/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql @@ -0,0 +1,86 @@ +-- 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. После переноса она — надмножество: 14 824 + 879 = 15 703 против 8 554. +-- +-- ── ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ ───────────────────────────────────────────────── +-- 1. Чистит невозможные значения в ОБЕИХ колонках (вне 2.0–6.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. Перенос уникальных значений в канон ────────────────────────────────── +-- Ожидаемо 879 строк: 855 cian (писал только в 019-колонку) + 24 yandex (detail +-- записал, а SERP по этой карточке значения не дал). Пересечение переносить +-- нечего — значения там тождественны (см. замер выше), поэтому только IS NULL. +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.0–6.0 м). Читает estimator (comp-scoring #2012).'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py index 7225be49..8e5e1163 100644 --- a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py +++ b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py @@ -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}) diff --git a/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py b/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py new file mode 100644 index 00000000..2ae59653 --- /dev/null +++ b/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py @@ -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", + ] diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index 1a2db7e6..ee2fface 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index 44ddd374..e35505e0 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/ceiling_height.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/ceiling_height.py new file mode 100644 index 00000000..c642bc36 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/ceiling_height.py @@ -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.0–6.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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py index 60aaeb40..e9c3330f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py @@ -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 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 494a04de..0bea2e14 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 @@ -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, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py index cbf171a6..33348d02 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py @@ -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, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py index 3c1b9f11..10802773 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py @@ -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.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 - ) + # 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")