From 1aaa8b88a1ac3f8874f76480638d6ae9b5bc4610 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 26 Aug 2026 12:48:18 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein/yandex):=20is=5Fpro=5Fseller=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=81=D0=BA=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=20+=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D1=84=D0=B8=D0=BB=D0=BB=204469=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=20(#3063=20=D0=BF.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail-обогащение извлекало agency_name, но признак «продаёт профи» не выводило — 4 469 активных листингов с известным агентством стояли с is_pro_seller=NULL (признак эрозирован SERP-затиранием до PR #3067, а заново не появлялся). Признак идёт в оценщик trade-in. - save_detail_enrichment: is_pro_seller=TRUE при известном agency_name, fill-only COALESCE; отсутствие блока НЕ доказывает «частник» (п.3 задачи — отдельное решение), в ту сторону ничего не пишем; - миграция 271: одноразовый бэкфилл уже существующих строк всех источников (правило источник-независимо), идемпотентна. Co-Authored-By: Claude Opus 5 --- .../271_listings_pro_seller_from_agency.sql | 17 ++++ .../tests/test_3063_pro_seller_from_agency.py | 82 +++++++++++++++++++ .../scraper_kit/providers/yandex/detail.py | 8 ++ 3 files changed, 107 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/271_listings_pro_seller_from_agency.sql create mode 100644 tradein-mvp/backend/tests/test_3063_pro_seller_from_agency.py diff --git a/tradein-mvp/backend/data/sql/271_listings_pro_seller_from_agency.sql b/tradein-mvp/backend/data/sql/271_listings_pro_seller_from_agency.sql new file mode 100644 index 00000000..ef1059db --- /dev/null +++ b/tradein-mvp/backend/data/sql/271_listings_pro_seller_from_agency.sql @@ -0,0 +1,17 @@ +-- 271_listings_pro_seller_from_agency.sql +-- #3063 п.2: is_pro_seller выводим из уже известного agency_name. +-- Прод-факт (замер владельца 23-24.08): 4 469 активных yandex-листингов имеют +-- заполненный agency_name (из detail-бэкфилла, где COALESCE-защита была), но +-- is_pro_seller у них NULL — признак эрозирован SERP-затиранием до PR #3067. +-- Починка разбора не чинит строки (ON CONFLICT/UPDATE прошлого не перезапишет +-- задним числом) — поэтому одноразовый бэкфилл. +-- +-- Скоуп: ВСЕ источники, не только yandex — правило «есть имя агентства ⇒ продаёт +-- профи» источник-независимо. is_homeowner не трогаем: отсутствие агентства не +-- доказывает «частник» (п.3 задачи — отдельное решение). +-- Идемпотентно: повторный прогон обновит 0 строк. +UPDATE listings + SET is_pro_seller = TRUE + WHERE agency_name IS NOT NULL + AND agency_name <> '' + AND is_pro_seller IS DISTINCT FROM TRUE; diff --git a/tradein-mvp/backend/tests/test_3063_pro_seller_from_agency.py b/tradein-mvp/backend/tests/test_3063_pro_seller_from_agency.py new file mode 100644 index 00000000..011a6151 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3063_pro_seller_from_agency.py @@ -0,0 +1,82 @@ +"""#3063 п.2: is_pro_seller выводится из наличия агентского блока на детальной. + +Прод-факт (23-24.08): 4 469 активных yandex-листингов с заполненным agency_name +(из detail-бэкфилла) имели is_pro_seller = NULL — признак эрозирован +SERP-затиранием до PR #3067, а detail-обогащение его и не выводило, хотя данных +достаточно. Признак идёт в оценщик trade-in. + +Два конца: +- save_detail_enrichment пишет is_pro_seller=TRUE при известном agency_name + (fill-only COALESCE; отсутствие блока НЕ доказывает «частник» — п.3 задачи); +- миграция 271 бэкфиллит уже существующие строки («починка разбора ≠ починка + строк»: UPDATE прошлого задним числом никто не перезапишет). + +Красный на origin/main по ЗНАЧЕНИЮ: в параметрах UPDATE нет ключа +is_pro_seller → .get() даёт None, не True. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from scraper_kit.providers.yandex.detail import DetailEnrichment, save_detail_enrichment + +_MIGRATION = ( + Path(__file__).resolve().parents[1] / "data" / "sql" / "271_listings_pro_seller_from_agency.sql" +) + + +class _CaptureDb: + def __init__(self) -> None: + self.params: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params: + self.params.append(params) + return MagicMock(rowcount=1) + + def commit(self) -> None: + pass + + +def _update_params(db: _CaptureDb) -> dict[str, Any]: + upd = [p for p in db.params if "listing_id" in p] + assert upd, "save_detail_enrichment не выполнил UPDATE" + return upd[-1] + + +def test_agency_block_derives_pro_seller_true() -> None: + db = _CaptureDb() + save_detail_enrichment( + db, 101, DetailEnrichment(offer_id="o1", source_url="https://x/1", agency_name="Этажи") + ) # type: ignore[arg-type] + assert _update_params(db).get("is_pro_seller") is True, ( + "агентский блок известен, а is_pro_seller не выводится — " + "4 469 строк на проде копились именно так" + ) + + +def test_no_agency_block_leaves_pro_seller_untouched() -> None: + """Отсутствие блока — NULL в параметре: COALESCE сохранит ранее известное + значение, и «частник» из этого НЕ выводится (п.3 задачи — отдельно).""" + db = _CaptureDb() + save_detail_enrichment( + db, 102, DetailEnrichment(offer_id="o2", source_url="https://x/2", agency_name=None) + ) # type: ignore[arg-type] + assert _update_params(db).get("is_pro_seller") is None + + +def test_backfill_migration_shape() -> None: + """Миграция существует, идемпотентна (IS DISTINCT FROM TRUE), не трогает + is_homeowner и пустые имена агентств.""" + sql = _MIGRATION.read_text() + assert "SET is_pro_seller = TRUE" in sql + assert "agency_name IS NOT NULL" in sql + assert "IS DISTINCT FROM TRUE" in sql + assert "is_homeowner" not in sql.split("--")[0] or "SET is_homeowner" not in sql diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py index 1f8a754d..8e08b344 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py @@ -612,6 +612,10 @@ def save_detail_enrichment(db: Session, listing_id: int, e: DetailEnrichment) -> publish_date_relative ), agency_name = COALESCE(CAST(:agency_name AS text), agency_name), + is_pro_seller = COALESCE( + CAST(:is_pro_seller AS boolean), + is_pro_seller + ), agency_founded_year = COALESCE( CAST(:agency_founded_year AS int), agency_founded_year @@ -655,6 +659,10 @@ def save_detail_enrichment(db: Session, listing_id: int, e: DetailEnrichment) -> "views_total": e.views_total, "pub_date_rel": e.publish_date_relative, "agency_name": e.agency_name, + # #3063 п.2: агентский блок на детальной странице = продаёт профи. + # Вывод только в одну сторону (True или NULL): отсутствие блока НЕ + # доказывает «частник» — это п.3 задачи, отдельное решение. + "is_pro_seller": True if e.agency_name else None, "agency_founded_year": e.agency_founded_year, "agency_objects_count": e.agency_objects_count, "metro_stations": metro_json, -- 2.45.3