diff --git a/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql b/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql new file mode 100644 index 00000000..e2147b23 --- /dev/null +++ b/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql @@ -0,0 +1,29 @@ +-- 320_listings_domclick_zero_area_to_null.sql +-- Issue #3252: Домклик отдаёт `livingArea: 0` / `kitchenArea: 0` как «не указано» +-- (живая карточка 2078257603, 17.09: обе площади 0, ремонт пустой). Парсер карточки +-- писал этот 0 в колонку как настоящую площадь; COALESCE(:new, old) в UPDATE при этом +-- затирал нулём ранее известное значение. Писатель починен в том же PR +-- (providers/domclick/detail.py::_pos_float), но починка разбора строк не чинит: +-- COALESCE(NULL, 0) оставит 0 навсегда. +-- +-- ЗАМЕР ПРОДА 2026-09-17 (SELECT source, count(*) FILTER (WHERE kitchen_area_m2=0) ...): +-- domklik: kitchen_area_m2 = 0 — 303 строки, living_area_m2 = 0 — 84 строки; +-- все обогащены карточкой 26.08–14.09 (detail_enriched_at), ни одной до этого. +-- avito / cian / yandex / n1: нулей нет вовсе. +-- Ожидаемо тронуто: 303 + 84 строки. Отрицательных площадей нет ни у кого. +-- +-- Idempotent: повторный прогон — 0 строк. + +BEGIN; + +UPDATE listings + SET kitchen_area_m2 = NULL + WHERE source = 'domklik' + AND kitchen_area_m2 = 0; + +UPDATE listings + SET living_area_m2 = NULL + WHERE source = 'domklik' + AND living_area_m2 = 0; + +COMMIT; diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index 68f606d4..8bcccd4e 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -83,9 +83,9 @@ _SSR_LITERAL = """{ }, "legalOptions": {"saleType": "Свободная продажа"}, "egrnData": { - "area": 38.2, - "floor": 5, - "owners_count": 1, + "area": {"status": "success", "value": 38.2}, + "floor": {"status": "success", "value": 5}, + "owners_count": {"status": "success", "value": 1}, "collateral": true, "collateral_sber": false }, @@ -264,7 +264,7 @@ def test_parse_detail_html_raw_extra() -> None: # wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type. assert "house_type" not in raw assert raw["domclick_building_guid"] == "abc-guid-123" - assert raw["egrn_area"] == 38.2 + assert raw["egrn_area"] == {"status": "success", "value": 38.2} assert raw["demand"]["calls"] == 5 assert raw["demand"]["favorites"] == 12 # AVM (Layer C, top-level pricePrediction) → raw_extra.avm diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 02194917..6908757f 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -146,3 +146,10 @@ tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hun tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction + +# Жилая площадь и балконы переживают переобход выдачи (#3252) — тот же `_live_session()`. +# Проверяют ПОВЕДЕНИЕ апсерта на живой схеме: SERP без полей не стирает то, что +# добыла карточка (COALESCE), и настоящее новое значение всё ещё перезаписывает. +# В ci-tradein.yml бегут по-настоящему (postgres-сервис); без БД — skip. +tests/test_3252_domclick_card_fields.py::test_serp_rescrape_keeps_detail_living_area_and_balconies +tests/test_3252_domclick_card_fields.py::test_real_new_value_still_overwrites diff --git a/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py b/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py new file mode 100644 index 00000000..d5af85af --- /dev/null +++ b/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py @@ -0,0 +1,192 @@ +"""#3252: три тихие дыры в полях карточки Домклика. + +1. owners_count. egrnData отдаёт поля ЕГРН обёрткой ``{"status", "value"}``, а парсер + делал ``int()`` от словаря и молча получал None: на проде 0 из ~2970 карточек, + обогащённых с 29.08 (при 5075 из 6296 у ручного прогона 18.07, который обёртку + разворачивал). +2. Нулевые площади. ``livingArea: 0`` / ``kitchenArea: 0`` у Домклика значит «не + указано», а в колонку ложился 0.00 (303 кухни и 84 жилые площади). +3. Жилая площадь и балконы стирались переобходом выдачи: апсерт в scraper_kit.base + писал их сырым ``EXCLUDED``, а SERP Домклика и Авито этих полей не отдаёт. У + domklik, переобойдённых после обогащения, living_area_m2 = 0 из 5217. + +Состояние карточек ниже — урезанный ``__SSR_STATE__`` живых карточек 2074362051 и +2078257603, снятый 17.09 (значения как есть, лишние ветки выкинуты). +""" + +from __future__ import annotations + +import json +import os +import uuid +from decimal import Decimal +from typing import Any +from unittest.mock import MagicMock + +import pytest +from scraper_kit.base import ScrapedLot, save_listings +from scraper_kit.providers.domclick.detail import parse_detail_html +from sqlalchemy import text + +_LIVE_2074362051 = { + "productCard": { + "objectInfo": { + "area": 59.8, + "balconies": 1, + "kitchenArea": 14.9, + "livingArea": 25.8, + "renovation": "Косметический", + }, + "egrnData": { + "area": {"status": "success", "value": 59.8}, + "collateral": False, + "collateral_sber": False, + "floor": {"status": "success", "value": 14}, + "owners_count": {"status": "error", "value": 6}, + }, + }, +} + +_LIVE_2078257603 = { + "productCard": { + "objectInfo": { + "area": 67.4, + "balconies": 0, + "kitchenArea": 0, + "livingArea": 0, + "renovation": "", + }, + "egrnData": { + "area": {"status": "error", "value": 65.3}, + "collateral": False, + "collateral_sber": False, + "floor": {"status": "success", "value": 21}, + "owners_count": {"status": "success", "value": 1}, + }, + }, +} + + +def _parse(state: dict, card_id: str): + html = f"" + return parse_detail_html(html, f"https://ekaterinburg.domclick.ru/card/sale__flat__{card_id}") + + +# ── 1-2. Разбор живой формы ────────────────────────────────────────────────── + + +def test_owners_count_is_read_from_egrn_wrapper_whatever_the_status() -> None: + # status=error у owners_count = «много собственников», value — сам факт ЕГРН. + assert _parse(_LIVE_2074362051, "2074362051").owners_count == 6 + assert _parse(_LIVE_2078257603, "2078257603").owners_count == 1 + + +def test_egrn_area_keeps_wrapper_in_raw_payload() -> None: + e = _parse(_LIVE_2078257603, "2078257603") + assert e.raw_extra["egrn_area"] == {"status": "error", "value": 65.3} + + +def test_zero_areas_mean_unknown_not_zero() -> None: + zero = _parse(_LIVE_2078257603, "2078257603") + assert (zero.living_area_m2, zero.kitchen_area_m2) == (None, None) + real = _parse(_LIVE_2074362051, "2074362051") + assert (real.living_area_m2, real.kitchen_area_m2) == (25.8, 14.9) + + +# ── 3. Переобход выдачи не стирает то, что добыла карточка (живой Postgres) ── + + +def _live_session() -> Any | None: + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _matcher() -> MagicMock: + m = MagicMock() + m.match_or_create_house.return_value = (None, 0.0, "no_address") + m.upsert_listing_source.return_value = None + return m + + +def _serp_lot(sid: str, **detail: Any) -> ScrapedLot: + return ScrapedLot( + source="domklik", + source_url=f"https://ekaterinburg.domclick.ru/card/sale__flat__t3252{sid}", + source_id=f"t3252-{sid}", + price_rub=7_000_000, + **detail, + ) + + +def _row(db: Any, sid: str) -> Any: + return db.execute( + text( + "SELECT living_area_m2, balconies_count FROM listings " + "WHERE source='domklik' AND source_id = :sid" + ), + {"sid": f"t3252-{sid}"}, + ).fetchone() + + +def _cleanup(db: Any) -> None: + try: + db.rollback() + ids = "(SELECT id FROM listings WHERE source='domklik' AND source_id LIKE 't3252-%')" + db.execute(text(f"DELETE FROM listings_snapshots WHERE listing_id IN {ids}")) + db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't3252-%'")) + db.execute(text("DELETE FROM listings WHERE source='domklik' AND source_id LIKE 't3252-%'")) + db.commit() + finally: + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_serp_rescrape_keeps_detail_living_area_and_balconies() -> None: + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings( + db, + [_serp_lot(sid, living_area_m2=25.8, balconies_count=1)], + matcher=_matcher(), + region_code=66, + ) + save_listings(db, [_serp_lot(sid)], matcher=_matcher(), region_code=66) # выдача: полей нет + row = _row(db, sid) + assert (row.living_area_m2, row.balconies_count) == (Decimal("25.80"), 1) + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_new_value_still_overwrites() -> None: + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings( + db, + [_serp_lot(sid, living_area_m2=25.8, balconies_count=1)], + matcher=_matcher(), + region_code=66, + ) + save_listings( + db, + [_serp_lot(sid, living_area_m2=30.5, balconies_count=2)], + matcher=_matcher(), + region_code=66, + ) + row = _row(db, sid) + assert (row.living_area_m2, row.balconies_count) == (Decimal("30.50"), 2) + finally: + _cleanup(db) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index d8676724..f470f51e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -587,9 +587,18 @@ def save_listings( price_rub = EXCLUDED.price_rub, price_per_m2 = EXCLUDED.price_per_m2, -- Cian-specific: обновляем при каждом re-scrape - living_area_m2 = EXCLUDED.living_area_m2, + -- #3252: жилая площадь и балконы — COALESCE по той же причине, что + -- #3063 ниже: их добывает detail (avito, domklik), а SERP этих + -- источников отдаёт NULL. Прод 17.09: у domklik, переобойдённых + -- после обогащения, living_area_m2 = 0 из 5217 (без переобхода — + -- 2990 из 4058), у avito — 1 из 8639 (4122 из 6505). + living_area_m2 = COALESCE( + EXCLUDED.living_area_m2, listings.living_area_m2 + ), bedrooms_count = EXCLUDED.bedrooms_count, - balconies_count = EXCLUDED.balconies_count, + balconies_count = COALESCE( + EXCLUDED.balconies_count, listings.balconies_count + ), loggias_count = EXCLUDED.loggias_count, description_minhash = EXCLUDED.description_minhash, cadastral_number = EXCLUDED.cadastral_number, @@ -742,8 +751,11 @@ def save_listings( listings.price_previous_rub, listings.newbuilding_id, listings.newbuilding_url, listings.card_hash, listings.is_active ) IS DISTINCT FROM ( - EXCLUDED.price_rub, EXCLUDED.price_per_m2, EXCLUDED.living_area_m2, - EXCLUDED.bedrooms_count, EXCLUDED.balconies_count, EXCLUDED.loggias_count, + EXCLUDED.price_rub, EXCLUDED.price_per_m2, + COALESCE(EXCLUDED.living_area_m2, listings.living_area_m2), + EXCLUDED.bedrooms_count, + COALESCE(EXCLUDED.balconies_count, listings.balconies_count), + EXCLUDED.loggias_count, EXCLUDED.description_minhash, EXCLUDED.cadastral_number, EXCLUDED.building_cadastral_number, -- #3063: те же COALESCE, что в SET выше. Правая часть гейта ОБЯЗАНА @@ -823,9 +835,9 @@ def save_listings( is_active = true, price_rub = :price_rub, price_per_m2 = :ppm2, - living_area_m2 = :living_area_m2, + living_area_m2 = COALESCE(:living_area_m2, living_area_m2), bedrooms_count = :bedrooms_count, - balconies_count = :balconies_count, + balconies_count = COALESCE(:balconies_count, balconies_count), loggias_count = :loggias_count, description_minhash = :description_minhash, cadastral_number = :cadastral_number, @@ -940,7 +952,7 @@ def save_listings( skip_snapshot = today_row is not None if skip_snapshot: logger.debug( - "save_listings:snapshot_skipped (card unchanged) " "source=%s listing_id=%s", + "save_listings:snapshot_skipped (card unchanged) source=%s listing_id=%s", lot.source, listing_id, ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py index 66912922..2a845426 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -27,7 +27,8 @@ _extract_ssr_state делает balanced-brace scan (с пропуском ск Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27): productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo. priceHistory ({date ISO8601+tz, price, diff, state}), productCard.egrnData -(snake_case owners_count/collateral/collateral_sber), productCard.legalOptions. +(snake_case; area/floor/owners_count — обёртка {status, value}, collateral/ +collateral_sber — bool; перепроверено 17.09, #3252), productCard.legalOptions. saleType, productCard.viewsCount/callsCount, houseInfo.info.{wallType,floorType}, и ТОП-УРОВНЕМ pricePrediction (DomClick AVM → raw_extra.avm). @@ -283,6 +284,29 @@ def _to_int(value: Any) -> int | None: return None +def _pos_float(value: Any) -> float | None: + """float > 0, иначе None (#3252). + + Домклик отдаёт ``livingArea: 0`` / ``kitchenArea: 0`` как «не указано» (живая + карточка 2078257603, 17.09). Без этого в колонку ложился 0.00, а COALESCE в + UPDATE затирал им ранее известную площадь: на проде 303 кухни и 84 жилые + площади = 0 — только у domklik и только у обогащённых после 26.08. + """ + f = _to_float(value) + return f if f is not None and f > 0 else None + + +def _egrn_value(value: Any) -> Any: + """Узел egrnData → значение (#3252). + + Поля ЕГРН приходят обёрткой ``{"status": "success"|"warning"|"error", "value": X}`` + (живые карточки 17.09: area, floor, owners_count). ``status`` — сверка с тем, что + указал продавец, а ``value`` — сам факт из ЕГРН, поэтому берём value при любом + статусе. Скаляр пропускаем как есть. + """ + return value.get("value") if isinstance(value, dict) else value + + def _parse_change_time(value: Any) -> datetime | None: """Нормализует дату изменения цены → timezone-aware datetime. @@ -436,8 +460,8 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: repair_state = _map_repair_state(repair_type) # площади - living_area_m2 = _to_float(oi.get("livingArea")) - kitchen_area_m2 = _to_float(oi.get("kitchenArea")) + living_area_m2 = _pos_float(oi.get("livingArea")) + kitchen_area_m2 = _pos_float(oi.get("kitchenArea")) # балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None balconies = oi.get("balconies") @@ -454,10 +478,11 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: sale_type = (pc.get("legalOptions") or {}).get("saleType") - # owners_count — защищаемся от двух написаний ключа - owners_count = _to_int(egrn.get("owners_count")) + # owners_count — обёртка {status, value} (см. _egrn_value); до #3252 int() + # от словаря молча давал None: 0 из ~2970 карточек, обогащённых с 29.08. + owners_count = _to_int(_egrn_value(egrn.get("owners_count"))) if owners_count is None: - owners_count = _to_int(egrn.get("ownersCount")) + owners_count = _to_int(_egrn_value(egrn.get("ownersCount"))) # encumbrances_clean (ИНВЕРСИЯ): collateral/collateral_sber обозначают # НАЛИЧИЕ обременения. Любой truthy → clean=False; оба отсутствуют/false @@ -523,8 +548,10 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: # raw_extra → merge в listings.raw_payload. wall/floor type ОСТАЮТСЯ тут, # НЕ пишем в listings.house_type (кросс-источниковый словарь грязный). - # egrn area key — точное написание не подтверждено; пробуем несколько. - egrn_area = egrn.get("area") or egrn.get("rosreestrArea") or egrn.get("object_area") + # egrnData.area — ключ подтверждён на живых карточках 17.09 (#3252). В raw_payload + # кладём обёртку {status, value} целиком: status говорит, сошлась ли площадь + # ЕГРН с площадью объявления, и уже лежит в таком виде у 2156 строк. + egrn_area = egrn.get("area") demand = _compact( { "calls": pc.get("callsCount"),