fix(tradein): свести высоту потолков к одной колонке ceiling_height_m #2779
12 changed files with 393 additions and 41 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.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. Перенос уникальных значений в канон ──────────────────────────────────
|
||||
-- Ожидаемо 877 строк: 855 cian (писал только в 019-колонку) − 2 мусорных,
|
||||
-- отсеянных шагом 1, + 24 yandex (detail записал, а SERP по этой карточке
|
||||
-- значения не дал). Пересечение переносить нечего — значения там тождественны
|
||||
-- (см. замер выше), поэтому только IS NULL.
|
||||
-- Итог после миграции (симуляция на проде 2026-08-07): ceiling_height_m = 15 591
|
||||
-- непустых, из них 0 вне диапазона 2.0–6.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.0–6.0 м). Читает estimator (comp-scoring #2012).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
193
tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py
Normal file
193
tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue