Compare commits
No commits in common. "e48b4a47d709e3ba632b1e5932c7af4d81a99b95" and "0e4a227a5aa165c3fa00dc6befa028764f50279a" have entirely different histories.
e48b4a47d7
...
0e4a227a5a
3 changed files with 1 additions and 446 deletions
|
|
@ -1,131 +0,0 @@
|
||||||
-- 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.
|
|
||||||
--
|
|
||||||
-- СЛОВАРЬ СТЕН И ПЕРЕКРЫТИЙ — НЕ СЫРЬЁ ПЛОЩАДКИ
|
|
||||||
-- houses.material_walls уже заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013):
|
|
||||||
-- кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. У Домклика в
|
|
||||||
-- raw_payload свой словарь. Лить его как есть — сделать колонку двухсловарной, и
|
|
||||||
-- фильтр по одному значению перестанет видеть половину строк (ровно то, что уже
|
|
||||||
-- случилось с sale_type до #2674). Тот же CASE стоит в коде-переносе
|
|
||||||
-- (canon_wall_type / canon_floor_type, providers/domclick/detail.py).
|
|
||||||
--
|
|
||||||
-- Домклик → houses.material_walls
|
|
||||||
-- Кирпичный → кирпич
|
|
||||||
-- Панельный → железобетонная панель
|
|
||||||
-- Монолитный → монолит
|
|
||||||
-- Монолитно-кирпичный → монолит (несущий каркас монолитный, кирпич —
|
|
||||||
-- заполнение; смешанного класса у ДОМ.РФ
|
|
||||||
-- нет, «иное» потеряло бы конструктив)
|
|
||||||
-- Блочный, Деревянный → иное
|
|
||||||
-- всё прочее → NULL (CASE без ELSE; сырьё не пишем)
|
|
||||||
--
|
|
||||||
-- Домклик → houses.material_floors
|
|
||||||
-- Железобетонный → Железобетонные (форма, уже лежащая в колонке — 26
|
|
||||||
-- Железобетонные → Железобетонные строк от avito)
|
|
||||||
-- всё прочее → NULL
|
|
||||||
--
|
|
||||||
-- ИДЕМПОТЕНТНОСТЬ
|
|
||||||
-- Каждый 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,
|
|
||||||
CASE lower(trim(NULLIF(l.raw_payload ->> 'wall_type', '')))
|
|
||||||
WHEN 'кирпичный' THEN 'кирпич'
|
|
||||||
WHEN 'панельный' THEN 'железобетонная панель'
|
|
||||||
WHEN 'монолитный' THEN 'монолит'
|
|
||||||
WHEN 'монолитно-кирпичный' THEN 'монолит'
|
|
||||||
WHEN 'блочный' THEN 'иное'
|
|
||||||
WHEN 'деревянный' THEN 'иное'
|
|
||||||
END AS wall_type,
|
|
||||||
CASE lower(trim(NULLIF(l.raw_payload ->> 'floor_type', '')))
|
|
||||||
WHEN 'железобетонный' THEN 'Железобетонные'
|
|
||||||
WHEN 'железобетонные' THEN 'Железобетонные'
|
|
||||||
END 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;
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
"""Дом-поля карточки Домклика доезжают до 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 у каждого оператора."""
|
|
||||||
|
|
||||||
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
|
|
||||||
# В 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
|
|
||||||
|
|
@ -109,36 +109,6 @@ _SALE_TYPE_MAP: dict[str, str] = {
|
||||||
"альтернатива": "alternative",
|
"альтернатива": "alternative",
|
||||||
}
|
}
|
||||||
|
|
||||||
# houses.material_walls УЖЕ заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013): на
|
|
||||||
# проде кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. У Домклика
|
|
||||||
# словарь свой — Монолитный 2656, Кирпичный 2241, Панельный 1671, Монолитно-кирпичный
|
|
||||||
# 773. Написать его сырьём = сделать колонку двухсловарной, и `WHERE material_walls =
|
|
||||||
# 'монолит'` перестанет видеть весь Домклик — ровно те же грабли, что sale_type в
|
|
||||||
# #2674. Поэтому маппим в ТОТ словарь, который в колонке уже лежит.
|
|
||||||
# «Монолитно-кирпичный» → монолит: несущий каркас монолитный, кирпич — заполнение и
|
|
||||||
# фасад; отдельного класса под смешанный тип у ДОМ.РФ нет, а «иное» выбросило бы
|
|
||||||
# известный конструктив в корзину «неизвестно». Блочный/деревянный → иное: у ДОМ.РФ
|
|
||||||
# для них тоже отдельного значения нет, но и терять нечего — это и есть «иное».
|
|
||||||
_WALL_TYPE_MAP: dict[str, str] = {
|
|
||||||
"кирпичный": "кирпич",
|
|
||||||
"панельный": "железобетонная панель",
|
|
||||||
"монолитный": "монолит",
|
|
||||||
"монолитно-кирпичный": "монолит",
|
|
||||||
"блочный": "иное",
|
|
||||||
"деревянный": "иное",
|
|
||||||
}
|
|
||||||
|
|
||||||
# houses.material_floors: в колонке ровно одна форма — «Железобетонные» (26 строк,
|
|
||||||
# avito). Домклик пишет «Железобетонный». Приводим к форме, которая уже в колонке.
|
|
||||||
_FLOOR_TYPE_MAP: dict[str, str] = {
|
|
||||||
"железобетонный": "Железобетонные",
|
|
||||||
"железобетонные": "Железобетонные",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Незнакомое значение словаря площадки логируем ОДИН раз на процесс: новый тип стен
|
|
||||||
# должно быть видно в логах, но не строкой на каждую карточку.
|
|
||||||
_WARNED_UNKNOWN_MATERIAL: set[str] = set()
|
|
||||||
|
|
||||||
|
|
||||||
# ── DomClickDetailEnrichment ──────────────────────────────────────────────────
|
# ── DomClickDetailEnrichment ──────────────────────────────────────────────────
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -344,47 +314,6 @@ def canon_sale_type(sale_type: str | None) -> str | None:
|
||||||
return _SALE_TYPE_MAP.get(sale_type.strip().lower())
|
return _SALE_TYPE_MAP.get(sale_type.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
def _canon_material(value: Any, mapping: dict[str, str], field: str) -> str | None:
|
|
||||||
"""Общая механика canon_wall_type/canon_floor_type: словарь площадки → словарь колонки.
|
|
||||||
|
|
||||||
Незнакомое (и не-строка, и пустая строка) → None: сырьё в колонку не попадает
|
|
||||||
никогда. Про каждое незнакомое значение — warning раз на процесс.
|
|
||||||
"""
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return None
|
|
||||||
key = value.strip().lower()
|
|
||||||
canon = mapping.get(key)
|
|
||||||
if canon is None and key not in _WARNED_UNKNOWN_MATERIAL:
|
|
||||||
_WARNED_UNKNOWN_MATERIAL.add(key)
|
|
||||||
logger.warning(
|
|
||||||
"domclick_detail: неизвестный %s=%r — houses не пишем (сырьё в колонку "
|
|
||||||
"не кладём); добавь значение в карту, если оно валидное",
|
|
||||||
field,
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
return canon
|
|
||||||
|
|
||||||
|
|
||||||
def canon_wall_type(wall_type: Any) -> str | None:
|
|
||||||
"""Тип стен Домклика → словарь houses.material_walls (ДОМ.РФ, #3253).
|
|
||||||
|
|
||||||
Кирпичный→кирпич, Панельный→железобетонная панель, Монолитный→монолит,
|
|
||||||
Монолитно-кирпичный→монолит, Блочный/Деревянный→иное. Незнакомое → None.
|
|
||||||
|
|
||||||
Стоит на границе записи в houses (как canon_sale_type на границе записи в
|
|
||||||
listings, #2674): в raw_payload сырая фраза площадки остаётся как есть.
|
|
||||||
"""
|
|
||||||
return _canon_material(wall_type, _WALL_TYPE_MAP, "wall_type")
|
|
||||||
|
|
||||||
|
|
||||||
def canon_floor_type(floor_type: Any) -> str | None:
|
|
||||||
"""Тип перекрытий Домклика → форма, лежащая в houses.material_floors (#3253).
|
|
||||||
|
|
||||||
Железобетонный/Железобетонные → «Железобетонные». Незнакомое → None.
|
|
||||||
"""
|
|
||||||
return _canon_material(floor_type, _FLOOR_TYPE_MAP, "floor_type")
|
|
||||||
|
|
||||||
|
|
||||||
def _compact(d: dict[str, Any]) -> dict[str, Any]:
|
def _compact(d: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Выкидывает None-значения, чтобы raw_extra оставался компактным."""
|
"""Выкидывает None-значения, чтобы raw_extra оставался компактным."""
|
||||||
return {k: v for k, v in d.items() if v is not None}
|
return {k: v for k, v in d.items() if v is not None}
|
||||||
|
|
@ -658,62 +587,6 @@ async def fetch_detail(
|
||||||
|
|
||||||
|
|
||||||
# ── save_detail_enrichment ──────────────────────────────────────────────────────
|
# ── 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).
|
|
||||||
|
|
||||||
Тип стен и перекрытий проходят через canon_wall_type/canon_floor_type: в колонках
|
|
||||||
houses лежит словарь ДОМ.РФ, а не словарь площадки. Незнакомое значение → None,
|
|
||||||
т.е. дом-поле просто не пишется — сырая фраза Домклика остаётся в raw_payload.
|
|
||||||
|
|
||||||
Возвращает число обновлённых домов (0 — листинг без house_id_fk, у дома всё уже
|
|
||||||
заполнено, либо карточка про дом ничего не сказала). Ошибка оператора глушится
|
|
||||||
под SAVEPOINT с warning: обогащение листинга важнее дом-полей.
|
|
||||||
"""
|
|
||||||
raw = e.raw_extra or {}
|
|
||||||
quarters_count = _to_int(raw.get("quarters_count"))
|
|
||||||
wall_type = canon_wall_type(raw.get("wall_type"))
|
|
||||||
floor_type = canon_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(
|
def save_detail_enrichment(
|
||||||
|
|
@ -727,16 +600,7 @@ def save_detail_enrichment(
|
||||||
raw_payload || raw_extra, detail_enriched_at=NOW() WHERE id=listing_id.
|
raw_payload || raw_extra, detail_enriched_at=NOW() WHERE id=listing_id.
|
||||||
sale_type проходит через canon_sale_type — в колонке общий с cian/avito
|
sale_type проходит через canon_sale_type — в колонке общий с cian/avito
|
||||||
словарь, а не сырая русская фраза Домклика (#2674, миграция 220).
|
словарь, а не сырая русская фраза Домклика (#2674, миграция 220).
|
||||||
2. Дом-поля карточки → houses через listings.house_id_fk, fill-only (#3253):
|
2. Для каждой записи priceHistory — INSERT INTO offer_price_history
|
||||||
quarters_count → total_units, wall_type → material_walls, floor_type →
|
|
||||||
material_floors. Стены/перекрытия — через canon_wall_type/canon_floor_type:
|
|
||||||
в колонках houses словарь ДОМ.РФ, сырую фразу площадки туда не кладём (та же
|
|
||||||
болезнь, что sale_type до #2674). COALESCE(houses.X, новое) — непустое
|
|
||||||
значение дома НЕ затирается: у houses есть конкурирующие писатели (ДОМ.РФ
|
|
||||||
капремонт, Houses Catalog), карточка площадки не канон. Серия дома,
|
|
||||||
энергоэффективность и число подъездов остаются в raw_payload — колонок под
|
|
||||||
них в houses нет.
|
|
||||||
3. Для каждой записи priceHistory — INSERT INTO offer_price_history
|
|
||||||
(ON CONFLICT DO NOTHING → идемпотентно). source='domklik'.
|
(ON CONFLICT DO NOTHING → идемпотентно). source='domklik'.
|
||||||
|
|
||||||
Snapshot (upsert_listing_snapshot) НЕ пишем: enrichment не несёт текущую цену,
|
Snapshot (upsert_listing_snapshot) НЕ пишем: enrichment не несёт текущую цену,
|
||||||
|
|
@ -820,10 +684,6 @@ def save_detail_enrichment(
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Дом-поля — только если листинг нашёлся: без строки в listings нет и house_id_fk.
|
|
||||||
if result.rowcount > 0:
|
|
||||||
_fill_house_params_from_detail(db, listing_id, e)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
found = result.rowcount > 0
|
found = result.rowcount > 0
|
||||||
if not found:
|
if not found:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue