Merge pull request 'Домклик: число собственников снова читается, жилая площадь и балконы не стираются переобходом, нули в площадях — не площадь' (#3569) from fix/domclick-card-fields into main
All checks were successful
Deploy Trade-In / build-browser (push) Successful in 1m8s
Deploy Trade-In / build-frontend (push) Successful in 4m55s
Deploy Trade-In / test (push) Successful in 6m25s
Deploy Trade-In / build-backend (push) Successful in 3m22s
Deploy Trade-In / deploy (push) Successful in 3m13s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m53s
Deploy Trade-In / changes (push) Successful in 30s

This commit is contained in:
bot-backend 2026-09-17 10:05:08 +00:00
commit be1403289a
6 changed files with 286 additions and 19 deletions

View file

@ -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.0814.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;

View file

@ -83,9 +83,9 @@ _SSR_LITERAL = """{
}, },
"legalOptions": {"saleType": "Свободная продажа"}, "legalOptions": {"saleType": "Свободная продажа"},
"egrnData": { "egrnData": {
"area": 38.2, "area": {"status": "success", "value": 38.2},
"floor": 5, "floor": {"status": "success", "value": 5},
"owners_count": 1, "owners_count": {"status": "success", "value": 1},
"collateral": true, "collateral": true,
"collateral_sber": false "collateral_sber": false
}, },
@ -264,7 +264,7 @@ def test_parse_detail_html_raw_extra() -> None:
# wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type. # wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type.
assert "house_type" not in raw assert "house_type" not in raw
assert raw["domclick_building_guid"] == "abc-guid-123" 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"]["calls"] == 5
assert raw["demand"]["favorites"] == 12 assert raw["demand"]["favorites"] == 12
# AVM (Layer C, top-level pricePrediction) → raw_extra.avm # AVM (Layer C, top-level pricePrediction) → raw_extra.avm

View file

@ -147,6 +147,13 @@ 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_statement_timeout_overrides_session_ceiling
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction 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
# Миграция 310 (#3385): настоящий SQL-файл с DELETE и RAISE-остановкой исполняет только # Миграция 310 (#3385): настоящий SQL-файл с DELETE и RAISE-остановкой исполняет только
# Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от # Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09. # снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.

View file

@ -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"<script>window.__SSR_STATE__ = {json.dumps(state, ensure_ascii=False)};</script>"
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)

View file

@ -587,9 +587,18 @@ def save_listings(
price_rub = EXCLUDED.price_rub, price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2, price_per_m2 = EXCLUDED.price_per_m2,
-- Cian-specific: обновляем при каждом re-scrape -- 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, bedrooms_count = EXCLUDED.bedrooms_count,
balconies_count = EXCLUDED.balconies_count, balconies_count = COALESCE(
EXCLUDED.balconies_count, listings.balconies_count
),
loggias_count = EXCLUDED.loggias_count, loggias_count = EXCLUDED.loggias_count,
description_minhash = EXCLUDED.description_minhash, description_minhash = EXCLUDED.description_minhash,
cadastral_number = EXCLUDED.cadastral_number, cadastral_number = EXCLUDED.cadastral_number,
@ -742,8 +751,11 @@ def save_listings(
listings.price_previous_rub, listings.newbuilding_id, listings.price_previous_rub, listings.newbuilding_id,
listings.newbuilding_url, listings.card_hash, listings.is_active listings.newbuilding_url, listings.card_hash, listings.is_active
) IS DISTINCT FROM ( ) IS DISTINCT FROM (
EXCLUDED.price_rub, EXCLUDED.price_per_m2, EXCLUDED.living_area_m2, EXCLUDED.price_rub, EXCLUDED.price_per_m2,
EXCLUDED.bedrooms_count, EXCLUDED.balconies_count, EXCLUDED.loggias_count, 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.description_minhash, EXCLUDED.cadastral_number,
EXCLUDED.building_cadastral_number, EXCLUDED.building_cadastral_number,
-- #3063: те же COALESCE, что в SET выше. Правая часть гейта ОБЯЗАНА -- #3063: те же COALESCE, что в SET выше. Правая часть гейта ОБЯЗАНА
@ -823,9 +835,9 @@ def save_listings(
is_active = true, is_active = true,
price_rub = :price_rub, price_rub = :price_rub,
price_per_m2 = :ppm2, price_per_m2 = :ppm2,
living_area_m2 = :living_area_m2, living_area_m2 = COALESCE(:living_area_m2, living_area_m2),
bedrooms_count = :bedrooms_count, bedrooms_count = :bedrooms_count,
balconies_count = :balconies_count, balconies_count = COALESCE(:balconies_count, balconies_count),
loggias_count = :loggias_count, loggias_count = :loggias_count,
description_minhash = :description_minhash, description_minhash = :description_minhash,
cadastral_number = :cadastral_number, cadastral_number = :cadastral_number,
@ -940,7 +952,7 @@ def save_listings(
skip_snapshot = today_row is not None skip_snapshot = today_row is not None
if skip_snapshot: if skip_snapshot:
logger.debug( 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, lot.source,
listing_id, listing_id,
) )

View file

@ -27,7 +27,8 @@ _extract_ssr_state делает balanced-brace scan (с пропуском ск
Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27): Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27):
productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo. productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo.
priceHistory ({date ISO8601+tz, price, diff, state}), productCard.egrnData 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}, saleType, productCard.viewsCount/callsCount, houseInfo.info.{wallType,floorType},
и ТОП-УРОВНЕМ pricePrediction (DomClick AVM raw_extra.avm). и ТОП-УРОВНЕМ pricePrediction (DomClick AVM raw_extra.avm).
@ -283,6 +284,29 @@ def _to_int(value: Any) -> int | None:
return 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: def _parse_change_time(value: Any) -> datetime | None:
"""Нормализует дату изменения цены → timezone-aware datetime. """Нормализует дату изменения цены → timezone-aware datetime.
@ -436,8 +460,8 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment:
repair_state = _map_repair_state(repair_type) repair_state = _map_repair_state(repair_type)
# площади # площади
living_area_m2 = _to_float(oi.get("livingArea")) living_area_m2 = _pos_float(oi.get("livingArea"))
kitchen_area_m2 = _to_float(oi.get("kitchenArea")) kitchen_area_m2 = _pos_float(oi.get("kitchenArea"))
# балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None # балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None
balconies = oi.get("balconies") 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") sale_type = (pc.get("legalOptions") or {}).get("saleType")
# owners_count — защищаемся от двух написаний ключа # owners_count — обёртка {status, value} (см. _egrn_value); до #3252 int()
owners_count = _to_int(egrn.get("owners_count")) # от словаря молча давал None: 0 из ~2970 карточек, обогащённых с 29.08.
owners_count = _to_int(_egrn_value(egrn.get("owners_count")))
if owners_count is None: 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 обозначают # encumbrances_clean (ИНВЕРСИЯ): collateral/collateral_sber обозначают
# НАЛИЧИЕ обременения. Любой truthy → clean=False; оба отсутствуют/false # НАЛИЧИЕ обременения. Любой 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 ОСТАЮТСЯ тут, # raw_extra → merge в listings.raw_payload. wall/floor type ОСТАЮТСЯ тут,
# НЕ пишем в listings.house_type (кросс-источниковый словарь грязный). # НЕ пишем в listings.house_type (кросс-источниковый словарь грязный).
# egrn area key — точное написание не подтверждено; пробуем несколько. # egrnData.area — ключ подтверждён на живых карточках 17.09 (#3252). В raw_payload
egrn_area = egrn.get("area") or egrn.get("rosreestrArea") or egrn.get("object_area") # кладём обёртку {status, value} целиком: status говорит, сошлась ли площадь
# ЕГРН с площадью объявления, и уже лежит в таком виде у 2156 строк.
egrn_area = egrn.get("area")
demand = _compact( demand = _compact(
{ {
"calls": pc.get("callsCount"), "calls": pc.get("callsCount"),