fix(tradein/domclick): sale_type перестаёт быть третьим словарём (#2674)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
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 3m0s

Замер прода 2026-08-06 (SELECT source, sale_type, count(*) FROM listings):
  avito   : free 2976 · assignment 46 · NULL 45020
  cian    : free 14800 · fz214 4673 · alternative 1327 · dupt 409 ·
            investment 64 · dzhsk 3 · pdkp 1 · NULL 463
  domklik : «Свободная продажа» 5546 · «Альтернатива» 553 ·
            «Первичная продажа» 2 · NULL 493
  n1/yandex: колонку не пишут вовсе (382 и 16721 NULL)
Итого 6101 русская строка против 24 299 английских: `WHERE sale_type='free'`
не видит ни одного Домклика, `GROUP BY sale_type` разносит одну и ту же
свободную продажу на две корзины.

ВАЖНОЕ ОГРАНИЧЕНИЕ, найденное по ходу: читателей у колонки НОЛЬ. Grep по
всему репозиторию даёт только писателей (providers/{avito,cian,domclick},
base.py, scripts/ingest_domclick_jsonl.py) и DDL — ни фильтра, ни агрегата,
ни поля схемы API, ни элемента фронтенда. Поэтому канон целиком тут НЕ
вводится: выбирать между cian-токеном dupt и avito-токеном assignment
(409 против 46 строк, оба = переуступка) без единого читателя — монетка.
Чинится ровно объективно сломанное: один писатель клал сырую русскую фразу
там, где два других уже кладут английский токен.

Проверка смысла перед маппингом (cross-tab sale_type × listing_segment):
это одна ось — структура сделки. Русские значения Домклика на 100%
vtorichka и совпадают с полюсами cian free/alternative; хвост cian
fz214/dupt/investment/dzhsk/pdkp — 5089 из 5107 строк novostroyki, т.е.
ПОДмножество той же оси (какой договор на первичке), а не другой признак.
Конфликта смыслов нет, склейка free/alternative безопасна.

canon_sale_type стоит на ЕДИНОЙ границе записи (save_detail_enrichment), а
не в парсере: в listings пишут три производителя одного dataclass'а — парсер
карточки, ingest_domclick_jsonl.py и domclick_local_runner.py (именно он
налил 6101 русскую строку 2026-07-18).

«Первичная продажа» (2 строки) осознанно НЕ маппится: у cian первичка
закодирована типом ДОГОВОРА, а маркетинговая метка Домклика договор не
называет — угадывать нельзя. Строки остаются как есть, факт зафиксирован в
COMMENT ON COLUMN вместе со всем фактическим словарём по источникам.

Миграция 220 (backfill 5546 + 553 = 6099 строк, счётчики сняты на проде ДО
применения; повторный прогон — 0 строк).

Refs #2674
This commit is contained in:
bot-backend 2026-08-06 10:47:09 +05:00
parent fb5ec56a54
commit e22e708031
3 changed files with 148 additions and 2 deletions

View file

@ -0,0 +1,72 @@
-- 220_listings_sale_type_domclick_dialect.sql
-- Issue #2674: listings.sale_type заполнялся тремя разными диалектами.
--
-- ЗАМЕР ПРОДА 2026-08-06 (SELECT source, sale_type, count(*) ... GROUP BY 1,2):
-- avito : free 2976 · assignment 46 · NULL 45020
-- cian : free 14800 · fz214 4673 · alternative 1327 · dupt 409 ·
-- investment 64 · dzhsk 3 · pdkp 1 · NULL 463
-- domklik : «Свободная продажа» 5546 · «Альтернатива» 553 ·
-- «Первичная продажа» 2 · NULL 493
-- n1 : NULL 382 · yandex: NULL 16721 (колонку не пишут вовсе)
-- Итого 6101 русская строка против 24 299 английских.
--
-- ЧТО ЭТО ЛОМАЕТ: `WHERE sale_type = 'free'` не видит ни одной строки Домклика,
-- `GROUP BY sale_type` разносит одну и ту же «свободную продажу» на две корзины.
--
-- ЧЕГО ЭТО НЕ ЛОМАЕТ СЕЙЧАС: читателей у колонки НОЛЬ. Grep по всему репозиторию
-- (2026-08-06) даёт только писателей (providers/{avito,cian,domclick}, base.py,
-- scripts/ingest_domclick_jsonl.py) и DDL — ни одного фильтра, агрегата, поля
-- схемы API или элемента фронтенда. Поэтому здесь НЕ вводится «канон» целиком:
-- выбирать между cian-токеном `dupt` и avito-токеном `assignment` (409 vs 46
-- строк, оба = переуступка права требования) без единого читателя — монетка.
-- Чинится ровно то, что сломано объективно: один писатель клал сырую русскую
-- фразу там, где два других уже кладут английский токен. Остаток словарей
-- (dupt/assignment, первично-договорный хвост cian fz214/dzhsk/pdkp/investment)
-- задокументирован в COMMENT и ждёт первого настоящего читателя.
--
-- ПРОВЕРКА СМЫСЛА (перед маппингом, требование #2674): это одна и та же ось —
-- структура сделки. cross-tab sale_type × listing_segment на проде:
-- domklik «Свободная продажа»/«Альтернатива» — 100% vtorichka;
-- cian free/alternative — 8545/1185 vtorichka (те же два полюса);
-- cian fz214/dupt/investment/dzhsk/pdkp — 5089 из 5107 строк novostroyki,
-- т.е. это ПОДмножество той же оси (какой договор на первичке), а не другой
-- признак. Конфликта смыслов нет → склейка free/alternative безопасна.
--
-- BACKFILL (счётчики сняты на проде ДО применения, 2026-08-06):
-- «Свободная продажа» → free : 5546 строк
-- «Альтернатива» → alternative : 553 строки
-- ИТОГО ожидаемо тронуто: 6099 строк.
-- «Первичная продажа» (2 строки) НЕ трогаем: у cian первичка кодируется типом
-- ДОГОВОРА (fz214/dzhsk/pdkp/dupt), маркетинговая метка Домклика договор не
-- называет — угадывать нельзя. Строки остаются как есть, видны в COMMENT.
--
-- Писатель починен в том же PR: providers/domclick/detail.py canon_sale_type()
-- на границе save_detail_enrichment (её проходят все три производителя строк —
-- парсер карточки, ingest_domclick_jsonl.py, domclick_local_runner.py).
--
-- Idempotent: повторный прогон — 0 строк (русских значений уже не осталось).
BEGIN;
UPDATE listings
SET sale_type = 'free'
WHERE source = 'domklik'
AND sale_type = 'Свободная продажа';
UPDATE listings
SET sale_type = 'alternative'
WHERE source = 'domklik'
AND sale_type = 'Альтернатива';
COMMENT ON COLUMN listings.sale_type IS
'Структура сделки. Единого enum НЕТ — источники пишут свои словари: '
'cian — free/alternative/dupt/fz214/investment/dzhsk/pdkp (тип договора, '
'хвост fz214/dzhsk/pdkp/investment/dupt почти целиком novostroyki); '
'avito — free/alternative/auction/assignment (assignment = cian dupt, '
'токены НЕ сведены: #2674 не выбирал победителя, читателей у колонки нет); '
'domklik — free/alternative после миграции 220 (до неё русские фразы), плюс '
'2 исторические строки «Первичная продажа» без соответствия в чужих словарях; '
'yandex и n1 колонку не пишут вовсе (100% NULL). Перед любым фильтром по '
'этой колонке сверьтесь с фактическим GROUP BY source, sale_type.';
COMMIT;

View file

@ -42,6 +42,7 @@ from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.providers.domclick.detail import (
DomClickDetailEnrichment,
_extract_ssr_state,
canon_sale_type,
fetch_detail,
parse_detail_html,
save_detail_enrichment,
@ -453,7 +454,7 @@ def test_save_detail_enrichment_writes_columns() -> None:
kitchen_area_m2=12.3,
balconies_count=2,
has_balcony=True,
sale_type="свободная",
sale_type="Свободная продажа",
owners_count=1,
encumbrances_clean=False,
year_built=2015,
@ -475,6 +476,8 @@ def test_save_detail_enrichment_writes_columns() -> None:
assert update_params["balconies_count"] == 2
assert update_params["has_balcony"] is True
assert update_params["encumbrances_clean"] is False
# #2674: в колонку уезжает общий с cian/avito токен, а не русская фраза.
assert update_params["sale_type"] == "free"
assert "монолит" in update_params["raw_extra"]
# price-history INSERT с ON CONFLICT (идемпотентность)
@ -597,3 +600,45 @@ def test_clamp_diff_percent_none() -> None:
def test_clamp_diff_percent_bool_treated_as_none() -> None:
assert clamp_diff_percent(True) is None
assert clamp_diff_percent(False) is None
# ── canon_sale_type (#2674) ───────────────────────────────────────────────────
# Кейсы — ФАКТИЧЕСКИЙ словарь прода на 2026-08-06:
# SELECT source, sale_type, count(*) FROM listings GROUP BY 1,2 →
# domklik: «Свободная продажа» 5546 · «Альтернатива» 553 · «Первичная продажа» 2.
# Токены-цели — те, что уже пишут cian (free 14800 / alternative 1327) и
# avito (free 2976). Третий словарь не заводим.
@pytest.mark.parametrize(
("raw", "expected"),
[
("Свободная продажа", "free"),
("Альтернатива", "alternative"),
# Регистр/пробелы источника не должны плодить новые значения в колонке.
("свободная продажа", "free"),
(" Альтернатива ", "alternative"),
# Соответствия в чужих словарях нет: первичка у cian — это ТИП ДОГОВОРА
# (fz214/dzhsk/pdkp/dupt), маркетинговая метка его не называет. Не угадываем.
("Первичная продажа", None),
# Незнакомое/пустое → None → COALESCE в UPDATE не затрёт существующее.
("Что-то новое", None),
("", None),
(None, None),
],
)
def test_canon_sale_type_covers_every_prod_value(raw: str | None, expected: str | None) -> None:
assert canon_sale_type(raw) == expected
def test_canon_sale_type_is_idempotent_on_canon_tokens() -> None:
"""Токен уже каноничен (повторное обогащение той же строки) → не теряем его."""
assert canon_sale_type("free") is None # чужой токен на вход не подаётся…
# …а вот через save_detail_enrichment COALESCE защищает уже записанное:
db = MagicMock()
db.execute.return_value.rowcount = 1
e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL, sale_type="Первичная продажа")
save_detail_enrichment(db, 1, e)
params = db.execute.call_args_list[0][0][1]
assert params["sale_type"] is None
assert "COALESCE(:sale_type, sale_type)" in str(db.execute.call_args_list[0][0][0])

View file

@ -94,6 +94,20 @@ _RENOVATION_MAP: dict[str, str] = {
"предчистовая": "needs_repair",
}
# listings.sale_type: cian и avito пишут английские токены, DomClick до #2674 клал
# сырую русскую фразу legalOptions.saleType — на проде 6101 строка русским против
# 24 299 английских, т.е. фильтр `WHERE sale_type = 'free'` терял весь Домклик.
# Маппим ТОЛЬКО те два значения, для которых токен уже существует у обоих соседей
# (cian free/alternative, avito free/alternative) — третий словарь не выдумываем.
# «Первичная продажа» (2 строки на проде) осознанно НЕ маппится: у cian первичка
# закодирована типом ДОГОВОРА (fz214/dzhsk/pdkp/dupt), а маркетинговая метка
# Домклика договор не называет — угадывать его нельзя. Незнакомое → None, как у
# avito._map_lower: COALESCE в UPDATE ниже оставит прежнее значение.
_SALE_TYPE_MAP: dict[str, str] = {
"свободная продажа": "free",
"альтернатива": "alternative",
}
# ── DomClickDetailEnrichment ──────────────────────────────────────────────────
@dataclass
@ -286,6 +300,19 @@ def _map_repair_state(renovation: str | None) -> str | None:
return infer_repair_state_from_text(renovation)
def canon_sale_type(sale_type: str | None) -> str | None:
"""RU-фраза Домклика → токен listings.sale_type, общий с cian/avito (#2674).
Стоит на ЕДИНОЙ границе записи (save_detail_enrichment), а не в парсере:
в listings пишут три источника одного и того же dataclass'а — сам парсер
карточки, scripts/ingest_domclick_jsonl.py и scripts/domclick_local_runner.py
(именно он налил 6101 русскую строку 2026-07-18). Незнакомое None.
"""
if not sale_type:
return None
return _SALE_TYPE_MAP.get(sale_type.strip().lower())
def _compact(d: dict[str, Any]) -> dict[str, Any]:
"""Выкидывает None-значения, чтобы raw_extra оставался компактным."""
return {k: v for k, v in d.items() if v is not None}
@ -542,6 +569,8 @@ def save_detail_enrichment(
1. UPDATE listings SET <cols> (COALESCE None не затирает существующее),
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
(ON CONFLICT DO NOTHING идемпотентно). source='domklik'.
@ -579,7 +608,7 @@ def save_detail_enrichment(
"kitchen_area_m2": e.kitchen_area_m2,
"balconies_count": e.balconies_count,
"has_balcony": e.has_balcony,
"sale_type": e.sale_type,
"sale_type": canon_sale_type(e.sale_type),
"owners_count": e.owners_count,
"encumbrances_clean": e.encumbrances_clean,
"year_built": e.year_built,