fix(tradein/domclick): дом-поля карточки → houses, backfill total_units (#3253)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m4s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m4s
Парсер карточки Домклика читал houseInfo.info и складывал блок дома целиком в listings.raw_payload; в houses не переносил ничего. Замер 29.08: total_units = 0 у ВСЕХ источников, хотя quarters_count уже лежал в собранных payload'ах. save_detail_enrichment получает второй оператор — тот же fill-only паттерн, что у avito (#3036), связь через listings.house_id_fk: quarters_count → total_units, wall_type → material_walls, floor_type → material_floors. COALESCE в SET и в WHERE-гейте: непустое значение дома не затирается (у houses есть конкурирующие писатели — ДОМ.РФ капремонт, Houses Catalog). Серия дома, энергоэффективность и число подъездов остаются в raw_payload — колонок под них нет, схему не расширяем. Миграция 284 переливает то же самое задним числом из уже собранных payload'ов, только в пустые колонки, идемпотентно, под lock_timeout.
This commit is contained in:
parent
99f112db0b
commit
2532bcbe27
3 changed files with 275 additions and 1 deletions
|
|
@ -0,0 +1,98 @@
|
|||
-- 284_houses_total_units_from_raw.sql
|
||||
-- Перелить дом-поля карточек Домклика из listings.raw_payload в колонки houses (#3253).
|
||||
--
|
||||
-- ЧТО БЫЛО НЕ ТАК
|
||||
-- Парсер карточки Домклика читает блок дома (houseInfo.info) и кладёт его ЦЕЛИКОМ
|
||||
-- в listings.raw_payload: wall_type, floor_type, quarters_count, плюс серию дома,
|
||||
-- энергоэффективность и число подъездов. В колонки houses не переносил ничего.
|
||||
-- Замер 29.08.2026: houses.total_units = 0 у ВСЕХ источников без исключения, хотя
|
||||
-- у Домклика число квартир уже лежало в собранных payload'ах. Код перенос чинит
|
||||
-- для будущих карточек (providers/domclick/detail.py, тот же PR); эта миграция
|
||||
-- отрабатывает задним числом по уже собранному — ни одного запроса к площадке.
|
||||
--
|
||||
-- СВЯЗЬ ОБЪЯВЛЕНИЕ → ДОМ
|
||||
-- listings.house_id_fk — та же связь, которой пользуется код-перенос и соседний
|
||||
-- avito-путь (#3036). Другой связи нет: у SERP Домклика house_source/house_ext_id
|
||||
-- намеренно не заполняются (#3064), а domclick_building_guid из карточки лежит в
|
||||
-- raw_payload и ключом дома пока не является.
|
||||
--
|
||||
-- ЧТО НЕ ДЕЛАЕМ
|
||||
-- Серия дома, энергоэффективность и число подъездов — колонок под них в houses нет,
|
||||
-- схему ради этого не расширяем: остаются в raw_payload.
|
||||
--
|
||||
-- ИДЕМПОТЕНТНОСТЬ
|
||||
-- Каждый UPDATE пишет ТОЛЬКО туда, где колонка пуста (IS NULL в WHERE) — повторный
|
||||
-- прогон видит уже заполненное и трогает 0 строк. Непустое значение дома не
|
||||
-- затирается никогда: у houses есть конкурирующие писатели (ДОМ.РФ капремонт,
|
||||
-- Houses Catalog), карточка площадки не канон.
|
||||
--
|
||||
-- ВЫБОР ЗНАЧЕНИЯ ПРИ НЕСКОЛЬКИХ ОБЪЯВЛЕНИЯХ В ОДНОМ ДОМЕ
|
||||
-- DISTINCT ON (house_id_fk) ... ORDER BY house_id_fk, detail_enriched_at DESC NULLS LAST,
|
||||
-- id DESC — берём самую свежую обогащённую карточку. NULLS LAST обязателен: в
|
||||
-- Postgres DESC по умолчанию ставит NULL ПЕРВЫМИ, и без него победителем стала бы
|
||||
-- карточка вообще без отметки обогащения.
|
||||
BEGIN;
|
||||
-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках houses и без
|
||||
-- lock_timeout встанет в очередь за чужой сессией, утащив за собой запросы приложения.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
before_units bigint;
|
||||
before_walls bigint;
|
||||
before_floors bigint;
|
||||
after_units bigint;
|
||||
after_walls bigint;
|
||||
after_floors bigint;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE total_units IS NOT NULL),
|
||||
count(*) FILTER (WHERE material_walls IS NOT NULL),
|
||||
count(*) FILTER (WHERE material_floors IS NOT NULL)
|
||||
INTO before_units, before_walls, before_floors
|
||||
FROM houses;
|
||||
|
||||
RAISE NOTICE 'houses ДО: total_units=%, material_walls=%, material_floors=%',
|
||||
before_units, before_walls, before_floors;
|
||||
|
||||
WITH src AS (
|
||||
SELECT DISTINCT ON (l.house_id_fk)
|
||||
l.house_id_fk AS house_id,
|
||||
NULLIF(l.raw_payload ->> 'quarters_count', '') AS quarters_count,
|
||||
NULLIF(l.raw_payload ->> 'wall_type', '') AS wall_type,
|
||||
NULLIF(l.raw_payload ->> 'floor_type', '') AS floor_type
|
||||
FROM listings l
|
||||
WHERE l.source = 'domklik'
|
||||
AND l.house_id_fk IS NOT NULL
|
||||
AND l.raw_payload IS NOT NULL
|
||||
AND (l.raw_payload ? 'quarters_count'
|
||||
OR l.raw_payload ? 'wall_type'
|
||||
OR l.raw_payload ? 'floor_type')
|
||||
ORDER BY l.house_id_fk, l.detail_enriched_at DESC NULLS LAST, l.id DESC
|
||||
)
|
||||
UPDATE houses h SET
|
||||
total_units = COALESCE(
|
||||
h.total_units,
|
||||
CASE WHEN s.quarters_count ~ '^[0-9]+$'
|
||||
THEN CAST(s.quarters_count AS integer) END
|
||||
),
|
||||
material_walls = COALESCE(h.material_walls, s.wall_type),
|
||||
material_floors = COALESCE(h.material_floors, s.floor_type)
|
||||
FROM src s
|
||||
WHERE h.id = s.house_id
|
||||
AND (h.total_units IS NULL AND s.quarters_count ~ '^[0-9]+$'
|
||||
OR h.material_walls IS NULL AND s.wall_type IS NOT NULL
|
||||
OR h.material_floors IS NULL AND s.floor_type IS NOT NULL);
|
||||
|
||||
SELECT count(*) FILTER (WHERE total_units IS NOT NULL),
|
||||
count(*) FILTER (WHERE material_walls IS NOT NULL),
|
||||
count(*) FILTER (WHERE material_floors IS NOT NULL)
|
||||
INTO after_units, after_walls, after_floors
|
||||
FROM houses;
|
||||
|
||||
RAISE NOTICE 'houses ПОСЛЕ: total_units=% (+%), material_walls=% (+%), material_floors=% (+%)',
|
||||
after_units, after_units - before_units,
|
||||
after_walls, after_walls - before_walls,
|
||||
after_floors, after_floors - before_floors;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
114
tradein-mvp/backend/tests/test_3253_domclick_house_fields.py
Normal file
114
tradein-mvp/backend/tests/test_3253_domclick_house_fields.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Дом-поля карточки Домклика доезжают до houses (#3253).
|
||||
|
||||
Парсер читал houseInfo.info (тип стен/перекрытий, число квартир) и складывал всё
|
||||
в listings.raw_payload, а в houses не переносил: на проде 400 домов-domklik против
|
||||
3061 объявления, а houses.total_units = 0 у ВСЕХ источников при том, что
|
||||
quarters_count уже лежал в собранных payload'ах.
|
||||
|
||||
(а) Значение: карточка с houseInfo → второй оператор получает total_units /
|
||||
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
|
||||
(б) Непустое значение дома не затирается пустым из карточки — COALESCE в обе
|
||||
стороны: и в SET, и в WHERE-гейте.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.providers.domclick.detail import (
|
||||
DomClickDetailEnrichment,
|
||||
parse_detail_html,
|
||||
save_detail_enrichment,
|
||||
)
|
||||
|
||||
_STATE = {
|
||||
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
|
||||
"houseInfo": {
|
||||
"info": {
|
||||
"wallType": "Кирпично-монолитный",
|
||||
"floorType": "Железобетонный",
|
||||
"quartersCount": 168,
|
||||
"entranceCount": 4,
|
||||
"buildingSeries": "II-68",
|
||||
"energyEfficiency": "B",
|
||||
}
|
||||
},
|
||||
}
|
||||
_HTML = (
|
||||
"<html><body><script>window.__SSR_STATE__ = "
|
||||
f"{json.dumps(_STATE, ensure_ascii=False)};</script></body></html>"
|
||||
)
|
||||
|
||||
|
||||
class _FakeNested:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Пишет все execute(stmt, params); rowcount=1 у каждого оператора."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||
self.calls.append((str(stmt), params or {}))
|
||||
return SimpleNamespace(rowcount=1)
|
||||
|
||||
def begin_nested(self) -> _FakeNested:
|
||||
return _FakeNested()
|
||||
|
||||
def commit(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _house_call(db: _FakeSession) -> dict[str, Any]:
|
||||
for sql, params in db.calls:
|
||||
if "UPDATE houses" in sql:
|
||||
return params
|
||||
raise AssertionError(f"UPDATE houses не исполнен; операторы: {[s[:40] for s, _ in db.calls]}")
|
||||
|
||||
|
||||
def test_house_fields_reach_houses_upsert() -> None:
|
||||
"""(а) Значения из фикстуры доезжают до апсерта дома."""
|
||||
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
||||
assert e.raw_extra["quarters_count"] == 168
|
||||
assert e.raw_extra["wall_type"] == "Кирпично-монолитный"
|
||||
|
||||
db = _FakeSession()
|
||||
save_detail_enrichment(db, 555, e) # type: ignore[arg-type]
|
||||
|
||||
params = _house_call(db)
|
||||
assert params["quarters_count"] == 168
|
||||
assert params["wall_type"] == "Кирпично-монолитный"
|
||||
assert params["floor_type"] == "Железобетонный"
|
||||
assert params["lid"] == 555
|
||||
|
||||
|
||||
def test_empty_card_does_not_overwrite_house() -> None:
|
||||
"""(б) Карточка без дом-полей вообще не трогает houses."""
|
||||
db = _FakeSession()
|
||||
e = DomClickDetailEnrichment(item_id="t3253-2", source_url="u", raw_extra={"egrn_area": 42.0})
|
||||
save_detail_enrichment(db, 556, e) # type: ignore[arg-type]
|
||||
|
||||
assert not [s for s, _ in db.calls if "UPDATE houses" in s]
|
||||
|
||||
|
||||
def test_coalesce_protects_filled_columns() -> None:
|
||||
"""(б) SET и WHERE-гейт оба COALESCE-семантика: пустым непустое не затирается."""
|
||||
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
|
||||
db = _FakeSession()
|
||||
save_detail_enrichment(db, 557, e) # type: ignore[arg-type]
|
||||
|
||||
sql = next(s for s, _ in db.calls if "UPDATE houses" in s)
|
||||
for col in ("total_units", "material_walls", "material_floors"):
|
||||
assert f"COALESCE(h.{col}," in sql
|
||||
assert f"h.{col} IS NULL" in sql
|
||||
|
|
@ -587,6 +587,58 @@ async def fetch_detail(
|
|||
|
||||
|
||||
# ── save_detail_enrichment ──────────────────────────────────────────────────────
|
||||
# #3253: дом-поля карточки → houses (fill-only), тот же второй оператор, что у avito
|
||||
# (_HOUSE_PARAMS_SQL в providers/avito/detail.py, #3036). Связь — listings.house_id_fk.
|
||||
# Колонок под building_series / energy_efficiency / entrance_count в houses НЕТ —
|
||||
# они намеренно остаются только в listings.raw_payload, схему тут не расширяем.
|
||||
_HOUSE_PARAMS_SQL = text("""
|
||||
UPDATE houses h SET
|
||||
total_units = COALESCE(h.total_units, CAST(:quarters_count AS integer)),
|
||||
material_walls = COALESCE(h.material_walls, CAST(:wall_type AS text)),
|
||||
material_floors = COALESCE(h.material_floors, CAST(:floor_type AS text))
|
||||
FROM listings l
|
||||
WHERE l.id = CAST(:lid AS bigint)
|
||||
AND l.house_id_fk = h.id
|
||||
AND (h.total_units IS NULL AND CAST(:quarters_count AS integer) IS NOT NULL
|
||||
OR h.material_walls IS NULL AND CAST(:wall_type AS text) IS NOT NULL
|
||||
OR h.material_floors IS NULL AND CAST(:floor_type AS text) IS NOT NULL)
|
||||
""")
|
||||
|
||||
|
||||
def _fill_house_params_from_detail(
|
||||
db: Session, listing_id: int, e: DomClickDetailEnrichment
|
||||
) -> int:
|
||||
"""Заполнить пустые дом-поля дома этого листинга значениями с карточки (#3253).
|
||||
|
||||
Возвращает число обновлённых домов (0 — листинг без house_id_fk, у дома всё уже
|
||||
заполнено, либо карточка про дом ничего не сказала). Ошибка оператора глушится
|
||||
под SAVEPOINT с warning: обогащение листинга важнее дом-полей.
|
||||
"""
|
||||
raw = e.raw_extra or {}
|
||||
quarters_count = _to_int(raw.get("quarters_count"))
|
||||
wall_type = raw.get("wall_type")
|
||||
floor_type = raw.get("floor_type")
|
||||
if quarters_count is None and wall_type is None and floor_type is None:
|
||||
return 0
|
||||
try:
|
||||
with db.begin_nested():
|
||||
r = db.execute(
|
||||
_HOUSE_PARAMS_SQL,
|
||||
{
|
||||
"lid": listing_id,
|
||||
"quarters_count": quarters_count,
|
||||
"wall_type": wall_type,
|
||||
"floor_type": floor_type,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"domclick_detail: house params not saved for listing_id=%s",
|
||||
listing_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
return int(r.rowcount or 0)
|
||||
|
||||
|
||||
def save_detail_enrichment(
|
||||
|
|
@ -600,7 +652,13 @@ def save_detail_enrichment(
|
|||
raw_payload || raw_extra, detail_enriched_at=NOW() WHERE id=listing_id.
|
||||
sale_type проходит через canon_sale_type — в колонке общий с cian/avito
|
||||
словарь, а не сырая русская фраза Домклика (#2674, миграция 220).
|
||||
2. Для каждой записи priceHistory — INSERT INTO offer_price_history
|
||||
2. Дом-поля карточки → houses через listings.house_id_fk, fill-only (#3253):
|
||||
quarters_count → total_units, wall_type → material_walls, floor_type →
|
||||
material_floors. COALESCE(houses.X, новое) — непустое значение дома НЕ
|
||||
затирается: у houses есть конкурирующие писатели (ДОМ.РФ капремонт,
|
||||
Houses Catalog), карточка площадки не канон. Серия дома, энергоэффективность
|
||||
и число подъездов остаются в raw_payload — колонок под них в houses нет.
|
||||
3. Для каждой записи priceHistory — INSERT INTO offer_price_history
|
||||
(ON CONFLICT DO NOTHING → идемпотентно). source='domklik'.
|
||||
|
||||
Snapshot (upsert_listing_snapshot) НЕ пишем: enrichment не несёт текущую цену,
|
||||
|
|
@ -684,6 +742,10 @@ def save_detail_enrichment(
|
|||
exc,
|
||||
)
|
||||
|
||||
# Дом-поля — только если листинг нашёлся: без строки в listings нет и house_id_fk.
|
||||
if result.rowcount > 0:
|
||||
_fill_house_params_from_detail(db, listing_id, e)
|
||||
|
||||
db.commit()
|
||||
found = result.rowcount > 0
|
||||
if not found:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue