From 58dcd76d2ea7eddccbd87edbc536696575e61aeb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 06:11:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20sale=5Ftype=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D1=80=D0=B5=D1=82=D1=8C=D0=B8=D0=BC=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=91=D0=BC=20(#2674)=20(#?= =?UTF-8?q?2696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...20_listings_sale_type_domclick_dialect.sql | 72 +++++++++++++++++++ .../tests/scrapers/test_domclick_detail.py | 47 +++++++++++- .../scraper_kit/providers/domclick/detail.py | 31 +++++++- 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql diff --git a/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql b/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql new file mode 100644 index 00000000..ce7462b1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/220_listings_sale_type_domclick_dialect.sql @@ -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; diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index 583fde16..3b46b334 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -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]) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py index 2869d843..93e38182 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -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 (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,