feat(tradein/yandex): normalize house_type + promote kitchen/ceiling to columns (#2007)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 53s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / build-backend (push) Successful in 54s
All checks were successful
Deploy Trade-In / deploy (push) Successful in 53s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m30s
Deploy Trade-In / build-backend (push) Successful in 54s
house_type normalized via shared house_type_normalizer (canon = _IMV_HOUSE_TYPE_MAP) — fixes estimator soft-penalty false-mismatch on ~70% yandex analogs; backfill migration 140 (SCREAMING->canon + 'other'->NULL). kitchen/ceiling promoted to columns (dashboard/matching, not valuation — see #2012); ceiling capped 2.0-6.0m to avoid numeric(3,2) overflow. Refs #2007
This commit is contained in:
parent
50684ce87e
commit
440bb2ca51
7 changed files with 358 additions and 5 deletions
|
|
@ -88,6 +88,12 @@ class ScrapedLot(BaseModel):
|
||||||
has_balcony: bool | None = None
|
has_balcony: bool | None = None
|
||||||
kadastr_num: str | None = None
|
kadastr_num: str | None = None
|
||||||
|
|
||||||
|
# Detail-поля кухни/потолка (#2007). Обычно из detail-страницы (avito_detail,
|
||||||
|
# cian_detail, yandex_detail), но yandex SERP отдаёт их прямо в карточке —
|
||||||
|
# промоутим из raw_payload в колонки для дашборда покрытия и matching.
|
||||||
|
kitchen_area_m2: float | None = None
|
||||||
|
ceiling_height_m: float | None = None
|
||||||
|
|
||||||
# Avito house linking (Stage 2a — search → houses)
|
# Avito house linking (Stage 2a — search → houses)
|
||||||
house_source: str | None = None # 'avito' / 'cian'
|
house_source: str | None = None # 'avito' / 'cian'
|
||||||
house_ext_id: str | None = None # Avito's '3171365'
|
house_ext_id: str | None = None # Avito's '3171365'
|
||||||
|
|
@ -339,6 +345,8 @@ def save_listings(
|
||||||
"house_type": lot.house_type,
|
"house_type": lot.house_type,
|
||||||
"repair_state": lot.repair_state,
|
"repair_state": lot.repair_state,
|
||||||
"has_balcony": lot.has_balcony,
|
"has_balcony": lot.has_balcony,
|
||||||
|
"kitchen_area_m2": lot.kitchen_area_m2,
|
||||||
|
"ceiling_height_m": lot.ceiling_height_m,
|
||||||
"house_source": lot.house_source,
|
"house_source": lot.house_source,
|
||||||
"house_ext_id": lot.house_ext_id,
|
"house_ext_id": lot.house_ext_id,
|
||||||
"house_url": lot.house_url,
|
"house_url": lot.house_url,
|
||||||
|
|
@ -385,6 +393,7 @@ def save_listings(
|
||||||
address, lat, lon, region_code,
|
address, lat, lon, region_code,
|
||||||
rooms, area_m2, floor, total_floors, year_built,
|
rooms, area_m2, floor, total_floors, year_built,
|
||||||
house_type, repair_state, has_balcony,
|
house_type, repair_state, has_balcony,
|
||||||
|
kitchen_area_m2, ceiling_height, ceiling_height_m,
|
||||||
house_source, house_ext_id, house_url, listing_segment,
|
house_source, house_ext_id, house_url, listing_segment,
|
||||||
newbuilding_id, newbuilding_url,
|
newbuilding_id, newbuilding_url,
|
||||||
price_rub, price_per_m2,
|
price_rub, price_per_m2,
|
||||||
|
|
@ -404,6 +413,10 @@ def save_listings(
|
||||||
:address, :lat, :lon, 66,
|
:address, :lat, :lon, 66,
|
||||||
:rooms, :area_m2, :floor, :total_floors, :year_built,
|
:rooms, :area_m2, :floor, :total_floors, :year_built,
|
||||||
:house_type, :repair_state, :has_balcony,
|
: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,
|
||||||
:house_source, :house_ext_id, :house_url, :listing_segment,
|
:house_source, :house_ext_id, :house_url, :listing_segment,
|
||||||
:newbuilding_id, :newbuilding_url,
|
:newbuilding_id, :newbuilding_url,
|
||||||
:price_rub, :ppm2,
|
:price_rub, :ppm2,
|
||||||
|
|
@ -449,6 +462,18 @@ def save_listings(
|
||||||
metro_stations = EXCLUDED.metro_stations,
|
metro_stations = EXCLUDED.metro_stations,
|
||||||
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
|
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
|
||||||
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
|
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
|
||||||
|
-- kitchen/ceiling (#2007): COALESCE — SERP re-scrape источника без
|
||||||
|
-- этих полей (avito SERP → NULL) НЕ затирает detail-enriched значение
|
||||||
|
-- (avito_detail пишет ceiling_height_m отдельным UPDATE).
|
||||||
|
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
|
||||||
|
),
|
||||||
-- Yandex rich fields (+ shared description/agency/publish_date):
|
-- Yandex rich fields (+ shared description/agency/publish_date):
|
||||||
-- COALESCE so a source that does not provide them never wipes
|
-- COALESCE so a source that does not provide them never wipes
|
||||||
-- a value previously written by another source / scrape.
|
-- a value previously written by another source / scrape.
|
||||||
|
|
@ -533,6 +558,9 @@ def save_listings(
|
||||||
metro_stations = CAST(:metro_stations AS jsonb),
|
metro_stations = CAST(:metro_stations AS jsonb),
|
||||||
listing_date = COALESCE(:listing_date, listing_date),
|
listing_date = COALESCE(:listing_date, listing_date),
|
||||||
area_m2 = COALESCE(:area_m2, area_m2),
|
area_m2 = COALESCE(:area_m2, area_m2),
|
||||||
|
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),
|
||||||
publish_date = COALESCE(:publish_date, publish_date),
|
publish_date = COALESCE(:publish_date, publish_date),
|
||||||
days_on_market = COALESCE(:days_on_market, days_on_market),
|
days_on_market = COALESCE(:days_on_market, days_on_market),
|
||||||
description = COALESCE(:description, description),
|
description = COALESCE(:description, description),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Нормализация house_type → каноничный enum для listings.house_type.
|
||||||
|
|
||||||
|
Целевой канон (сверено с estimator._IMV_HOUSE_TYPE_MAP, estimator.py:146):
|
||||||
|
panel / brick / monolith / monolith_brick / block / wood.
|
||||||
|
|
||||||
|
Зачем (#2007): estimator применяет soft-penalty по house_type — аналог с
|
||||||
|
house_type != target штрафуется. Yandex SERP отдаёт SCREAMING-значения
|
||||||
|
(MONOLIT / BRICK / PANEL / ...), которые НИКОГДА не равны каноничным
|
||||||
|
(monolith / brick / panel / ...) → ~70% yandex-аналогов получали ложный штраф
|
||||||
|
и фактически выпадали из скоринга. Нормализация на ингесте чинит это.
|
||||||
|
|
||||||
|
Важно про None: неизвестное / 'other' / '' → None, НЕ 'other'. В estimator
|
||||||
|
`house_type IS NULL` нейтрально (без штрафа), а любое не-канон значение всегда
|
||||||
|
!= target → ложный штраф. Поэтому unknown лучше схлопнуть в NULL.
|
||||||
|
|
||||||
|
Источники raw-значений:
|
||||||
|
- yandex SERP: building.buildingType — SCREAMING_SNAKE (MONOLIT, MONOLIT_BRICK, ...)
|
||||||
|
- cian SERP: building.materialType — camelCase (monolith, monolithBrick,
|
||||||
|
gasSilicateBlock, ...) — переиспользуется в #2008 (cian.py:815).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Канон listings.house_type — выровнен по estimator._IMV_HOUSE_TYPE_MAP.
|
||||||
|
_CANON: frozenset[str] = frozenset(
|
||||||
|
{"panel", "brick", "monolith", "monolith_brick", "block", "wood"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# raw-токен → канон. Ключи — точные значения вокабуляров источников; сравнение
|
||||||
|
# регистронезависимое (см. _LOOKUP ниже), но строго по полному токену енума,
|
||||||
|
# без fuzzy-матчинга. Неизвестные токены сюда НЕ попадают → normalize вернёт None.
|
||||||
|
_RAW_TO_CANON: dict[str, str] = {
|
||||||
|
# ── yandex SERP (SCREAMING_SNAKE buildingType) ───────────────────────────
|
||||||
|
"MONOLIT": "monolith",
|
||||||
|
"BRICK": "brick",
|
||||||
|
"PANEL": "panel",
|
||||||
|
"MONOLIT_BRICK": "monolith_brick",
|
||||||
|
"BLOCK": "block",
|
||||||
|
"WOOD": "wood",
|
||||||
|
# ── cian SERP (camelCase materialType) — reuse в #2008 ───────────────────
|
||||||
|
"monolith": "monolith",
|
||||||
|
"brick": "brick",
|
||||||
|
"panel": "panel",
|
||||||
|
"block": "block",
|
||||||
|
"wood": "wood",
|
||||||
|
"monolithBrick": "monolith_brick",
|
||||||
|
"gasSilicateBlock": "block",
|
||||||
|
"aerocreteBlock": "block",
|
||||||
|
"foamConcreteBlock": "block",
|
||||||
|
"stalin": "brick", # «сталинка» — кирпич
|
||||||
|
}
|
||||||
|
|
||||||
|
# Регистронезависимый lookup. Лоуэркейс-ключи не коллизят между вокабулярами:
|
||||||
|
# 'MONOLIT'→'monolit' и 'monolith'→'monolith' — разные ключи.
|
||||||
|
_LOOKUP: dict[str, str] = {k.lower(): v for k, v in _RAW_TO_CANON.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_house_type(raw: str | None) -> str | None:
|
||||||
|
"""Преобразовать raw house_type в каноничный enum.
|
||||||
|
|
||||||
|
Уже-каноничное значение возвращается as-is (идемпотентность — нужна при
|
||||||
|
ре-обработке и для backfill-миграции). Неизвестное / 'other' / пустое /
|
||||||
|
None → None (НЕ 'other': см. docstring модуля про estimator soft-penalty).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: сырое значение из парсера (e.g. «MONOLIT», «monolithBrick») или None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Одно из panel / brick / monolith / monolith_brick / block / wood, либо None.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
# Pass-through: уже каноничное значение (idempotency — в т.ч. 'monolith_brick',
|
||||||
|
# которого нет среди raw-ключей карты).
|
||||||
|
if stripped in _CANON:
|
||||||
|
return stripped
|
||||||
|
# Регистронезависимый exact-token lookup по обоим вокабулярам.
|
||||||
|
canon = _LOOKUP.get(stripped) or _LOOKUP.get(stripped.lower())
|
||||||
|
if canon is None:
|
||||||
|
# Неизвестное / 'other' — нормально (NULL нейтрально для estimator).
|
||||||
|
# debug, не warning: 'other' встречается массово, warning засорил бы лог.
|
||||||
|
logger.debug("house_type_normalizer: unmapped raw value %r — stored as NULL", raw)
|
||||||
|
return canon
|
||||||
|
|
@ -41,6 +41,7 @@ from curl_cffi.requests import AsyncSession as _CurlCffiSession
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
from app.services.scrapers.house_type_normalizer import normalize_house_type
|
||||||
from app.services.scrapers.price_brackets import get_price_seed_brackets
|
from app.services.scrapers.price_brackets import get_price_seed_brackets
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -240,13 +241,13 @@ def _parse_gate_json(
|
||||||
price.valuePerPart -> price_per_m2
|
price.valuePerPart -> price_per_m2
|
||||||
area.value -> area_m2
|
area.value -> area_m2
|
||||||
livingSpace.value -> living_area_m2
|
livingSpace.value -> living_area_m2
|
||||||
kitchenSpace.value -> raw_payload["kitchen_area_m2"]
|
kitchenSpace.value -> kitchen_area_m2 (+ raw_payload["kitchen_area_m2"])
|
||||||
roomsTotal (int | None) -> rooms (None -> 0 for studio)
|
roomsTotal (int | None) -> rooms (None -> 0 for studio)
|
||||||
floorsOffered[0] -> floor
|
floorsOffered[0] -> floor
|
||||||
floorsTotal -> total_floors
|
floorsTotal -> total_floors
|
||||||
building.builtYear -> year_built
|
building.builtYear -> year_built
|
||||||
building.buildingType -> house_type (raw: "MONOLIT"/"BRICK"/etc.)
|
building.buildingType -> house_type (normalize_house_type: MONOLIT->monolith)
|
||||||
ceilingHeight -> raw_payload["ceiling_height"]
|
ceilingHeight -> ceiling_height_m (+ raw_payload["ceiling_height"])
|
||||||
location.point.latitude/longitude -> lat / lon
|
location.point.latitude/longitude -> lat / lon
|
||||||
location.geocoderAddress -> address (full, with house number)
|
location.geocoderAddress -> address (full, with house number)
|
||||||
mainImages[] -> photo_urls (prepend "https:", up to 5)
|
mainImages[] -> photo_urls (prepend "https:", up to 5)
|
||||||
|
|
@ -307,6 +308,7 @@ def _entity_to_lot(
|
||||||
|
|
||||||
kitchen_dict = entity.get("kitchenSpace") or {}
|
kitchen_dict = entity.get("kitchenSpace") or {}
|
||||||
kitchen_area_raw = kitchen_dict.get("value")
|
kitchen_area_raw = kitchen_dict.get("value")
|
||||||
|
kitchen_area_m2 = float(kitchen_area_raw) if kitchen_area_raw is not None else None
|
||||||
|
|
||||||
rooms_raw = entity.get("roomsTotal")
|
rooms_raw = entity.get("roomsTotal")
|
||||||
if rooms_raw is None:
|
if rooms_raw is None:
|
||||||
|
|
@ -323,11 +325,22 @@ def _entity_to_lot(
|
||||||
total_floors = int(floors_total_raw) if floors_total_raw is not None else None
|
total_floors = int(floors_total_raw) if floors_total_raw is not None else None
|
||||||
|
|
||||||
ceiling_height = entity.get("ceilingHeight")
|
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
|
||||||
|
)
|
||||||
|
|
||||||
building = entity.get("building") or {}
|
building = entity.get("building") or {}
|
||||||
year_built_raw = building.get("builtYear")
|
year_built_raw = building.get("builtYear")
|
||||||
year_built = int(year_built_raw) if year_built_raw is not None else None
|
year_built = int(year_built_raw) if year_built_raw is not None else None
|
||||||
house_type = building.get("buildingType")
|
# buildingType — SCREAMING (MONOLIT/BRICK/...). Нормализуем в канон, иначе
|
||||||
|
# estimator soft-penalty штрафует yandex-аналоги (#2007).
|
||||||
|
raw_building_type = building.get("buildingType")
|
||||||
|
house_type = normalize_house_type(raw_building_type)
|
||||||
|
|
||||||
site_id = building.get("siteId")
|
site_id = building.get("siteId")
|
||||||
site_name = building.get("siteName")
|
site_name = building.get("siteName")
|
||||||
|
|
@ -381,6 +394,8 @@ def _entity_to_lot(
|
||||||
total_floors=total_floors,
|
total_floors=total_floors,
|
||||||
year_built=year_built,
|
year_built=year_built,
|
||||||
house_type=house_type,
|
house_type=house_type,
|
||||||
|
kitchen_area_m2=kitchen_area_m2,
|
||||||
|
ceiling_height_m=ceiling_height_m,
|
||||||
price_rub=price_rub,
|
price_rub=price_rub,
|
||||||
price_per_m2=price_per_m2,
|
price_per_m2=price_per_m2,
|
||||||
photo_urls=photo_urls,
|
photo_urls=photo_urls,
|
||||||
|
|
@ -406,6 +421,7 @@ def _entity_to_lot(
|
||||||
"page_param": page_param,
|
"page_param": page_param,
|
||||||
"ceiling_height": ceiling_height,
|
"ceiling_height": ceiling_height,
|
||||||
"kitchen_area_m2": kitchen_area_raw,
|
"kitchen_area_m2": kitchen_area_raw,
|
||||||
|
"raw_building_type": raw_building_type, # сырой SCREAMING — для отладки
|
||||||
"site_name": site_name,
|
"site_name": site_name,
|
||||||
"offer_id": offer_id,
|
"offer_id": offer_id,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
-- 140_yandex_house_type_backfill.sql
|
||||||
|
-- Purpose: нормализовать УЖЕ записанные yandex listings.house_type из SCREAMING
|
||||||
|
-- вокабуляра (MONOLIT/BRICK/PANEL/...) в канон (monolith/brick/panel/...).
|
||||||
|
--
|
||||||
|
-- Rationale (#2007):
|
||||||
|
-- Yandex SERP писал building.buildingType как есть — SCREAMING_SNAKE. Estimator
|
||||||
|
-- применяет soft-penalty по house_type: аналог с house_type != target штрафуется.
|
||||||
|
-- 'MONOLIT' никогда не равен каноничному 'monolith' → ~70% yandex-аналогов
|
||||||
|
-- получали ложный штраф. Код-фикс (normalize_house_type на ингесте) чинит НОВЫЕ
|
||||||
|
-- строки; этот файл подтягивает СТАРЫЕ под тот же канон.
|
||||||
|
--
|
||||||
|
-- Маппинг тождественен house_type_normalizer._RAW_TO_CANON (yandex-ветка):
|
||||||
|
-- MONOLIT->monolith, BRICK->brick, PANEL->panel, MONOLIT_BRICK->monolith_brick,
|
||||||
|
-- BLOCK->block, WOOD->wood.
|
||||||
|
--
|
||||||
|
-- Плюс '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;
|
||||||
|
|
||||||
|
UPDATE listings
|
||||||
|
SET house_type = CASE house_type
|
||||||
|
WHEN 'MONOLIT' THEN 'monolith'
|
||||||
|
WHEN 'BRICK' THEN 'brick'
|
||||||
|
WHEN 'PANEL' THEN 'panel'
|
||||||
|
WHEN 'MONOLIT_BRICK' THEN 'monolith_brick'
|
||||||
|
WHEN 'BLOCK' THEN 'block'
|
||||||
|
WHEN 'WOOD' THEN 'wood'
|
||||||
|
ELSE house_type
|
||||||
|
END
|
||||||
|
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;
|
||||||
|
|
@ -250,6 +250,55 @@ def test_save_listings_on_conflict_updates_cian_cols():
|
||||||
assert params["description_minhash"] == "newhash"
|
assert params["description_minhash"] == "newhash"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test 3b: kitchen/ceiling promote (#2007) ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_promotes_kitchen_ceiling_to_columns():
|
||||||
|
"""yandex lot с kitchen_area_m2/ceiling_height_m → params + обе ceiling-колонки.
|
||||||
|
|
||||||
|
ceiling пишется ОДНИМ param :ceiling_height_m в ДВЕ колонки: ceiling_height
|
||||||
|
(019, coverage-дашборд + yandex_detail) и ceiling_height_m (111, avito-колонка).
|
||||||
|
"""
|
||||||
|
db = _mock_db(inserted=True)
|
||||||
|
lot = _base_lot(
|
||||||
|
source="yandex",
|
||||||
|
source_id=None,
|
||||||
|
source_url="https://realty.yandex.ru/offer/123/",
|
||||||
|
kitchen_area_m2=12.7,
|
||||||
|
ceiling_height_m=2.7,
|
||||||
|
)
|
||||||
|
|
||||||
|
save_listings(db, [lot])
|
||||||
|
|
||||||
|
params = _get_execute_params(db)
|
||||||
|
assert params["kitchen_area_m2"] == 12.7
|
||||||
|
assert params["ceiling_height_m"] == 2.7
|
||||||
|
|
||||||
|
sql_text = _get_listings_insert_sql(db)
|
||||||
|
# обе колонки в списке INSERT
|
||||||
|
assert "kitchen_area_m2" in sql_text
|
||||||
|
assert "ceiling_height" in sql_text
|
||||||
|
assert "ceiling_height_m" in sql_text
|
||||||
|
# один param :ceiling_height_m питает обе ceiling-колонки (дважды в VALUES)
|
||||||
|
assert sql_text.count(":ceiling_height_m") >= 2
|
||||||
|
# ON CONFLICT — COALESCE, чтобы re-scrape без полей не затирал detail-enrichment
|
||||||
|
assert "kitchen_area_m2 = COALESCE(" in sql_text
|
||||||
|
assert "ceiling_height = COALESCE(" in sql_text
|
||||||
|
assert "ceiling_height_m = COALESCE(" in sql_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_kitchen_ceiling_none_for_serp_without_them():
|
||||||
|
"""Avito SERP lot без kitchen/ceiling → params None (COALESCE сохранит detail-значение)."""
|
||||||
|
db = _mock_db(inserted=True)
|
||||||
|
lot = _base_lot(source="avito", source_id="987654")
|
||||||
|
|
||||||
|
save_listings(db, [lot])
|
||||||
|
|
||||||
|
params = _get_execute_params(db)
|
||||||
|
assert params["kitchen_area_m2"] is None
|
||||||
|
assert params["ceiling_height_m"] is None
|
||||||
|
|
||||||
|
|
||||||
# ── Test 4: empty list → early return, no db.execute call ───────────────────
|
# ── Test 4: empty list → early return, no db.execute call ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
108
tradein-mvp/backend/tests/test_house_type_normalizer.py
Normal file
108
tradein-mvp/backend/tests/test_house_type_normalizer.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Unit-тесты для house_type_normalizer (#2007).
|
||||||
|
|
||||||
|
Покрывают:
|
||||||
|
- normalize_house_type(): yandex SCREAMING + cian camelCase вокабуляры → канон,
|
||||||
|
- идемпотентность (normalize(normalize(x)) == normalize(x)),
|
||||||
|
- None / '' / whitespace / 'other' / unknown → None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.scrapers.house_type_normalizer import (
|
||||||
|
_CANON,
|
||||||
|
normalize_house_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── yandex SERP (SCREAMING_SNAKE buildingType) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("MONOLIT", "monolith"),
|
||||||
|
("BRICK", "brick"),
|
||||||
|
("PANEL", "panel"),
|
||||||
|
("MONOLIT_BRICK", "monolith_brick"),
|
||||||
|
("BLOCK", "block"),
|
||||||
|
("WOOD", "wood"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_yandex_screaming_vocab(raw: str, expected: str) -> None:
|
||||||
|
assert normalize_house_type(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── cian SERP (camelCase materialType) — reuse #2008 ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("monolith", "monolith"),
|
||||||
|
("brick", "brick"),
|
||||||
|
("panel", "panel"),
|
||||||
|
("block", "block"),
|
||||||
|
("wood", "wood"),
|
||||||
|
("monolithBrick", "monolith_brick"),
|
||||||
|
("gasSilicateBlock", "block"),
|
||||||
|
("aerocreteBlock", "block"),
|
||||||
|
("foamConcreteBlock", "block"),
|
||||||
|
("stalin", "brick"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cian_camelcase_vocab(raw: str, expected: str) -> None:
|
||||||
|
assert normalize_house_type(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── регистронезависимость ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("monolit", "monolith"), # lower yandex-token
|
||||||
|
("Brick", "brick"),
|
||||||
|
("MONOLITHBRICK", "monolith_brick"),
|
||||||
|
(" PANEL ", "panel"), # окружающие пробелы
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_case_insensitive_and_strip(raw: str, expected: str) -> None:
|
||||||
|
assert normalize_house_type(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── идемпотентность ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("canon", sorted(_CANON))
|
||||||
|
def test_canonical_passthrough(canon: str) -> None:
|
||||||
|
"""Уже каноничное значение возвращается as-is (в т.ч. monolith_brick)."""
|
||||||
|
assert normalize_house_type(canon) == canon
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
"MONOLIT",
|
||||||
|
"MONOLIT_BRICK",
|
||||||
|
"monolithBrick",
|
||||||
|
"gasSilicateBlock",
|
||||||
|
"stalin",
|
||||||
|
"monolith_brick",
|
||||||
|
"panel",
|
||||||
|
"other",
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_idempotency(raw: str | None) -> None:
|
||||||
|
"""normalize(normalize(x)) == normalize(x) для любого входа."""
|
||||||
|
once = normalize_house_type(raw)
|
||||||
|
assert normalize_house_type(once) == once
|
||||||
|
|
||||||
|
|
||||||
|
# ── None / unknown → None ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", [None, "", " ", "other", "OTHER", "unknown", " keramzit "])
|
||||||
|
def test_unknown_returns_none(raw: str | None) -> None:
|
||||||
|
"""Неизвестное / 'other' / пустое → None (НЕ 'other'): NULL нейтрально для estimator."""
|
||||||
|
assert normalize_house_type(raw) is None
|
||||||
|
|
@ -222,7 +222,9 @@ def test_entity_to_lot_full_entity():
|
||||||
assert lot.floor == 8
|
assert lot.floor == 8
|
||||||
assert lot.total_floors == 25
|
assert lot.total_floors == 25
|
||||||
assert lot.year_built == 2016
|
assert lot.year_built == 2016
|
||||||
assert lot.house_type == "MONOLIT"
|
assert lot.house_type == "monolith" # normalize_house_type(MONOLIT) — #2007
|
||||||
|
assert lot.kitchen_area_m2 == pytest.approx(12.7) # promoted to column — #2007
|
||||||
|
assert lot.ceiling_height_m == pytest.approx(2.7) # promoted to column — #2007
|
||||||
assert lot.lat == pytest.approx(56.79512)
|
assert lot.lat == pytest.approx(56.79512)
|
||||||
assert lot.lon == pytest.approx(60.49186)
|
assert lot.lon == pytest.approx(60.49186)
|
||||||
assert lot.address == "Ekaterinburg, ulitsa Evgenia Savkova, 8"
|
assert lot.address == "Ekaterinburg, ulitsa Evgenia Savkova, 8"
|
||||||
|
|
@ -234,6 +236,20 @@ def test_entity_to_lot_full_entity():
|
||||||
assert lot.raw_payload is not None
|
assert lot.raw_payload is not None
|
||||||
assert lot.raw_payload["ceiling_height"] == pytest.approx(2.7)
|
assert lot.raw_payload["ceiling_height"] == pytest.approx(2.7)
|
||||||
assert lot.raw_payload["kitchen_area_m2"] == pytest.approx(12.7)
|
assert lot.raw_payload["kitchen_area_m2"] == pytest.approx(12.7)
|
||||||
|
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():
|
def test_entity_to_lot_segment_vtorichka_default():
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue