fix(tradein/domclick): собственники, нулевые площади и жилая площадь карточки перестали теряться (#3252)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m55s

Сверка парсера с живым __SSR_STATE__ трёх карточек (17.09) нашла три тихие дыры:

1. owners_count. egrnData отдаёт поля ЕГРН обёрткой {status, value}; int() от
   словаря молча давал None. Прод: 0 из ~2970 карточек, обогащённых с 29.08,
   против 5075 из 6296 у ручного прогона 18.07, который обёртку разворачивал.
   Ключ egrnData.area подтверждён — гадания rosreestrArea/object_area удалены,
   в raw_payload обёртка остаётся как есть (status = сверка с объявлением).
2. livingArea/kitchenArea = 0 у Домклика значит «не указано», а в колонку ложился
   0.00 и затирал известное значение. Прод: 303 кухни и 84 жилые площади = 0,
   только domklik. Разбор: _pos_float; строки: миграция 320 ставит NULL.
3. Жилую площадь и балконы стирал переобход выдачи: апсерт scraper_kit.base писал
   их сырым EXCLUDED, а SERP Домклика и Авито этих полей не отдаёт. Прод: domklik,
   переобойдённые после обогащения, living_area_m2 0 из 5217 (без переобхода
   2990 из 4058); avito 1 из 8639 (4122 из 6505). COALESCE в SET, в гейте #2992
   и на reconcile-пути.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 13:25:29 +05:00
parent 9cc23c6698
commit 6e8c261ff8
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": "Свободная продажа"},
"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

View file

@ -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

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_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,
)

View file

@ -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"),