From f5b39e6fc92d83d5d285e85854fd0e183f533539 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 05:00:36 +0500 Subject: [PATCH] =?UTF-8?q?chore(tradein):=20=D1=80=D0=B0=D0=B7=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=B0=20=E2=80=94=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C,=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=20=D0=B8=D0=BB=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой. ПОДКЛЮЧЕНО Загрузчик ДОМ.РФ. Loader и CLI существуют с #2013, а Handler'а в product_handlers и строки в scrape_schedules не было — вызвать его было нечем. На проде 29 978 строк staging с ОДНИМ loaded_at (2026-07-12), то есть ровно один ручной запуск, 24 дня без обновления. Отсюда кормятся houses.year_built/material_walls/total_floors и дальше listings.year_built — когортный фильтр эстиматора. Недельный такт, окно 03:00-04:00 UTC (до импорта ДКП и дневных агрегатов). filters_hash. Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ уровнем выше — estimation.sale.filtersHash. Колонка пуста 0/1658, при том что в сохранённых сырых ответах хеш есть у 139/139 и все значения различны. Путь исправлен, 139 строк восстановлены бэкфиллом из raw_payload. has_panorama. Разбирался парсером, лежал в карте приоритетов, обещан публичным контрактом market.v_houses — и не попадал в houses ни одной строкой кода (0 из 9366). Пишется там, где yandex_valuation уже держит и house_id, и мету. Гейт честности: парсер отдаёт bool, а не bool|None, поэтому false пишем только при подтверждённо отрисованной странице (есть год или этажность) — иначе NULL, а не выдуманный false. УДАЛЕНО Дедуп-обёртки эстиматора _phys_dedup_key / _extract_street_token: 25 ссылок, все из тестов. Хуже, чем просто мёртвые — _phys_dedup_key утверждала правило «ключ = кадастр ИЛИ улица», которого в боевом дедупе нет (_union_find_phys_dedup держит оба композита и сливает по любому совпадению). Тесты переведены на живые функции. Тиерные коэффициенты выкупа asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds: ноль читателей и писателей, флага tier_aware_ratio_enabled не существует. Посчитаны один раз при накатке 098 (computed_at 2026-06-27) — тогда как живая asking_to_sold_ratios обновляется ежедневно (2026-08-05). Методика сохранена в 098. Колонки без писателя: listings.merged_into (113 уже называла её мёртвой) и house_sources.raw_payload вместе с GIN-индексом по всегда-NULL колонке. v_data_quality.price_disagreements_count: у всех 89 699 объявлений ровно один источник, показатель структурно не мог быть ненулевым, а ноль читался как «расхождений нет». ЗАДОКУМЕНТИРОВАНО BROWSER_BLOCK_RESOURCES выставлен во всех трёх прод-контейнерах, а код перестал его читать в #1812. Блокировка при этом не ослабла (image глушит camoufox block_images, font/media — дефолт списка типов), мёртв только выключатель. Сервис теперь говорит об этом на старте: молча игнорируемая ручка опаснее отсутствующей. v_price_divergence / v_cross_source_health оставлены как задел, но в COMMENT написано, почему они пусты структурно: боевой путь загрузки зовёт upsert_listing_source напрямую и не зовёт match_or_create_listing. house_sources.ext_url пуст 46 813/46 813, но входит в публичный контракт market.v_house_sources — оставлен и подписан. Refs #2674 --- tradein-mvp/backend/app/services/estimator.py | 77 ++++- .../backend/app/services/product_handlers.py | 55 ++- .../backend/data/sql/216_dead_code_sweep.sql | 232 +++++++++++++ .../tests/test_dead_code_sweep_2674.py | 320 ++++++++++++++++++ .../test_estimator_dedup_cross_source_2087.py | 87 +++-- .../test_estimator_yandex_integration.py | 14 +- .../tests/test_yandex_history_area_filter.py | 12 +- .../tests/test_yandex_valuation_save.py | 13 +- tradein-mvp/browser/server.py | 33 ++ tradein-mvp/browser/test_server.py | 34 ++ .../scraper_kit/providers/cian/valuation.py | 11 +- 11 files changed, 827 insertions(+), 61 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/216_dead_code_sweep.sql create mode 100644 tradein-mvp/backend/tests/test_dead_code_sweep_2674.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index e93a0951..c8d0fa46 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -47,6 +47,7 @@ from scraper_kit.providers.cian.valuation import ( estimate_via_cian_valuation, ) from scraper_kit.providers.yandex.valuation import ( + ValuationHouseMeta, YandexValuationResult, YandexValuationScraper, ) @@ -935,6 +936,7 @@ def _save_yandex_history_items( if rows: db.execute(sql, rows) db.commit() + _save_yandex_house_panorama(db, house_id, result.house) return len(rows) except Exception as e: logger.warning( @@ -946,6 +948,60 @@ def _save_yandex_history_items( return 0 +# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в +# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но +# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь — +# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету. +# +# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text +# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн, +# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет». +# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность +# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL. +def _save_yandex_house_panorama( + db: Session, + house_id: int | None, + meta: ValuationHouseMeta, +) -> None: + """Пишет houses.has_panorama по разобранной мете yandex_valuation. + + No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт + выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за + справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает + только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история. + """ + if house_id is None: + return + if meta.year_built is None and meta.total_floors is None: + logger.debug( + "yandex_valuation: has_panorama не пишем для house_id=%s — " + "страница не подтверждена (нет ни года, ни этажности)", + house_id, + ) + return + try: + with db.begin_nested(): + db.execute( + text( + """ + UPDATE houses + SET has_panorama = CAST(:panorama AS boolean) + WHERE id = CAST(:hid AS bigint) + AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean) + """ + ), + {"hid": house_id, "panorama": meta.has_panorama}, + ) + db.commit() + except Exception as e: + logger.warning( + "yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s", + house_id, + e, + ) + db.rollback() + + # ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── @@ -5764,11 +5820,6 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]: return street, house -def _extract_street_token(addr: str | None) -> str: - """Нормализованный уличный токен для дедуп-ключа (#2265). См. _parse_street_house.""" - return _parse_street_house(addr)[0] - - def _lot_dedup_components( lot: dict[str, Any], *, @@ -5806,15 +5857,13 @@ def _lot_dedup_components( return cad_s, house, cad_key, street_key -def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None: - """Первичный физический ключ (building, floor, area_bucket, price_bucket). - - building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет - площади/цены или не из чего построить building. Сохраняет 4-кортежную форму - (canonical-ключ; union-find в _dedup_cross_source использует оба композита). - """ - _cad_s, _house, cad_key, street_key = _lot_dedup_components(lot) - return cad_key or street_key +# #2674: здесь жили `_phys_dedup_key` и `_extract_street_token` — однострочные обёртки +# над _lot_dedup_components / _parse_street_house. Прод не звал ни ту, ни другую ни разу +# (25 ссылок, все из тестов). Хуже: _phys_dedup_key утверждала правило «первичный ключ = +# кадастр ИЛИ улица», которого в проде нет — живой путь (_union_find_phys_dedup) держит +# ОБА композита и сливает по любому совпадению, с guard'ами на разные кадастры/номера +# домов. Тесты, проверявшие обёртку, проверяли не тот алгоритм; они переведены на живые +# функции (tests/test_estimator_dedup_cross_source_2087.py). def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]: diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 726ce48a..1069ab8b 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -441,6 +441,57 @@ async def _job_house_imv_backfill( logger.exception("scheduler: mark_failed crashed run_id=%d", run_id) +# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ───────── +# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py) +# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник +# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех). +# Это не мёртвый код, а оборванная проводка: нечему было его вызвать. +async def _job_domrf_kapremont_load( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + """Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings. + + Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не + дублируем логику). Lifecycle не свой — mark_done/mark_failed здесь, как у + _job_yandex_newbuilding_sweep. + + Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные + колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых + прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же + промах, что чинили у house_imv_backfill). + """ + from app.services.domrf_kapremont_loader import ( + backfill_houses_from_domrf, + load_domrf_kapremont, + propagate_listings_year_from_houses, + ) + + def _run() -> dict[str, int]: + load_counts = load_domrf_kapremont(db) + db.commit() + houses_counts = backfill_houses_from_domrf(db) + listings_counts = propagate_listings_year_from_houses(db) + db.commit() + return { + "kr11_rows": load_counts["kr11_rows"], + "upserted": load_counts["upserted"], + "houses_updated": houses_counts["houses_updated"], + "listings_updated": listings_counts["listings_updated"], + # см. докстринг: выделенные колонки прогона + гейт «нулевой прогон». + "total_seen": load_counts["kr11_rows"], + "new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"], + } + + loop = asyncio.get_event_loop() + try: + counters = await loop.run_in_executor(None, _run) + ctx.runs.mark_done(db, run_id, counters) + except Exception as exc: + logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id) + db.rollback() + ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {}) + + # ── house_dedup_merge — sync destructive merge в executor, owns lifecycle ───── async def _job_house_dedup_merge( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -475,8 +526,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: """Реестр НЕ-sweep продуктовых source→Handler для kit build_registry. Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь — - их даёт build_registry(_default_kit_handlers). Здесь — 19 именованных + 1 wildcard + их даёт build_registry(_default_kit_handlers). Здесь — именованные + 1 wildcard (deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch. + (Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.) `ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается. @@ -520,6 +572,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"), "house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"), "house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"), + "domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"), "proxy_healthcheck": Handler( _job_proxy_healthcheck, "proxy_healthcheck", diff --git a/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql b/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql new file mode 100644 index 00000000..c735a5e8 --- /dev/null +++ b/tradein-mvp/backend/data/sql/216_dead_code_sweep.sql @@ -0,0 +1,232 @@ +-- 216_dead_code_sweep.sql +-- Purpose (#2674, раздел «Мёртвый код»): развести три разных вещи, которые снаружи +-- выглядят одинаково — «написано и ни разу не сработало». +-- +-- 1. ОБОРВАННАЯ ПРОВОДКА — механизм рабочий, звать некому/нечем. Чиним подключением. +-- 2. МЁРТВОЕ — механизм невыразим, дублирует существующее или потерял смысл. Удаляем. +-- 3. ЗАДЕЛ — оставляем, но в схеме должно быть написано, чем он НЕ является сегодня. +-- +-- Все числа — с прод-БД tradein 2026-08-06. +-- +-- ── 1. ПОДКЛЮЧАЕМ ─────────────────────────────────────────────────────────── +-- +-- A. domrf_kapremont_load — расписание для загрузчика ДОМ.РФ. +-- Загрузчик (app/services/domrf_kapremont_loader.py) и CLI +-- (app/tasks/domrf_kapremont_load.py) написаны и покрыты тестами с #2013, но +-- Handler'а в product_handlers и строки в scrape_schedules не существовало — +-- вызвать его было НЕЧЕМ. Итог на проде: 29 978 строк staging, у ВСЕХ один и тот +-- же loaded_at = 2026-07-12 13:19:30 (ровно один ручной запуск), 24 дня без +-- обновления. Это не мёртвый код: houses.year_built/material_walls/total_floors и +-- дальше listings.year_built (когортный фильтр эстиматора) кормятся именно отсюда. +-- +-- B. external_valuations.filters_hash — бэкфилл из сырых ответов. +-- Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ на УРОВЕНЬ +-- ВЫШЕ — estimation.sale.filtersHash (ключи sale на проде: isError, filtersHash, +-- data, isFetching). Колонка была пуста 0/1658, при том что в сырых ответах хеш +-- есть у 139/139 строк cian_valuation и все 139 значений различны. Путь починен в +-- providers/cian/valuation.py; здесь достаём то, что уже лежит в raw_payload. +-- +-- ── 2. УДАЛЯЕМ ────────────────────────────────────────────────────────────── +-- +-- C. asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds (мигр. 098, #928). +-- Ноль читателей и ноль писателей в коде — грепом не находится ни одного +-- упоминания вне самой 098 и манифеста. Флага tier_aware_ratio_enabled, под +-- который таблицы задумывались, в конфиге не существует. Посчитаны один раз при +-- накатке (21 + 5 строк, computed_at 2026-06-27) и с тех пор не двигались, тогда +-- как ЖИВАЯ asking_to_sold_ratios обновляется ежедневно (computed_at 2026-08-05). +-- То есть в БД лежат коэффициенты выкупа сорокадневной давности, которые выглядят +-- как рабочая сегментация — их достаточно один раз прочитать по ошибке, чтобы +-- получить оценку по устаревшему рынку. Методика не потеряна: derivation-CTE +-- целиком сохранён в 098, восстановить = переприменить файл. +-- +-- D. listings.merged_into — 142 569 строк, NULL у всех, ноль упоминаний в коде. +-- Заведена в 028 «under dedup workflow», который так и не построили; 113 уже +-- писала прямым текстом «column is dead, no code writer». Дедуп объявлений живёт +-- в другом месте и по-другому (estimator._union_find_phys_dedup, во время оценки, +-- без записи в БД). Соседнюю listings.canonical НЕ трогаем — она вырождена (t у +-- всех 142 569), но её читает WHERE listings_search_mv (050/094), и снос колонки +-- потянул бы пересоздание matview с шестью индексами ради нулевого выигрыша. +-- +-- E. house_sources.raw_payload + GIN-индекс по нему — 46 813 строк, NULL у всех. +-- Оба писателя house_sources (matching/houses.py:556, house_dedup_merge.py:493) +-- эту колонку в INSERT не включают; читателей нет, из публичного контракта +-- market.v_house_sources (154) она намеренно исключена. GIN-индекс по колонке, +-- которая всегда NULL, — чистая стоимость на каждой вставке. +-- Соседний house_sources.ext_url тоже пуст 46 813/46 813, но он ВХОДИТ в +-- market.v_house_sources — удаление сломало бы обещание стабильности контракта. +-- Оставляем и подписываем (см. п. 3). +-- +-- F. v_data_quality.price_disagreements_count — показатель, который не может быть +-- ненулевым. Считает строки v_price_divergence («разброс цен между площадками у +-- одного объявления > 5%»), а на проде у 89 699 объявлений РОВНО ОДИН источник +-- каждое: distinct listing_id = 89 699 при 89 699 строках listing_sources. +-- v_price_divergence = 0 строк, v_cross_source_health = 0 строк. Причина не в +-- данных: боевой путь загрузки (scrapers/base.py::_link_listing_to_house) зовёт +-- upsert_listing_source('source_link') напрямую и НЕ зовёт match_or_create_listing — +-- см. NOTE на matching/listings.py:188. Пока связывание источников не подключено, +-- ноль здесь читается как «расхождений нет», хотя честно это «мы не сравниваем». +-- Тот же довод, по которому 214 убрала outliers_flagged. +-- +-- ── 3. ОСТАВЛЯЕМ И ПОДПИСЫВАЕМ ────────────────────────────────────────────── +-- +-- G. Сами v_price_divergence / v_cross_source_health не удаляем: они выразимы и +-- станут ненулевыми в тот день, когда связывание источников заработает. Но в +-- COMMENT должно быть написано, что они пусты СТРУКТУРНО, а не по счастью. +-- Туда же — house_sources.ext_url. +-- +-- ⚠️ View-зависимость (тот же порядок, что 214): v_data_quality содержит +-- `WITH active_listings AS (SELECT * FROM listings)`, что фиксирует column-level +-- зависимость на ВСЕ колонки listings. Порядок: DROP VIEW → DROP COLUMN → +-- CREATE VIEW. Последний DDL v_data_quality — 214_drop_dead_run_metrics.sql. +-- +-- Dependencies: 028_matching_tables.sql, 029_extend_matching_valuation_dynamics.sql, +-- 046_views.sql, 052_scrape_schedules.sql, 098_asking_to_sold_ratios_tiered.sql, +-- 176_domrf_kapremont.sql, 214_drop_dead_run_metrics.sql. +-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего +-- 'domrf_kapremont_load' в product_handlers.build_product_handlers() — иначе +-- kit-scheduler не найдёт Handler на первом due-run. next_run_at = завтра, так что +-- даже при обратном порядке накатки окно не наступит раньше следующих суток. +-- Идемпотентно: ON CONFLICT DO NOTHING / DROP ... IF EXISTS / CREATE OR REPLACE VIEW / +-- бэкфилл под WHERE filters_hash IS NULL. + +BEGIN; + +-- ── A. Расписание загрузчика ДОМ.РФ (оборванная проводка) ──────────────────── +-- enabled = true: внешний источник, но открытые данные без auth и без анти-бота +-- (тот же класс, что sber_index_pull / rosreestr_quarter_poll). +-- interval_days = 7: реестр капремонта не меняется ежедневно, а прогон качает два +-- zip и парсит ~30 тыс. строк. Недельный такт достаточен и не жжёт трафик впустую. +-- Ключ читает compute_next_run_at из default_params (см. 129). +-- Окно 03:00-04:00 UTC — до rosreestr_dkp_import (04:00-06:00) и до +-- asking_to_sold_ratio_refresh (06:00-07:00): год постройки должен доехать в +-- listings раньше, чем по ним считают дневные агрегаты. +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'domrf_kapremont_load', + true, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"interval_days": 7}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +-- ── B. Бэкфилл filters_hash из уже сохранённых сырых ответов ───────────────── +-- Путь тот же, что теперь читает парсер. Пустую строку не пишем (NULLIF) — «есть +-- ключ, но он пуст» и «ключа нет» должны остаться одинаково NULL, а не разойтись. +UPDATE external_valuations +SET filters_hash = NULLIF(raw_payload #>> '{estimation,sale,filtersHash}', '') +WHERE filters_hash IS NULL + AND raw_payload #>> '{estimation,sale,filtersHash}' IS NOT NULL; + +-- ── C. Тиерные коэффициенты выкупа: две таблицы без читателя и писателя ────── +DROP TABLE IF EXISTS asking_to_sold_ratios_tiered; +DROP TABLE IF EXISTS asking_to_sold_tier_bounds; + +-- ── D+E+F. Колонки без писателя + показатель, который не может быть ненулевым ─ +DROP VIEW IF EXISTS v_data_quality; + +ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into; + +DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx; +ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload; + +-- DDL идентичен 214, минус строка price_disagreements_count (см. п. F шапки). +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT * FROM listings WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago; + +COMMENT ON VIEW v_data_quality IS + 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): ' + '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. ' + '#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один ' + 'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как ' + '«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен ' + 'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), ' + 'ноль в нём — честный ответ, а не мнимое благополучие.'; + +-- ── G. Подписи к тому, что оставлено как задел ─────────────────────────────── +COMMENT ON VIEW v_price_divergence IS + 'Объявления с разбросом цен между источниками > 5%. #2674: СЕГОДНЯ ВСЕГДА ПУСТ ' + 'и это структурно, а не случайно — боевой путь загрузки зовёт ' + 'upsert_listing_source(''source_link'') напрямую (scrapers/base.py::_link_listing_to_house) ' + 'и не зовёт match_or_create_listing, поэтому у каждого объявления ровно один ' + 'источник (89 699 строк listing_sources = 89 699 разных listing_id). View оставлен ' + 'как задел: станет осмысленным в тот день, когда связывание источников подключат ' + '(см. NOTE на matching/listings.py:188). Из v_data_quality исключён — там ноль ' + 'выглядел как результат проверки.'; + +COMMENT ON VIEW v_cross_source_health IS + 'Пер-объявленческая агрегация цен по источникам. #2674: пуст по той же причине, ' + 'что v_price_divergence — HAVING count(*) >= 2 недостижим, пока связывание ' + 'источников не подключено. Читателей в коде нет.'; + +COMMENT ON COLUMN house_sources.ext_url IS + '#2674: NULL у всех 46 813 строк — ни один из двух писателей house_sources ' + '(matching/houses.py, house_dedup_merge.py) эту колонку не заполняет. НЕ удалена ' + 'только потому, что входит в публичный контракт market.v_house_sources (мигр. 154), ' + 'где удаление колонки объявлено ломающим изменением. Соседний raw_payload из ' + 'контракта исключён и удалён этой же миграцией.'; + +COMMENT ON COLUMN houses.has_panorama IS + '#2674: заполняется из yandex_valuation (estimator._save_yandex_house_panorama). ' + 'До этой правки колонка была пуста у всех 9 366 домов, хотя парсер флаг разбирал. ' + 'Пишется ТОЛЬКО когда страница оценки подтверждённо отрисовалась (в мете есть год ' + 'или этажность) — иначе «метки нет» неотличимо от «страница не открылась», и NULL ' + 'честнее false.'; + +COMMENT ON COLUMN external_valuations.filters_hash IS + 'sha256 фильтров от самого Циана, estimation.sale.filtersHash (#2674: НЕ ' + 'estimation.sale.data.filtersHash — из-за лишнего уровня колонка была пуста 0/1658). ' + 'Отличается от cache_key: cache_key — наш хеш параметров ЗАПРОСА, filters_hash — ' + 'хеш того, во что Циан их разрешил, поэтому он способен схлопнуть варианты записи ' + 'одного адреса, которые cache_key разводит.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py new file mode 100644 index 00000000..2fda663c --- /dev/null +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -0,0 +1,320 @@ +"""Разбор мёртвого кода #2674: подключить / удалить / задокументировать. + +Каждая правка эпика — тест, который краснеет без неё: + + подключено: + - houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница + не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»); + - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — + именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; + - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. + + удалено (гейт против возврата): + - _phys_dedup_key / _extract_street_token — обёртки без прод-вызовов; + - asking_to_sold_ratios_tiered / asking_to_sold_tier_bounds — таблицы без + читателя и писателя; + - listings.merged_into, house_sources.raw_payload — колонки без писателя; + - v_data_quality.price_disagreements_count — показатель, который не мог быть + ненулевым. + + задокументировано: + - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — + сервис обязан сказать об этом вслух на старте. + +Без БД и сети: сессия замокана, SQL-миграции читаются как текст. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.cian.valuation import _parse_valuation_state +from scraper_kit.providers.yandex.valuation import ( + ValuationHistoryItem, + ValuationHouseMeta, + YandexValuationResult, +) + +from app.services import estimator +from app.services.estimator import _save_yandex_history_items + +REPO_ROOT = Path(__file__).resolve().parents[3] +TRADEIN = REPO_ROOT / "tradein-mvp" +SQL_DIR = TRADEIN / "backend" / "data" / "sql" +MIGRATION = SQL_DIR / "216_dead_code_sweep.sql" + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 1/3: houses.has_panorama +# ───────────────────────────────────────────────────────────────────────────── + + +def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult: + return YandexValuationResult( + address="Екатеринбург, ул. Куйбышева, 106", + offer_category="APARTMENT", + offer_type="SELL", + page=1, + source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test", + house=meta, + history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)], + ) + + +def _panorama_updates(db: MagicMock) -> list[dict]: + """Параметры всех db.execute, которые обновляли houses.has_panorama.""" + found = [] + for call in db.execute.call_args_list: + sql = str(call.args[0]) + if "has_panorama" in sql and "UPDATE houses" in sql: + found.append(call.args[1]) + return found + + +def test_has_panorama_written_when_page_rendered() -> None: + """Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой.""" + db = MagicMock() + result = _result_with_meta( + ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True) + ) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(99, 0.9, "fp"), + ): + _save_yandex_history_items(db, result) + + updates = _panorama_updates(db) + assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674" + assert updates[0] == {"hid": 99, "panorama": True} + + +def test_has_panorama_false_written_when_page_rendered() -> None: + """Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false.""" + db = MagicMock() + result = _result_with_meta( + ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False) + ) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(7, 0.9, "fp"), + ): + _save_yandex_history_items(db, result) + + assert _panorama_updates(db) == [{"hid": 7, "panorama": False}] + + +def test_has_panorama_not_written_when_page_unconfirmed() -> None: + """Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false.""" + db = MagicMock() + result = _result_with_meta(ValuationHouseMeta(has_panorama=False)) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(5, 0.9, "fp"), + ): + _save_yandex_history_items(db, result) + + assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали" + + +def test_has_panorama_not_written_without_house_id() -> None: + """Дом не сматчился → писать некуда, но и падать нельзя.""" + db = MagicMock() + result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16)) + + with patch( + "app.services.estimator.match_or_create_house", + side_effect=RuntimeError("no house"), + ): + _save_yandex_history_items(db, result) + + assert _panorama_updates(db) == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка +# ───────────────────────────────────────────────────────────────────────────── + + +def test_domrf_loader_has_a_scheduler_handler() -> None: + """Без Handler'а загрузчик ДОМ.РФ был невызываем — этого и не хватало.""" + from app.services.product_handlers import build_product_handlers + + handlers = build_product_handlers(MagicMock()) + assert "domrf_kapremont_load" in handlers + + +def test_domrf_loader_is_seeded_into_schedules() -> None: + """Handler без строки расписания так же нем, как расписание без Handler'а.""" + sql = MIGRATION.read_text(encoding="utf-8") + assert "'domrf_kapremont_load'" in sql + assert "INSERT INTO scrape_schedules" in sql + # Недельный такт: реестр капремонта не меняется ежедневно, а прогон качает два zip. + assert '"interval_days": 7' in sql + + +def test_domrf_handler_reuses_loader_functions() -> None: + """Дизайн-инвариант product_handlers: job переиспользует боевое тело, не копирует.""" + src = (TRADEIN / "backend" / "app" / "services" / "product_handlers.py").read_text( + encoding="utf-8" + ) + body = src.split("_job_domrf_kapremont_load", 1)[1].split("# ──", 1)[0] + for fn in ( + "load_domrf_kapremont", + "backfill_houses_from_domrf", + "propagate_listings_year_from_houses", + ): + assert fn in body, f"{fn} не вызывается — загрузчик подключён лишь наполовину" + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали +# ───────────────────────────────────────────────────────────────────────────── + + +def test_filters_hash_read_from_sale_wrapper() -> None: + """Прод-форма ответа: ключи estimation.sale = {isError, filtersHash, data, isFetching}.""" + state = { + "user": {"isAuthenticated": True, "userId": 1}, + "estimation": { + "sale": { + "isError": False, + "isFetching": False, + "filtersHash": "96bba2876162e2b822f80eec", + "data": {"price": 9_000_000, "accuracy": 12}, + }, + "rent": {"data": {}}, + }, + } + assert _parse_valuation_state(state).filters_hash == "96bba2876162e2b822f80eec" + + +def test_filters_hash_absent_stays_none() -> None: + """Нет ключа → None. Со старым (вложенным) путём тест бы прошёл — он не про фикс.""" + state = {"estimation": {"sale": {"data": {"price": 1}}, "rent": {"data": {}}}} + assert _parse_valuation_state(state).filters_hash is None + + +def test_filters_hash_backfill_uses_the_same_path() -> None: + """Миграция достаёт хеш ровно оттуда же, откуда его теперь читает парсер.""" + sql = MIGRATION.read_text(encoding="utf-8") + assert "'{estimation,sale,filtersHash}'" in sql + assert "WHERE filters_hash IS NULL" in sql + + +# ───────────────────────────────────────────────────────────────────────────── +# Удалено: гейты против возврата +# ───────────────────────────────────────────────────────────────────────────── + + +def _live_python_sources() -> list[Path]: + """Боевой Python trade-in: app + scraper-kit + browser. Без тестов.""" + roots = [ + TRADEIN / "backend" / "app", + TRADEIN / "packages" / "scraper-kit" / "src", + TRADEIN / "browser", + ] + return [p for root in roots for p in root.rglob("*.py") if not p.name.startswith("test_")] + + +def test_dead_dedup_wrappers_are_gone() -> None: + """Обёртки без прод-вызовов (25 ссылок, все из тестов) не должны вернуться.""" + for name in ("_phys_dedup_key", "_extract_street_token"): + assert not hasattr(estimator, name), ( + f"{name} снова в estimator — эта обёртка описывала правило, " + "которого в боевом дедупе (_union_find_phys_dedup) нет" + ) + + +def test_dead_names_absent_from_live_code() -> None: + """Имена удалённых таблиц/колонок/показателей не упоминаются в боевом коде. + + SQL-миграции сознательно НЕ проверяем: 098/028/029/046 — исторические файлы, + переписывать их задним числом нельзя (пересборка с нуля идёт по ним). + """ + dead = [ + "asking_to_sold_ratios_tiered", + "asking_to_sold_tier_bounds", + "price_disagreements_count", + ] + offenders: list[str] = [] + for path in _live_python_sources(): + text = path.read_text(encoding="utf-8") + for name in dead: + if name in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {name}") + assert not offenders, "удалённое снова упоминается: " + "; ".join(offenders) + + +def test_dropped_columns_have_no_python_writer() -> None: + """merged_into / house_sources.raw_payload: писателя не было и быть не должно.""" + offenders = [ + str(p.relative_to(REPO_ROOT)) + for p in _live_python_sources() + if "merged_into" in p.read_text(encoding="utf-8") + ] + assert not offenders, f"listings.merged_into снова упомянут: {offenders}" + + hs_writers = [ + p + for p in _live_python_sources() + if "INSERT INTO house_sources" in p.read_text(encoding="utf-8") + ] + assert hs_writers, "писатели house_sources исчезли — тест потерял смысл, проверь grep" + for path in hs_writers: + stmt = path.read_text(encoding="utf-8").split("INSERT INTO house_sources", 1)[1] + stmt = stmt.split("VALUES", 1)[0] + assert "raw_payload" not in stmt, f"{path} снова пишет удалённую колонку" + + +def test_migration_drops_exactly_what_was_declared_dead() -> None: + sql = MIGRATION.read_text(encoding="utf-8") + for stmt in ( + "DROP TABLE IF EXISTS asking_to_sold_ratios_tiered", + "DROP TABLE IF EXISTS asking_to_sold_tier_bounds", + "ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into", + "ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload", + "DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx", + ): + assert stmt in sql, f"миграция не выполняет: {stmt}" + # Показатель убран из KPI-снимка, но сам view-источник оставлен как задел. + view_ddl = sql.split("CREATE OR REPLACE VIEW v_data_quality", 1)[1].split(";", 1)[0] + assert "price_disagreements_count" not in view_ddl + assert "COMMENT ON VIEW v_price_divergence" in sql + + +def test_price_divergence_is_documented_as_structurally_empty() -> None: + """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" + sql = MIGRATION.read_text(encoding="utf-8") + comment = sql.split("COMMENT ON VIEW v_price_divergence IS", 1)[1].split(";", 1)[0] + assert ( + "match_or_create_listing" in comment + ), "комментарий не называет причину пустоты — без неё это просто «пока пусто»" + + +# ───────────────────────────────────────────────────────────────────────────── +# Задокументировано: мёртвая переменная окружения +# ───────────────────────────────────────────────────────────────────────────── + + +def test_browser_warns_about_retired_env() -> None: + """BROWSER_BLOCK_RESOURCES выставлен в трёх прод-контейнерах и ни на что не влияет. + + Сам browser/server.py тянет aiohttp+camoufox и в backend-окружении не + импортируется, поэтому проверяем исходник: переменная обязана быть в реестре + отставных И должна логироваться предупреждением на старте. + """ + src = (TRADEIN / "browser" / "server.py").read_text(encoding="utf-8") + assert "_RETIRED_ENV" in src + assert '"BROWSER_BLOCK_RESOURCES"' in src + assert "_warn_retired_env()" in src, "предупреждение не вызывается со старта" + # Переменная НЕ должна снова начать что-то менять — только предупреждать. + read_sites = re.findall(r'os\.environ(?:\.get)?[\[(]"BROWSER_BLOCK_RESOURCES"', src) + assert not read_sites, "BROWSER_BLOCK_RESOURCES снова читается как рабочий флаг" diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 4492cbad..4564c412 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -1,7 +1,7 @@ """Unit tests for cross-source physical dedup of analogs (#2087 H4). Проверяет: - - физический ключ (_phys_dedup_key) — что образует / не образует; + - композиты физического ключа (_lot_dedup_components) — что образуют / не образуют; - _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя (свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично; - выбор представителя детерминирован (freshest scraped_at). @@ -21,6 +21,16 @@ import pytest from app.services import estimator +def _street_token(addr: str | None) -> str: + """Уличный токен из ЖИВОЙ функции разбора адреса. + + #2674: раньше тут звался `estimator._extract_street_token` — однострочная обёртка, + которую прод не вызывал ни разу. Обёртку убрали; хелпер живёт в тестах, где ему и + место, а проверяется ровно тот `_parse_street_house`, что крутится в проде. + """ + return estimator._parse_street_house(addr)[0] + + def _lot( *, source: str, @@ -48,45 +58,56 @@ def _lot( # --------------------------------------------------------------------------- # -# _phys_dedup_key +# _lot_dedup_components — композиты физического ключа +# +# #2674: раньше эти пять тестов дёргали `estimator._phys_dedup_key` — обёртку без +# единого прод-вызова, которая возвращала `cad_key or street_key`. Прод так ключ НЕ +# строит: _union_find_phys_dedup держит ОБА композита и сливает по любому совпадению +# (с guard'ами на разные кадастры / разные номера домов). Обёртку убрали, тесты +# переведены на живую _lot_dedup_components → (cad_s, house_no, cad_key, street_key). # --------------------------------------------------------------------------- # def test_phys_key_prefers_cadastral_over_address() -> None: lot = _lot(source="avito", address="ул. Ленина 5", cad="66:41:0601025:94") - key = estimator._phys_dedup_key(lot) - assert key is not None - assert key[0] == "66:41:0601025:94" # building = cad, не address + _cad_s, _house, cad_key, street_key = estimator._lot_dedup_components(lot) + assert cad_key is not None + assert cad_key[0] == "66:41:0601025:94" # building = cad, не address + # Street-композит тоже строится — прод сливает по ЛЮБОМУ из двух, не по одному. + assert street_key is not None and street_key[0] == "ленина" def test_phys_key_normalizes_address_when_no_cad() -> None: # Тот же адрес, разное форматирование (регистр/пунктуация/пробелы) — # punctuation-only нормализация схлопывает их к одному building-компоненту. - a = estimator._phys_dedup_key(_lot(source="avito", address="Екатеринбург, ул. Ленина, д. 5")) - b = estimator._phys_dedup_key(_lot(source="cian", address="екатеринбург ул ленина д 5")) - assert a is not None and b is not None - assert a[0] == b[0] + a = estimator._lot_dedup_components( + _lot(source="avito", address="Екатеринбург, ул. Ленина, д. 5") + ) + b = estimator._lot_dedup_components(_lot(source="cian", address="екатеринбург ул ленина д 5")) + assert a[3] is not None and b[3] is not None + assert a[3][0] == b[3][0] def test_phys_key_different_streets_do_not_merge() -> None: # Токенное отличие (разная улица) НЕ должно сливаться — защита от ложного merge. - a = estimator._phys_dedup_key(_lot(source="avito", address="ул. Ленина 5")) - b = estimator._phys_dedup_key(_lot(source="cian", address="ул. Мира 5")) - assert a is not None and b is not None - assert a[0] != b[0] + a = estimator._lot_dedup_components(_lot(source="avito", address="ул. Ленина 5")) + b = estimator._lot_dedup_components(_lot(source="cian", address="ул. Мира 5")) + assert a[3] is not None and b[3] is not None + assert a[3][0] != b[3][0] def test_phys_key_none_without_area_or_price() -> None: - assert estimator._phys_dedup_key(_lot(source="avito", area=0)) is None - assert estimator._phys_dedup_key(_lot(source="avito", price=0)) is None + for lot in (_lot(source="avito", area=0), _lot(source="avito", price=0)): + _cad_s, _house, cad_key, street_key = estimator._lot_dedup_components(lot) + assert cad_key is None and street_key is None def test_phys_key_area_bucket_tolerates_rounding() -> None: # 66.9 и 67.0 → один и тот же area_bucket (round → 67) - a = estimator._phys_dedup_key(_lot(source="avito", area=66.9)) - b = estimator._phys_dedup_key(_lot(source="cian", area=67.0)) - assert a is not None and b is not None - assert a[2] == b[2] == 67 + a = estimator._lot_dedup_components(_lot(source="avito", area=66.9)) + b = estimator._lot_dedup_components(_lot(source="cian", area=67.0)) + assert a[3] is not None and b[3] is not None + assert a[3][2] == b[3][2] == 67 # --------------------------------------------------------------------------- # @@ -188,20 +209,20 @@ def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> def test_extract_street_token_across_source_formats() -> None: # Три РЕАЛЬНЫХ прод-формата одного дома (ул. Сыромолотова) → один токен. - cian = estimator._extract_street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") - domklik = estimator._extract_street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") - yandex = estimator._extract_street_token("Екатеринбург, улица Сыромолотова, 11") + cian = _street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") + domklik = _street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") + yandex = _street_token("Екатеринбург, улица Сыромолотова, 11") assert cian == domklik == yandex == "сыромолотова" def test_extract_street_token_strips_house_and_noise() -> None: - assert estimator._extract_street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина" - assert estimator._extract_street_token("проспект Космонавтов 15") == "космонавтов" - assert estimator._extract_street_token("пер. Банковский, 8а") == "банковский" + assert _street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина" + assert _street_token("проспект Космонавтов 15") == "космонавтов" + assert _street_token("пер. Банковский, 8а") == "банковский" # Нет типа улицы → распознать нельзя → пусто (безопасный fallback, лот уникален). - assert estimator._extract_street_token("Екатеринбург, ЖК Дружба") == "" - assert estimator._extract_street_token("") == "" - assert estimator._extract_street_token(None) == "" + assert _street_token("Екатеринбург, ЖК Дружба") == "" + assert _street_token("") == "" + assert _street_token(None) == "" def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None: @@ -325,8 +346,8 @@ def test_parse_numbered_streets_and_house_no() -> None: def test_extract_street_token_numbered_not_degraded() -> None: # Ведущая цифра нумерованной улицы разрешена, номер дома — нет. - assert estimator._extract_street_token("улица 8 Марта, 46") == "8 марта" - assert estimator._extract_street_token("ул. Белинского 86") == "белинского" + assert _street_token("улица 8 Марта, 46") == "8 марта" + assert _street_token("ул. Белинского 86") == "белинского" def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None: @@ -458,9 +479,9 @@ def test_parse_street_house_branch1_wins_over_mkr_noise() -> None: def test_extract_street_token_extended_same_token_across_formats() -> None: # Все три source-формата дома «Рассветная» → один и тот же токен → склейка. - domklik = estimator._extract_street_token("Рассветная улица, 6 к1") - avito = estimator._extract_street_token("Рассветная ул.,6к1") - cian = estimator._extract_street_token("улица Рассветная, 6к1") + domklik = _street_token("Рассветная улица, 6 к1") + avito = _street_token("Рассветная ул.,6к1") + cian = _street_token("улица Рассветная, 6к1") assert domklik == avito == cian == "рассветная" diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py index 0803ab8f..cff1264d 100644 --- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py +++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py @@ -170,12 +170,16 @@ def test_save_history_items_inserts_each(): ): saved = _save_yandex_history_items(db, result) assert saved == 2 - # 1 batch INSERT (executemany) + 1 commit - assert db.execute.call_count == 1 - args = db.execute.call_args - rows = args.args[1] + # 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE + # houses.has_panorama — считаем именно вставки истории, а не все execute. + inserts = [ + c + for c in db.execute.call_args_list + if "INSERT INTO house_placement_history" in str(c.args[0]) + ] + assert len(inserts) == 1 + rows = inserts[0].args[1] assert isinstance(rows, list) and len(rows) == 2 - db.commit.assert_called_once() def test_save_history_items_empty_no_commit(): diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py index 3bc90192..481d6610 100644 --- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -155,8 +155,16 @@ def test_all_invalid_area_returns_zero_no_crash() -> None: assert saved == 0 # db.execute не должен вызываться для пустого rows (нет INSERT) - # Commit вызывается, rollback — нет. - db.commit.assert_called_once() + inserts = [ + c + for c in db.execute.call_args_list + if "INSERT INTO house_placement_history" in str(c.args[0]) + ] + assert inserts == [] + # Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись + # houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась + # ли история по площади. + assert db.commit.call_count == 2 db.rollback.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 9079f0df..20386d31 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py @@ -90,9 +90,16 @@ def test_save_row_contains_house_id_and_confidence(): ): _save_yandex_history_items(db, result) - # db.execute called once with list-of-dicts (executemany, one round-trip) - assert db.execute.call_count == 1 - rows = db.execute.call_args_list[0].args[1] + # История — один execute со list-of-dicts (executemany, один round-trip). + # #2674 добавил отдельный UPDATE houses.has_panorama — фильтруем по SQL, а не + # по порядковому номеру вызова. + inserts = [ + c + for c in db.execute.call_args_list + if "INSERT INTO house_placement_history" in str(c.args[0]) + ] + assert len(inserts) == 1 + rows = inserts[0].args[1] assert isinstance(rows, list) and len(rows) == 2 for row in rows: assert row["house_id"] == 54321 diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index bc97d927..46c9deb0 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -47,6 +47,9 @@ Per-provider модель (#1793): для гидрации, CSS проверяется anti-bot fingerprint. (legacy alias BROWSER_BLOCKED_RESOURCE_TYPES всё ещё читается как fallback, если новая переменная не задана.) + ⚠ BROWSER_BLOCK_RESOURCES (булев выключатель до #1812) + НЕ читается — см. _RETIRED_ENV; в проде он всё ещё + выставлен во всех трёх контейнерах и ни на что не влияет. BROWSER_MIN_PAGE_INTERVAL_S — минимальный интервал (сек) между последовательными page.goto ОДНОГО провайдера (default: 2.0). Даёт под- коннектам предыдущей страницы дренироваться, прежде чем @@ -536,6 +539,36 @@ async def _on_startup(app: web.Application) -> None: list(PROVIDERS), ) logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) + _warn_retired_env() + + +# Переменные, которые окружение всё ещё задаёт, а код больше не читает. Ставим сюда +# ровно те, что реально видели в проде — молчаливо игнорируемая ручка опаснее +# отсутствующей: оператор ставит BROWSER_BLOCK_RESOURCES=false, чтобы посмотреть +# страницу с ресурсами, ничего не меняется, и он делает вывод не о переменной, +# а о блокировке. +_RETIRED_ENV: dict[str, str] = { + # #1812 заменил булев выключатель на список типов. Блокировка при этом НЕ + # ослабла: image глушится camoufox block_images (_launch_browser), font/media — + # дефолтом BROWSER_BLOCK_RESOURCE_TYPES. Мёртв только сам выключатель. + "BROWSER_BLOCK_RESOURCES": ( + "BROWSER_BLOCK_RESOURCE_TYPES (CSV типов; пустая строка = не блокировать)" + ), +} + + +def _warn_retired_env() -> None: + """Предупреждает про заданные env, которые код больше не читает (#2674).""" + for name, replacement in _RETIRED_ENV.items(): + if os.environ.get(name) is not None: + logger.warning( + "tradein-browser: %s=%r задана, но НЕ читается кодом с #1812 — " + "значение ни на что не влияет. Замена: %s. Уберите переменную из " + "окружения, чтобы она не выглядела рабочим выключателем", + name, + os.environ[name], + replacement, + ) async def _on_cleanup(app: web.Application) -> None: diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 39f5eacf..399f6722 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -485,5 +485,39 @@ def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) assert slept == [], "разные провайдеры не пейсят друг друга" +# ── #2674: отставные переменные окружения ──────────────────────────────────── +# BROWSER_BLOCK_RESOURCES стоит =true во всех трёх прод-контейнерах, а код перестал +# её читать в #1812 (булев выключатель → BROWSER_BLOCK_RESOURCE_TYPES). Блокировка при +# этом не ослабла (image глушит camoufox block_images, font/media — дефолт списка), но +# сама ручка мертва: выставив =false, оператор ничего не выключит и сделает вывод о +# блокировке, а не о переменной. Сервис обязан сказать это вслух на старте. + + +def test_retired_env_warns_when_set( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") + with caplog.at_level("WARNING"): + server._warn_retired_env() + assert any("BROWSER_BLOCK_RESOURCES" in r.getMessage() for r in caplog.records), ( + "мёртвая переменная выставлена, а сервис молчит" + ) + + +def test_retired_env_silent_when_unset( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.delenv("BROWSER_BLOCK_RESOURCES", raising=False) + with caplog.at_level("WARNING"): + server._warn_retired_env() + assert caplog.records == [], "предупреждение без повода — это шум, а не сигнал" + + +def test_retired_env_does_not_change_blocking(monkeypatch: pytest.MonkeyPatch) -> None: + """Отставная переменная остаётся отставной: блокировкой правит только список типов.""" + monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false") + assert server._parse_block_types(None) == frozenset({"font", "media"}) + + async def _coro(value: Any) -> Any: return value diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py index 9f62c966..17e51f7d 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py @@ -101,7 +101,7 @@ class CianValuationResult: # House info (15 items: year, type, floors, etc.) house_info: list[dict[str, Any]] = field(default_factory=list) external_house_id: int | None = None # filters.houseId - filters_hash: str | None = None # estimation.sale.data.filtersHash + filters_hash: str | None = None # estimation.sale.filtersHash (#2674: не .data.) # Management company (unique to Cian Valuation) management_company: dict[str, Any] | None = None @@ -262,7 +262,8 @@ def _parse_valuation_state(state: dict[str, Any]) -> CianValuationResult: """Extract CianValuationResult from Cian valuation initialState. Mapping (per Schema_Cian_SERP_Inventory sec 24.4–24.6): - estimation.sale.data.{price, accuracy, priceFrom, priceTo, priceSqm, filtersHash} + estimation.sale.data.{price, accuracy, priceFrom, priceTo, priceSqm} + estimation.sale.filtersHash (НЕ внутри data — см. #2674) estimation.rent.data.{price, accuracy, priceFrom, priceTo, taxPrice} estimationChart.data.{title.{change, changeValue}, chartData.data[]} houseInfo.data.{items[], ...} @@ -284,7 +285,11 @@ def _parse_valuation_state(state: dict[str, Any]) -> CianValuationResult: result.sale_price_from = _parse_num(sale_data.get("priceFrom")) result.sale_price_to = _parse_num(sale_data.get("priceTo")) result.sale_price_sqm = _parse_num(sale_data.get("priceSqm")) - result.filters_hash = sale_data.get("filtersHash") + # #2674: filtersHash лежит на УРОВЕНЬ ВЫШЕ — рядом с data/isError/isFetching, а не + # внутри data. Читали sale_data → колонка external_valuations.filters_hash была + # пуста 0/1658, хотя в сырых ответах ключ есть у 139/139 (все 139 значений разные). + # Ключи estimation.sale на проде: {isError, filtersHash, data, isFetching}. + result.filters_hash = sale_wrapper.get("filtersHash") # --- estimation: rent --- rent_wrapper = estimation.get("rent") or {}