fix(tradein/houses): персистить house_class из avito «Класс жилья» и yandex parse (#2003) (#2359)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 1m31s
Deploy Trade-In / build-backend (push) Successful in 1m23s
Deploy Trade-In / deploy (push) Successful in 53s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
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 1m31s
Deploy Trade-In / build-backend (push) Successful in 1m23s
Deploy Trade-In / deploy (push) Successful in 53s
This commit is contained in:
parent
6468bd6b37
commit
91c8365d91
6 changed files with 286 additions and 1 deletions
|
|
@ -86,6 +86,10 @@ EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = {
|
||||||
# вернёт None (хранится только в raw_characteristics).
|
# вернёт None (хранится только в raw_characteristics).
|
||||||
"Пассажирских лифтов": ("passenger_elevators", "int"),
|
"Пассажирских лифтов": ("passenger_elevators", "int"),
|
||||||
"Грузовых лифтов": ("cargo_elevators", "int"),
|
"Грузовых лифтов": ("cargo_elevators", "int"),
|
||||||
|
# Класс жилья (#2003) — ТОЛЬКО developmentPage (новостройки); housePage
|
||||||
|
# (вторичка) этот параметр не отдаёт. Сырое значение "Комфорт"/"Бизнес"/... →
|
||||||
|
# каноничный enum houses.house_class через _normalize_house_class.
|
||||||
|
"Класс жилья": ("house_class", "house_class"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# URL-маркер новостройки: /catalog/novostroyki/... (vs вторичка /catalog/houses/...).
|
# URL-маркер новостройки: /catalog/novostroyki/... (vs вторичка /catalog/houses/...).
|
||||||
|
|
@ -104,6 +108,21 @@ HOUSE_TYPE_MAP: dict[str, str] = {
|
||||||
"деревянный": "wood",
|
"деревянный": "wood",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Нормализация класса жилья из Avito "Класс жилья" в canonical houses.house_class
|
||||||
|
# (#2003). Avito сам отдаёт только 4 значения (Комфорт/Бизнес/Премиум/Эконом —
|
||||||
|
# live-подтверждено), но карта дополнена "Комфорт+"/"Элит" для согласованности с
|
||||||
|
# вокабуляром yandex_helpers.parse_house_class (тот же столбец пишут оба источника).
|
||||||
|
HOUSE_CLASS_MAP: dict[str, str] = {
|
||||||
|
"эконом": "economy",
|
||||||
|
"комфорт": "comfort",
|
||||||
|
"комфорт+": "comfort_plus",
|
||||||
|
"комфорт плюс": "comfort_plus",
|
||||||
|
"бизнес": "business",
|
||||||
|
"премиум": "premium",
|
||||||
|
"элит": "elite",
|
||||||
|
"элитный": "elite",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dataclasses
|
# Dataclasses
|
||||||
|
|
@ -122,6 +141,7 @@ class HouseInfo:
|
||||||
year_built: int | None = None
|
year_built: int | None = None
|
||||||
total_floors: int | None = None
|
total_floors: int | None = None
|
||||||
house_type: str | None = None # нормализован: monolith/panel/brick/...
|
house_type: str | None = None # нормализован: monolith/panel/brick/...
|
||||||
|
house_class: str | None = None # нормализован: comfort/business/premium/economy/... (#2003)
|
||||||
material_floors: str | None = None
|
material_floors: str | None = None
|
||||||
hot_water: str | None = None
|
hot_water: str | None = None
|
||||||
passenger_elevators: int | None = None
|
passenger_elevators: int | None = None
|
||||||
|
|
@ -244,6 +264,18 @@ def _normalize_house_type(raw: str | None) -> str | None:
|
||||||
return HOUSE_TYPE_MAP.get(raw.lower(), "other")
|
return HOUSE_TYPE_MAP.get(raw.lower(), "other")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_house_class(raw: str | None) -> str | None:
|
||||||
|
"""Нормализует класс жилья: "Комфорт" → "comfort" (#2003).
|
||||||
|
|
||||||
|
В отличие от _normalize_house_type, неизвестное значение → None (НЕ "other"):
|
||||||
|
house_class используется как точный фильтр (`= ANY(...)` в search_query.py) —
|
||||||
|
мусорный bucket-токен там бесполезен и только засоряет список значений.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return HOUSE_CLASS_MAP.get(raw.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
def _cast_expand_param(value: str, cast_type: str) -> Any:
|
def _cast_expand_param(value: str, cast_type: str) -> Any:
|
||||||
"""Кастует строковое значение expandParams к нужному типу."""
|
"""Кастует строковое значение expandParams к нужному типу."""
|
||||||
if cast_type == "int":
|
if cast_type == "int":
|
||||||
|
|
@ -255,6 +287,8 @@ def _cast_expand_param(value: str, cast_type: str) -> Any:
|
||||||
return value.strip().lower() == "да"
|
return value.strip().lower() == "да"
|
||||||
if cast_type == "house_type":
|
if cast_type == "house_type":
|
||||||
return _normalize_house_type(value)
|
return _normalize_house_type(value)
|
||||||
|
if cast_type == "house_class":
|
||||||
|
return _normalize_house_class(value)
|
||||||
# str
|
# str
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -374,6 +408,7 @@ def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
|
||||||
year_built=parsed_params.get("year_built"),
|
year_built=parsed_params.get("year_built"),
|
||||||
total_floors=parsed_params.get("total_floors"),
|
total_floors=parsed_params.get("total_floors"),
|
||||||
house_type=parsed_params.get("house_type"),
|
house_type=parsed_params.get("house_type"),
|
||||||
|
house_class=parsed_params.get("house_class"),
|
||||||
material_floors=parsed_params.get("material_floors"),
|
material_floors=parsed_params.get("material_floors"),
|
||||||
hot_water=parsed_params.get("hot_water"),
|
hot_water=parsed_params.get("hot_water"),
|
||||||
passenger_elevators=parsed_params.get("passenger_elevators"),
|
passenger_elevators=parsed_params.get("passenger_elevators"),
|
||||||
|
|
@ -447,6 +482,7 @@ def _parse_development_page(widget: dict[str, Any]) -> HouseInfo:
|
||||||
year_built=parsed_params.get("year_built"), # обычно None — дом строится
|
year_built=parsed_params.get("year_built"), # обычно None — дом строится
|
||||||
total_floors=parsed_params.get("total_floors"),
|
total_floors=parsed_params.get("total_floors"),
|
||||||
house_type=parsed_params.get("house_type"),
|
house_type=parsed_params.get("house_type"),
|
||||||
|
house_class=parsed_params.get("house_class"),
|
||||||
material_floors=parsed_params.get("material_floors"),
|
material_floors=parsed_params.get("material_floors"),
|
||||||
hot_water=parsed_params.get("hot_water"),
|
hot_water=parsed_params.get("hot_water"),
|
||||||
passenger_elevators=parsed_params.get("passenger_elevators"),
|
passenger_elevators=parsed_params.get("passenger_elevators"),
|
||||||
|
|
@ -958,6 +994,9 @@ def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int | None:
|
||||||
avito_id_hash) перезаписываются напрямую.
|
avito_id_hash) перезаписываются напрямую.
|
||||||
- address/lat/lon/year_built — через COALESCE чтобы не затирать данные
|
- address/lat/lon/year_built — через COALESCE чтобы не затирать данные
|
||||||
канонического дома от cian/кадастра.
|
канонического дома от cian/кадастра.
|
||||||
|
- house_class (#2003) — COALESCE(new, existing): свежее avito-значение
|
||||||
|
(топ-приоритет источник) побеждает, но None не затирает значение от
|
||||||
|
более низкоприоритетного источника (см. HOUSE_FIELD_PRIORITY).
|
||||||
3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment
|
3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment
|
||||||
(advisory lock xact-scoped, должен держаться до commit).
|
(advisory lock xact-scoped, должен держаться до commit).
|
||||||
|
|
||||||
|
|
@ -995,6 +1034,12 @@ def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int | None:
|
||||||
full_address = CAST(:full_address AS text),
|
full_address = CAST(:full_address AS text),
|
||||||
avito_id_hash = CAST(:avito_id_hash AS text),
|
avito_id_hash = CAST(:avito_id_hash AS text),
|
||||||
house_type = CAST(:house_type AS text),
|
house_type = CAST(:house_type AS text),
|
||||||
|
-- house_class (#2003): avito_houses_catalog топ-приоритет источник
|
||||||
|
-- (conflict_resolution.HOUSE_FIELD_PRIORITY) — свежее avito-значение
|
||||||
|
-- всегда побеждает, но None (вторичка не отдаёт "Класс жилья") не
|
||||||
|
-- затирает значение, уже проставленное более низкоприоритетным
|
||||||
|
-- источником (напр. yandex_realty_nb).
|
||||||
|
house_class = COALESCE(CAST(:house_class AS text), houses.house_class),
|
||||||
total_floors = CAST(:total_floors AS int),
|
total_floors = CAST(:total_floors AS int),
|
||||||
passenger_elevators = CAST(:passenger_elevators AS int),
|
passenger_elevators = CAST(:passenger_elevators AS int),
|
||||||
cargo_elevators = CAST(:cargo_elevators AS int),
|
cargo_elevators = CAST(:cargo_elevators AS int),
|
||||||
|
|
@ -1030,6 +1075,7 @@ def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int | None:
|
||||||
"full_address": h.full_address,
|
"full_address": h.full_address,
|
||||||
"avito_id_hash": h.ext_id_hash,
|
"avito_id_hash": h.ext_id_hash,
|
||||||
"house_type": h.house_type,
|
"house_type": h.house_type,
|
||||||
|
"house_class": h.house_class,
|
||||||
"total_floors": h.total_floors,
|
"total_floors": h.total_floors,
|
||||||
"passenger_elevators": h.passenger_elevators,
|
"passenger_elevators": h.passenger_elevators,
|
||||||
"cargo_elevators": h.cargo_elevators,
|
"cargo_elevators": h.cargo_elevators,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ Context
|
||||||
Через BrowserFetcher (tradein-browser camoufox) — единственный рабочий путь.
|
Через BrowserFetcher (tradein-browser camoufox) — единственный рабочий путь.
|
||||||
3. UPSERT в market.yandex_jk_enrichment (ON CONFLICT (ext_id) DO UPDATE).
|
3. UPSERT в market.yandex_jk_enrichment (ON CONFLICT (ext_id) DO UPDATE).
|
||||||
4. UPDATE houses.yandex_jk_id WHERE yandex_jk_slug = slug.
|
4. UPDATE houses.yandex_jk_id WHERE yandex_jk_slug = slug.
|
||||||
|
5. Если info.house_class распарсен — UPDATE houses.house_class (#2003, fill-only:
|
||||||
|
COALESCE не затирает уже проставленное более приоритетным источником значение;
|
||||||
|
yandex_realty_nb — низший приоритет в HOUSE_FIELD_PRIORITY для house_class).
|
||||||
|
|
||||||
Idempotency
|
Idempotency
|
||||||
-----------
|
-----------
|
||||||
|
|
@ -135,6 +138,18 @@ _UPDATE_SLUG = """
|
||||||
WHERE id = CAST(:hid AS bigint)
|
WHERE id = CAST(:hid AS bigint)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# house_class (#2003): yandex_realty_nb — самый низкий приоритет источник для
|
||||||
|
# house_class (conflict_resolution.HOUSE_FIELD_PRIORITY: avito_houses_catalog >
|
||||||
|
# cian > yandex_realty_nb) — COALESCE(houses.house_class, ...) заполняет ТОЛЬКО
|
||||||
|
# пустое значение, никогда не перезаписывает уже проставленное avito/cian.
|
||||||
|
# Вызывается только когда info.house_class IS NOT NULL (см. caller) — лишний
|
||||||
|
# UPDATE с NULL никогда не выполняется.
|
||||||
|
_UPDATE_HOUSE_CLASS = """
|
||||||
|
UPDATE houses
|
||||||
|
SET house_class = COALESCE(houses.house_class, CAST(:house_class AS text))
|
||||||
|
WHERE id = CAST(:hid AS bigint)
|
||||||
|
"""
|
||||||
|
|
||||||
_UPSERT_ENRICHMENT = """
|
_UPSERT_ENRICHMENT = """
|
||||||
INSERT INTO market.yandex_jk_enrichment (
|
INSERT INTO market.yandex_jk_enrichment (
|
||||||
ext_id, name, developer_name, address,
|
ext_id, name, developer_name, address,
|
||||||
|
|
@ -377,16 +392,26 @@ async def enrich_yandex_newbuilding_sweep(
|
||||||
"raw_payload": json.dumps(info.raw_payload or {}, ensure_ascii=False),
|
"raw_payload": json.dumps(info.raw_payload or {}, ensure_ascii=False),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# house_class (#2003): распарсен parse_house_class из description (yandex_newbuilding
|
||||||
|
# parse()), но раньше нигде не персистился (только market.yandex_jk_enrichment,
|
||||||
|
# без колонки house_class) — Bug 1 находка. Пишем в houses в ТОЙ ЖЕ savepoint,
|
||||||
|
# только если распарсили (иначе UPDATE не нужен — houses.house_class не трогаем).
|
||||||
|
if info.house_class is not None:
|
||||||
|
db.execute(
|
||||||
|
text(_UPDATE_HOUSE_CLASS),
|
||||||
|
{"hid": house_id, "house_class": info.house_class},
|
||||||
|
)
|
||||||
sp.commit()
|
sp.commit()
|
||||||
db.commit()
|
db.commit()
|
||||||
result.rows_inserted += 1
|
result.rows_inserted += 1
|
||||||
result.succeeded += 1
|
result.succeeded += 1
|
||||||
logger.info(
|
logger.info(
|
||||||
"enriched house_id=%s ext_id=%s slug=%s name=%r",
|
"enriched house_id=%s ext_id=%s slug=%s name=%r house_class=%s",
|
||||||
house_id,
|
house_id,
|
||||||
info.ext_id,
|
info.ext_id,
|
||||||
jk_slug,
|
jk_slug,
|
||||||
info.name,
|
info.name,
|
||||||
|
info.house_class,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
sp.rollback()
|
sp.rollback()
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ class FakeDB:
|
||||||
self.enrichment: dict[str, dict] = {}
|
self.enrichment: dict[str, dict] = {}
|
||||||
# houses.yandex_jk_slug written during resolve
|
# houses.yandex_jk_slug written during resolve
|
||||||
self.slug_updates: dict[int, str] = {}
|
self.slug_updates: dict[int, str] = {}
|
||||||
|
# houses.house_class written after successful enrichment (#2003)
|
||||||
|
self.house_class_updates: dict[int, str] = {}
|
||||||
self.commits = 0
|
self.commits = 0
|
||||||
self.rollbacks = 0
|
self.rollbacks = 0
|
||||||
self._sp_stack: list[_FakeSavepoint] = []
|
self._sp_stack: list[_FakeSavepoint] = []
|
||||||
|
|
@ -104,6 +106,11 @@ class FakeDB:
|
||||||
self.slug_updates[params["hid"]] = params["slug"]
|
self.slug_updates[params["hid"]] = params["slug"]
|
||||||
return _FakeResult()
|
return _FakeResult()
|
||||||
|
|
||||||
|
# UPDATE houses SET house_class (#2003)
|
||||||
|
if "UPDATE houses" in sql and "house_class" in sql:
|
||||||
|
self.house_class_updates[params["hid"]] = params["house_class"]
|
||||||
|
return _FakeResult()
|
||||||
|
|
||||||
# Idempotency check: SELECT 1 FROM market.yandex_jk_enrichment
|
# Idempotency check: SELECT 1 FROM market.yandex_jk_enrichment
|
||||||
if "SELECT 1 FROM market.yandex_jk_enrichment" in sql:
|
if "SELECT 1 FROM market.yandex_jk_enrichment" in sql:
|
||||||
ext_id = params.get("ext_id")
|
ext_id = params.get("ext_id")
|
||||||
|
|
@ -214,6 +221,48 @@ async def test_sweep_with_existing_slug_upserts_enrichment():
|
||||||
assert result.failed_resolve == 0
|
assert result.failed_resolve == 0
|
||||||
assert "999" in db.enrichment
|
assert "999" in db.enrichment
|
||||||
assert db.enrichment["999"]["name"] == "ЖК Татлин"
|
assert db.enrichment["999"]["name"] == "ЖК Татлин"
|
||||||
|
# house_class не распарсен в этой фикстуре (None) — UPDATE houses.house_class не должен
|
||||||
|
# выполниться вовсе (Bug 1 guard: лишний UPDATE с NULL пропускается).
|
||||||
|
assert 10 not in db.house_class_updates
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sweep_persists_house_class_when_parsed():
|
||||||
|
"""#2003: info.house_class распарсен → UPDATE houses.house_class выполняется.
|
||||||
|
|
||||||
|
Ранее (Bug 1) house_class парсился в YandexNewbuildingInfo, но нигде не персистился —
|
||||||
|
только market.yandex_jk_enrichment (без колонки house_class). Регрессия на фикс.
|
||||||
|
"""
|
||||||
|
db = FakeDB(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"house_id": 11,
|
||||||
|
"ext_id": "1000",
|
||||||
|
"yandex_jk_slug": "tatlin",
|
||||||
|
"yandex_jk_id": "1000",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo
|
||||||
|
|
||||||
|
fake_info = YandexNewbuildingInfo(
|
||||||
|
ext_id="1000",
|
||||||
|
ext_slug="tatlin",
|
||||||
|
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1000/",
|
||||||
|
name="ЖК Татлин",
|
||||||
|
house_class="comfort_plus",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
|
||||||
|
scraper_instance = MagicMock()
|
||||||
|
scraper_instance.fetch_jk = AsyncMock(return_value=fake_info)
|
||||||
|
mock_scraper.return_value = scraper_instance
|
||||||
|
|
||||||
|
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
|
||||||
|
|
||||||
|
assert result.succeeded == 1
|
||||||
|
assert db.house_class_updates.get(11) == "comfort_plus"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,54 @@ def test_persist_house_coalesce_fields_in_bind():
|
||||||
assert bind["year_built"] == h.year_built
|
assert bind["year_built"] == h.year_built
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 7b: #2003 — house_class передаётся в UPDATE bind (COALESCE fill-only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_house_house_class_in_bind():
|
||||||
|
"""UPDATE передаёт house_class в bind для COALESCE(CAST(:house_class AS text), houses.*)."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = MagicMock()
|
||||||
|
|
||||||
|
h = _make_house()
|
||||||
|
h.house_class = "comfort"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.scrapers.avito_houses.match_or_create_house",
|
||||||
|
return_value=(1, 1.0, "new"),
|
||||||
|
):
|
||||||
|
_persist_house(db, h, "")
|
||||||
|
|
||||||
|
bind = db.execute.call_args[0][1]
|
||||||
|
assert bind["house_class"] == "comfort"
|
||||||
|
|
||||||
|
sql_obj = db.execute.call_args[0][0]
|
||||||
|
assert "house_class" in str(sql_obj)
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_house_house_class_none_does_not_null_via_coalesce():
|
||||||
|
"""house_class=None в bind — SQL использует COALESCE(new, existing), не голый overwrite."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = MagicMock()
|
||||||
|
|
||||||
|
h = _make_house() # house_class остаётся дефолтным None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.scrapers.avito_houses.match_or_create_house",
|
||||||
|
return_value=(1, 1.0, "new"),
|
||||||
|
):
|
||||||
|
_persist_house(db, h, "")
|
||||||
|
|
||||||
|
bind = db.execute.call_args[0][1]
|
||||||
|
assert bind["house_class"] is None
|
||||||
|
|
||||||
|
sql_obj = str(db.execute.call_args[0][0])
|
||||||
|
# SQL должен явно защищать существующее значение через COALESCE, а не
|
||||||
|
# безусловный overwrite (который занулил бы значение от другого источника).
|
||||||
|
assert "COALESCE(CAST(:house_class AS text), houses.house_class)" in sql_obj
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Test 8: #1820 — ext_id=0 graceful-skip гард
|
# Test 8: #1820 — ext_id=0 graceful-skip гард
|
||||||
# Два id-less URL (ext_id=0, без адреса) → match_or_create_house НЕ вызывается,
|
# Два id-less URL (ext_id=0, без адреса) → match_or_create_house НЕ вызывается,
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,77 @@ def test_house_type_normalization() -> None:
|
||||||
assert _normalize_house_type(None) is None
|
assert _normalize_house_type(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_class_normalization() -> None:
|
||||||
|
"""Все варианты класса жилья нормализуются корректно (#2003)."""
|
||||||
|
from app.services.scrapers.avito_houses import _normalize_house_class
|
||||||
|
|
||||||
|
assert _normalize_house_class("Комфорт") == "comfort"
|
||||||
|
assert _normalize_house_class("Бизнес") == "business"
|
||||||
|
assert _normalize_house_class("Премиум") == "premium"
|
||||||
|
assert _normalize_house_class("Эконом") == "economy"
|
||||||
|
assert _normalize_house_class("Комфорт+") == "comfort_plus"
|
||||||
|
assert _normalize_house_class("Элит") == "elite"
|
||||||
|
# Регистронезависимость + пробелы по краям
|
||||||
|
assert _normalize_house_class(" комфорт ") == "comfort"
|
||||||
|
# Неизвестное значение → None (НЕ "other" — house_class точный ANY(...) фильтр)
|
||||||
|
assert _normalize_house_class("Неизвестный класс") is None
|
||||||
|
assert _normalize_house_class(None) is None
|
||||||
|
assert _normalize_house_class("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_class_parsed_from_novostroyka_expand_params() -> None:
|
||||||
|
""" "Класс жилья" из developmentPage expandParams → HouseInfo.house_class (#2003)."""
|
||||||
|
state: dict = {
|
||||||
|
"data": {
|
||||||
|
"data": {
|
||||||
|
"page": {
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"type": "developmentPage",
|
||||||
|
"props": {
|
||||||
|
"developmentPage": {
|
||||||
|
"developmentData": {
|
||||||
|
"avitoId": 42,
|
||||||
|
"coords": {"lat": 56.8, "lng": 60.6},
|
||||||
|
"developer": {},
|
||||||
|
"mapPreview": {},
|
||||||
|
"aboutDevelopment": {
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"expandParams": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"type": "Класс жилья",
|
||||||
|
"value": "Комфорт",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e = parse_houses_state(state, "/catalog/novostroyki/ekaterinburg/test/42")
|
||||||
|
assert e.house.house_class == "comfort"
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_class_absent_on_secondary_listing() -> None:
|
||||||
|
"""Вторичка (housePage) не отдаёт "Класс жилья" → house_class остаётся None."""
|
||||||
|
e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365")
|
||||||
|
assert e.house.house_class is None
|
||||||
|
|
||||||
|
|
||||||
def test_strip_price() -> None:
|
def test_strip_price() -> None:
|
||||||
"""_strip_price корректно извлекает числа из строк Avito."""
|
"""_strip_price корректно извлекает числа из строк Avito."""
|
||||||
from app.services.scrapers.avito_houses import _strip_price
|
from app.services.scrapers.avito_houses import _strip_price
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,10 @@ EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = {
|
||||||
# вернёт None (хранится только в raw_characteristics).
|
# вернёт None (хранится только в raw_characteristics).
|
||||||
"Пассажирских лифтов": ("passenger_elevators", "int"),
|
"Пассажирских лифтов": ("passenger_elevators", "int"),
|
||||||
"Грузовых лифтов": ("cargo_elevators", "int"),
|
"Грузовых лифтов": ("cargo_elevators", "int"),
|
||||||
|
# Класс жилья (#2003) — ТОЛЬКО developmentPage (новостройки); housePage
|
||||||
|
# (вторичка) этот параметр не отдаёт. Сырое значение "Комфорт"/"Бизнес"/... →
|
||||||
|
# каноничный enum houses.house_class через _normalize_house_class.
|
||||||
|
"Класс жилья": ("house_class", "house_class"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# URL-маркер новостройки: /catalog/novostroyki/... (vs вторичка /catalog/houses/...).
|
# URL-маркер новостройки: /catalog/novostroyki/... (vs вторичка /catalog/houses/...).
|
||||||
|
|
@ -104,6 +108,21 @@ HOUSE_TYPE_MAP: dict[str, str] = {
|
||||||
"деревянный": "wood",
|
"деревянный": "wood",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Нормализация класса жилья из Avito "Класс жилья" в canonical houses.house_class
|
||||||
|
# (#2003). Avito сам отдаёт только 4 значения (Комфорт/Бизнес/Премиум/Эконом —
|
||||||
|
# live-подтверждено), но карта дополнена "Комфорт+"/"Элит" для согласованности с
|
||||||
|
# вокабуляром yandex_helpers.parse_house_class (тот же столбец пишут оба источника).
|
||||||
|
HOUSE_CLASS_MAP: dict[str, str] = {
|
||||||
|
"эконом": "economy",
|
||||||
|
"комфорт": "comfort",
|
||||||
|
"комфорт+": "comfort_plus",
|
||||||
|
"комфорт плюс": "comfort_plus",
|
||||||
|
"бизнес": "business",
|
||||||
|
"премиум": "premium",
|
||||||
|
"элит": "elite",
|
||||||
|
"элитный": "elite",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dataclasses
|
# Dataclasses
|
||||||
|
|
@ -122,6 +141,7 @@ class HouseInfo:
|
||||||
year_built: int | None = None
|
year_built: int | None = None
|
||||||
total_floors: int | None = None
|
total_floors: int | None = None
|
||||||
house_type: str | None = None # нормализован: monolith/panel/brick/...
|
house_type: str | None = None # нормализован: monolith/panel/brick/...
|
||||||
|
house_class: str | None = None # нормализован: comfort/business/premium/economy/... (#2003)
|
||||||
material_floors: str | None = None
|
material_floors: str | None = None
|
||||||
hot_water: str | None = None
|
hot_water: str | None = None
|
||||||
passenger_elevators: int | None = None
|
passenger_elevators: int | None = None
|
||||||
|
|
@ -244,6 +264,18 @@ def _normalize_house_type(raw: str | None) -> str | None:
|
||||||
return HOUSE_TYPE_MAP.get(raw.lower(), "other")
|
return HOUSE_TYPE_MAP.get(raw.lower(), "other")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_house_class(raw: str | None) -> str | None:
|
||||||
|
"""Нормализует класс жилья: "Комфорт" → "comfort" (#2003).
|
||||||
|
|
||||||
|
В отличие от _normalize_house_type, неизвестное значение → None (НЕ "other"):
|
||||||
|
house_class используется как точный фильтр (`= ANY(...)` в search_query.py) —
|
||||||
|
мусорный bucket-токен там бесполезен и только засоряет список значений.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return HOUSE_CLASS_MAP.get(raw.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
def _cast_expand_param(value: str, cast_type: str) -> Any:
|
def _cast_expand_param(value: str, cast_type: str) -> Any:
|
||||||
"""Кастует строковое значение expandParams к нужному типу."""
|
"""Кастует строковое значение expandParams к нужному типу."""
|
||||||
if cast_type == "int":
|
if cast_type == "int":
|
||||||
|
|
@ -255,6 +287,8 @@ def _cast_expand_param(value: str, cast_type: str) -> Any:
|
||||||
return value.strip().lower() == "да"
|
return value.strip().lower() == "да"
|
||||||
if cast_type == "house_type":
|
if cast_type == "house_type":
|
||||||
return _normalize_house_type(value)
|
return _normalize_house_type(value)
|
||||||
|
if cast_type == "house_class":
|
||||||
|
return _normalize_house_class(value)
|
||||||
# str
|
# str
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -374,6 +408,7 @@ def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
|
||||||
year_built=parsed_params.get("year_built"),
|
year_built=parsed_params.get("year_built"),
|
||||||
total_floors=parsed_params.get("total_floors"),
|
total_floors=parsed_params.get("total_floors"),
|
||||||
house_type=parsed_params.get("house_type"),
|
house_type=parsed_params.get("house_type"),
|
||||||
|
house_class=parsed_params.get("house_class"),
|
||||||
material_floors=parsed_params.get("material_floors"),
|
material_floors=parsed_params.get("material_floors"),
|
||||||
hot_water=parsed_params.get("hot_water"),
|
hot_water=parsed_params.get("hot_water"),
|
||||||
passenger_elevators=parsed_params.get("passenger_elevators"),
|
passenger_elevators=parsed_params.get("passenger_elevators"),
|
||||||
|
|
@ -447,6 +482,7 @@ def _parse_development_page(widget: dict[str, Any]) -> HouseInfo:
|
||||||
year_built=parsed_params.get("year_built"), # обычно None — дом строится
|
year_built=parsed_params.get("year_built"), # обычно None — дом строится
|
||||||
total_floors=parsed_params.get("total_floors"),
|
total_floors=parsed_params.get("total_floors"),
|
||||||
house_type=parsed_params.get("house_type"),
|
house_type=parsed_params.get("house_type"),
|
||||||
|
house_class=parsed_params.get("house_class"),
|
||||||
material_floors=parsed_params.get("material_floors"),
|
material_floors=parsed_params.get("material_floors"),
|
||||||
hot_water=parsed_params.get("hot_water"),
|
hot_water=parsed_params.get("hot_water"),
|
||||||
passenger_elevators=parsed_params.get("passenger_elevators"),
|
passenger_elevators=parsed_params.get("passenger_elevators"),
|
||||||
|
|
@ -964,6 +1000,9 @@ def _persist_house(
|
||||||
avito_id_hash) перезаписываются напрямую.
|
avito_id_hash) перезаписываются напрямую.
|
||||||
- address/lat/lon/year_built — через COALESCE чтобы не затирать данные
|
- address/lat/lon/year_built — через COALESCE чтобы не затирать данные
|
||||||
канонического дома от cian/кадастра.
|
канонического дома от cian/кадастра.
|
||||||
|
- house_class (#2003) — COALESCE(new, existing): свежее avito-значение
|
||||||
|
(топ-приоритет источник) побеждает, но None не затирает значение от
|
||||||
|
более низкоприоритетного источника (см. HOUSE_FIELD_PRIORITY).
|
||||||
3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment
|
3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment
|
||||||
(advisory lock xact-scoped, должен держаться до commit).
|
(advisory lock xact-scoped, должен держаться до commit).
|
||||||
|
|
||||||
|
|
@ -1001,6 +1040,12 @@ def _persist_house(
|
||||||
full_address = CAST(:full_address AS text),
|
full_address = CAST(:full_address AS text),
|
||||||
avito_id_hash = CAST(:avito_id_hash AS text),
|
avito_id_hash = CAST(:avito_id_hash AS text),
|
||||||
house_type = CAST(:house_type AS text),
|
house_type = CAST(:house_type AS text),
|
||||||
|
-- house_class (#2003): avito_houses_catalog топ-приоритет источник
|
||||||
|
-- (conflict_resolution.HOUSE_FIELD_PRIORITY) — свежее avito-значение
|
||||||
|
-- всегда побеждает, но None (вторичка не отдаёт "Класс жилья") не
|
||||||
|
-- затирает значение, уже проставленное более низкоприоритетным
|
||||||
|
-- источником (напр. yandex_realty_nb).
|
||||||
|
house_class = COALESCE(CAST(:house_class AS text), houses.house_class),
|
||||||
total_floors = CAST(:total_floors AS int),
|
total_floors = CAST(:total_floors AS int),
|
||||||
passenger_elevators = CAST(:passenger_elevators AS int),
|
passenger_elevators = CAST(:passenger_elevators AS int),
|
||||||
cargo_elevators = CAST(:cargo_elevators AS int),
|
cargo_elevators = CAST(:cargo_elevators AS int),
|
||||||
|
|
@ -1036,6 +1081,7 @@ def _persist_house(
|
||||||
"full_address": h.full_address,
|
"full_address": h.full_address,
|
||||||
"avito_id_hash": h.ext_id_hash,
|
"avito_id_hash": h.ext_id_hash,
|
||||||
"house_type": h.house_type,
|
"house_type": h.house_type,
|
||||||
|
"house_class": h.house_class,
|
||||||
"total_floors": h.total_floors,
|
"total_floors": h.total_floors,
|
||||||
"passenger_elevators": h.passenger_elevators,
|
"passenger_elevators": h.passenger_elevators,
|
||||||
"cargo_elevators": h.cargo_elevators,
|
"cargo_elevators": h.cargo_elevators,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue