fix(tradein/houses): персистить house_class из avito «Класс жилья» и yandex parse (#2003) #2359

Merged
lekss361 merged 1 commit from fix/tradein-house-class-persist into main 2026-07-04 00:49:49 +00:00
6 changed files with 286 additions and 1 deletions

View file

@ -86,6 +86,10 @@ EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = {
# вернёт None (хранится только в raw_characteristics).
"Пассажирских лифтов": ("passenger_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/...).
@ -104,6 +108,21 @@ HOUSE_TYPE_MAP: dict[str, str] = {
"деревянный": "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
@ -122,6 +141,7 @@ class HouseInfo:
year_built: int | None = None
total_floors: int | None = None
house_type: str | None = None # нормализован: monolith/panel/brick/...
house_class: str | None = None # нормализован: comfort/business/premium/economy/... (#2003)
material_floors: str | None = None
hot_water: str | 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")
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:
"""Кастует строковое значение expandParams к нужному типу."""
if cast_type == "int":
@ -255,6 +287,8 @@ def _cast_expand_param(value: str, cast_type: str) -> Any:
return value.strip().lower() == "да"
if cast_type == "house_type":
return _normalize_house_type(value)
if cast_type == "house_class":
return _normalize_house_class(value)
# str
return value
@ -374,6 +408,7 @@ def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
year_built=parsed_params.get("year_built"),
total_floors=parsed_params.get("total_floors"),
house_type=parsed_params.get("house_type"),
house_class=parsed_params.get("house_class"),
material_floors=parsed_params.get("material_floors"),
hot_water=parsed_params.get("hot_water"),
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 — дом строится
total_floors=parsed_params.get("total_floors"),
house_type=parsed_params.get("house_type"),
house_class=parsed_params.get("house_class"),
material_floors=parsed_params.get("material_floors"),
hot_water=parsed_params.get("hot_water"),
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) перезаписываются напрямую.
- address/lat/lon/year_built через COALESCE чтобы не затирать данные
канонического дома от cian/кадастра.
- house_class (#2003) — COALESCE(new, existing): свежее avito-значение
(топ-приоритет источник) побеждает, но None не затирает значение от
более низкоприоритетного источника (см. HOUSE_FIELD_PRIORITY).
3. Не вызывает db.commit() коммитит вызывающий save_house_catalog_enrichment
(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),
avito_id_hash = CAST(:avito_id_hash 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),
passenger_elevators = CAST(:passenger_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,
"avito_id_hash": h.ext_id_hash,
"house_type": h.house_type,
"house_class": h.house_class,
"total_floors": h.total_floors,
"passenger_elevators": h.passenger_elevators,
"cargo_elevators": h.cargo_elevators,

View file

@ -12,6 +12,9 @@ Context
Через BrowserFetcher (tradein-browser camoufox) единственный рабочий путь.
3. UPSERT в market.yandex_jk_enrichment (ON CONFLICT (ext_id) DO UPDATE).
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
-----------
@ -135,6 +138,18 @@ _UPDATE_SLUG = """
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 = """
INSERT INTO market.yandex_jk_enrichment (
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),
},
)
# 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()
db.commit()
result.rows_inserted += 1
result.succeeded += 1
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,
info.ext_id,
jk_slug,
info.name,
info.house_class,
)
except Exception as exc:
sp.rollback()

View file

@ -63,6 +63,8 @@ class FakeDB:
self.enrichment: dict[str, dict] = {}
# houses.yandex_jk_slug written during resolve
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.rollbacks = 0
self._sp_stack: list[_FakeSavepoint] = []
@ -104,6 +106,11 @@ class FakeDB:
self.slug_updates[params["hid"]] = params["slug"]
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
if "SELECT 1 FROM market.yandex_jk_enrichment" in sql:
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 "999" in db.enrichment
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

View file

@ -274,6 +274,54 @@ def test_persist_house_coalesce_fields_in_bind():
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 гард
# Два id-less URL (ext_id=0, без адреса) → match_or_create_house НЕ вызывается,

View file

@ -551,6 +551,77 @@ def test_house_type_normalization() -> 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:
"""_strip_price корректно извлекает числа из строк Avito."""
from app.services.scrapers.avito_houses import _strip_price

View file

@ -86,6 +86,10 @@ EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = {
# вернёт None (хранится только в raw_characteristics).
"Пассажирских лифтов": ("passenger_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/...).
@ -104,6 +108,21 @@ HOUSE_TYPE_MAP: dict[str, str] = {
"деревянный": "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
@ -122,6 +141,7 @@ class HouseInfo:
year_built: int | None = None
total_floors: int | None = None
house_type: str | None = None # нормализован: monolith/panel/brick/...
house_class: str | None = None # нормализован: comfort/business/premium/economy/... (#2003)
material_floors: str | None = None
hot_water: str | 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")
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:
"""Кастует строковое значение expandParams к нужному типу."""
if cast_type == "int":
@ -255,6 +287,8 @@ def _cast_expand_param(value: str, cast_type: str) -> Any:
return value.strip().lower() == "да"
if cast_type == "house_type":
return _normalize_house_type(value)
if cast_type == "house_class":
return _normalize_house_class(value)
# str
return value
@ -374,6 +408,7 @@ def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
year_built=parsed_params.get("year_built"),
total_floors=parsed_params.get("total_floors"),
house_type=parsed_params.get("house_type"),
house_class=parsed_params.get("house_class"),
material_floors=parsed_params.get("material_floors"),
hot_water=parsed_params.get("hot_water"),
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 — дом строится
total_floors=parsed_params.get("total_floors"),
house_type=parsed_params.get("house_type"),
house_class=parsed_params.get("house_class"),
material_floors=parsed_params.get("material_floors"),
hot_water=parsed_params.get("hot_water"),
passenger_elevators=parsed_params.get("passenger_elevators"),
@ -964,6 +1000,9 @@ def _persist_house(
avito_id_hash) перезаписываются напрямую.
- address/lat/lon/year_built через COALESCE чтобы не затирать данные
канонического дома от cian/кадастра.
- house_class (#2003) — COALESCE(new, existing): свежее avito-значение
(топ-приоритет источник) побеждает, но None не затирает значение от
более низкоприоритетного источника (см. HOUSE_FIELD_PRIORITY).
3. Не вызывает db.commit() коммитит вызывающий save_house_catalog_enrichment
(advisory lock xact-scoped, должен держаться до commit).
@ -1001,6 +1040,12 @@ def _persist_house(
full_address = CAST(:full_address AS text),
avito_id_hash = CAST(:avito_id_hash 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),
passenger_elevators = CAST(:passenger_elevators AS int),
cargo_elevators = CAST(:cargo_elevators AS int),
@ -1036,6 +1081,7 @@ def _persist_house(
"full_address": h.full_address,
"avito_id_hash": h.ext_id_hash,
"house_type": h.house_type,
"house_class": h.house_class,
"total_floors": h.total_floors,
"passenger_elevators": h.passenger_elevators,
"cargo_elevators": h.cargo_elevators,