All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 4m56s
Ревью нашло, что миграция и код ловили РАЗНОЕ. Миграция брала базой предыдущую СЫРУЮ строку (lag), гейт — предыдущую ОСТАВЛЕННУЮ. На 1M→10M→1M→10M (цена 1M) lag-версия удаляла честную точку, на 1M→10M→1.05M→9.9M — не была идемпотентной (второй прогон доедал 9.9M). Теперь кандидаты выбирает PL/pgSQL-цикл, пошагово повторяющий drop_decimal_slips, а правило первой точки — отдельным INSERT..SELECT уже по ОСТАВШИМСЯ строкам. Правило первой точки — из прод-разбора: 12 из 20 остатков domklik это серии вида 330 000 → 3 300 000 (текущая цена 3 300 000) и 420 000 → 4 200 000 → 4 500 000, где дефектная точка ПЕРВАЯ и базы слева у неё нет. Свидетелей по-прежнему два: ×10 ко второй точке И подтверждение второй третьей-или-текущей-ценой. Решение по первой точке принимается по kept-серии, а не по сырой, — иначе гейт теряет идемпотентность (перебор ловит 1122 таких прогона). Идемпотентность доказана НА ГЕЙТЕ: property-тест gate(gate(s)) == gate(s) по всем сериям длины 2-6 (19 525 серий × 3 текущие цены). Фальсифицирован обеими поломками — сырая база даёт 136 красных прогонов, сырые соседи первой точки 1122. Раз SQL зеркалит гейт, свойство переносится на миграцию. Ещё в 286: третий свидетель ПРОТИВ удаления (цена подтверждена триггерной строкой того же объявления — значит она реально наблюдалась в listings.price_rub) и финальный шаг |diff_percent| > 100 → NULL по всем источникам, то же правило, что validate_diff_percent на записи. Ожидаемое число удалений в шапке — 35 + ~12 из двухсвидетельского предзамера, а не 76 (то была односвидетельская цифра). Прогон обеих фаз дважды с ROLLBACK — tradein-mvp/scripts/sql/286_dryrun.sql. yandex: проводка гейта снята как мёртвая. На том пути серия из двух точек, а свидетель последней — текущая цена лота, то есть она же сама: ветка по построению не могла выбросить ничего. Оставлен честный комментарий-потолок и ссылка на follow-up (отлов требует DELETE на следующем наблюдении). cian: после выброса точки соседу пересчитывается diff_percent (было только у domclick). domclick: цена листинга берётся RETURNING'ом у UPDATE вместо отдельного SELECT по PK, пересчёт вынесен в общий recompute_diff_percent с гейтом на пустую цену (ручной ingest кладёт price_changes из JSONL без валидации).
180 lines
7.8 KiB
Python
180 lines
7.8 KiB
Python
"""Дом-поля карточки Домклика доезжают до 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-гейте.
|
||
(в) Словарь: в material_walls едет КАНОН ДОМ.РФ («монолит»), а не сырая фраза
|
||
площадки («Монолитно-кирпичный») — иначе колонка становится двухсловарной.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from scraper_kit.providers.domclick.detail import (
|
||
DomClickDetailEnrichment,
|
||
canon_floor_type,
|
||
canon_wall_type,
|
||
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 у каждого оператора.
|
||
|
||
fetchone() → None: UPDATE listings отдаёт текущую цену через RETURNING — она нужна
|
||
гейту сдвигов разряда (#3376) как второй свидетель. Этой фикстуре цена не нужна,
|
||
«строки нет» гейт трактует как отсутствие свидетеля и серию не трогает; факт
|
||
«листинг найден» здесь по-прежнему приходит из rowcount, а не из RETURNING.
|
||
"""
|
||
|
||
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, fetchone=lambda: None)
|
||
|
||
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
|
||
# В raw_payload сырая фраза площадки остаётся как есть — канон только в колонках.
|
||
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
|
||
|
||
|
||
# ── (в) канонизация словаря (#3253) ───────────────────────────────────────────
|
||
# houses.material_walls на проде уже заполнена словарём ДОМ.РФ: кирпич 2662,
|
||
# железобетонная панель 2107, иное 1850, монолит 754. Домклик пишет свой словарь.
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
("Кирпичный", "кирпич"), # 2241 карточка на проде
|
||
("Панельный", "железобетонная панель"), # 1671
|
||
("Монолитный", "монолит"), # 2656
|
||
("Монолитно-кирпичный", "монолит"), # 773
|
||
("Блочный", "иное"),
|
||
("Деревянный", "иное"),
|
||
(" монолитный ", "монолит"), # регистр и пробелы не мешают
|
||
("Саманный", None), # незнакомое — сырьё в колонку НЕ пишем
|
||
("", None),
|
||
(None, None),
|
||
(42, None), # не-строка из JSON
|
||
],
|
||
)
|
||
def test_canon_wall_type_maps_to_domrf_dictionary(raw: object, expected: str | None) -> None:
|
||
assert canon_wall_type(raw) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
("Железобетонный", "Железобетонные"), # форма Домклика
|
||
("Железобетонные", "Железобетонные"), # форма, уже лежащая в колонке (avito)
|
||
("Деревянные", None),
|
||
(None, None),
|
||
],
|
||
)
|
||
def test_canon_floor_type_maps_to_column_form(raw: object, expected: str | None) -> None:
|
||
assert canon_floor_type(raw) == expected
|
||
|
||
|
||
def test_unknown_wall_type_is_not_written_to_houses() -> None:
|
||
"""Незнакомый тип стен не доезжает до houses — колонка не портится сырьём."""
|
||
e = DomClickDetailEnrichment(
|
||
item_id="t3253-3",
|
||
source_url="u",
|
||
raw_extra={"wall_type": "Саманный", "quarters_count": 12},
|
||
)
|
||
db = _FakeSession()
|
||
save_detail_enrichment(db, 558, e) # type: ignore[arg-type]
|
||
|
||
params = _house_call(db)
|
||
assert params["wall_type"] is None
|
||
assert params["quarters_count"] == 12 # остальные поля едут как обычно
|
||
|
||
|
||
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
|