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
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:
parent
fb5ec56a54
commit
e22e708031
3 changed files with 148 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue