Merge remote-tracking branch 'origin/main' into fix/ptica-bulk-harvest-phase25-removal
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m42s
CI / backend-tests (pull_request) Successful in 7m11s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m42s
CI / backend-tests (pull_request) Successful in 7m11s
This commit is contained in:
commit
89b4a67508
6 changed files with 286 additions and 19 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_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-остановкой исполняет только
|
||||
# Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от
|
||||
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.
|
||||
|
|
|
|||
192
tradein-mvp/backend/tests/test_3252_domclick_card_fields.py
Normal file
192
tradein-mvp/backend/tests/test_3252_domclick_card_fields.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue