chore(tradein): разбор мёртвого кода — подключить, удалить или задокументировать (#2674) #2689
11 changed files with 945 additions and 76 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -826,10 +827,15 @@ def _save_yandex_history_items(
|
|||
(address|publish_date|area|floor|prices) hash.
|
||||
|
||||
Batch semantics: single try/except; on any failure the batch rolls back.
|
||||
"""
|
||||
if not result.history_items:
|
||||
return 0
|
||||
|
||||
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
|
||||
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
|
||||
без единого объявления в истории, до записи панорамы не доходила — на проде это
|
||||
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
|
||||
переноса: match_or_create_house теперь вызывается и для таких страниц (может
|
||||
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
|
||||
остальных 90% — новых сущностей класс не появляется, появляется недостающая доля.
|
||||
"""
|
||||
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
||||
# — stable across re-runs, distinguishes pages for different addresses.
|
||||
address_seed = (result.address or "").strip().lower()
|
||||
|
|
@ -865,6 +871,12 @@ def _save_yandex_history_items(
|
|||
result.address,
|
||||
)
|
||||
|
||||
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
|
||||
_save_yandex_house_panorama(db, house_id, result.house)
|
||||
|
||||
if not result.history_items:
|
||||
return 0
|
||||
|
||||
rows = []
|
||||
skipped_area = 0
|
||||
for item in result.history_items:
|
||||
|
|
@ -946,6 +958,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 +5830,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 +5867,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]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
245
tradein-mvp/backend/data/sql/216_dead_code_sweep.sql
Normal file
245
tradein-mvp/backend/data/sql/216_dead_code_sweep.sql
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
-- 216_dead_code_sweep.sql
|
||||
-- Purpose (#2674, раздел «Мёртвый код»): развести три разных вещи, которые снаружи
|
||||
-- выглядят одинаково — «написано и ни разу не сработало».
|
||||
--
|
||||
-- 1. ОБОРВАННАЯ ПРОВОДКА — механизм рабочий, звать некому/нечем. Чиним подключением.
|
||||
-- 2. МЁРТВОЕ — механизм невыразим, дублирует существующее или потерял смысл. Удаляем.
|
||||
-- 3. ЗАДЕЛ — оставляем, но в схеме должно быть написано, чем он НЕ является сегодня.
|
||||
--
|
||||
-- Все числа — с прод-БД tradein 2026-08-06, ТОЧНЫМ count(*). Первая редакция несла
|
||||
-- сюда reltuples-оценки планировщика (listings «142 569» против реальных 93 408 —
|
||||
-- раздув мёртвыми кортежами на 53%); в постоянном комментарии к схеме оценке не место,
|
||||
-- тем более в PR, тезис которого — «каждое утверждение несёт число с прода».
|
||||
--
|
||||
-- ── 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 — 93 408 строк, NULL у всех, ноль упоминаний в коде.
|
||||
-- Заведена в 028 «under dedup workflow», который так и не построили; 113 уже
|
||||
-- писала прямым текстом «column is dead, no code writer». Дедуп объявлений живёт
|
||||
-- в другом месте и по-другому (estimator._union_find_phys_dedup, во время оценки,
|
||||
-- без записи в БД). Соседнюю listings.canonical НЕ трогаем — она вырождена (t у
|
||||
-- всех 93 408), но её читает WHERE listings_search_mv (050/094), и снос колонки
|
||||
-- потянул бы пересоздание matview с шестью индексами ради нулевого выигрыша.
|
||||
--
|
||||
-- E. house_sources.raw_payload + GIN-индекс по нему — 49 502 строки, NULL у всех.
|
||||
-- Оба писателя house_sources (matching/houses.py:556, house_dedup_merge.py:493)
|
||||
-- эту колонку в INSERT не включают; читателей нет, из публичного контракта
|
||||
-- market.v_house_sources (154) она намеренно исключена. GIN-индекс по колонке,
|
||||
-- которая всегда NULL, — чистая стоимость на каждой вставке.
|
||||
-- Соседний house_sources.ext_url тоже пуст 49 502/49 502, но он ВХОДИТ в
|
||||
-- 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).
|
||||
-- Окно 01:00-02:00 UTC. Первая редакция ставила 03:00-04:00 — ровно туда, где сидит
|
||||
-- refresh_search_matview (сверено с прод-таблицей scrape_schedules), то есть именно
|
||||
-- то задание, которое и переносит year_built в поиск. Планировщик берёт случайный
|
||||
-- момент внутри окна и гоняет источники ПАРАЛЛЕЛЬНО, порядок он не гарантирует
|
||||
-- ничем — совпадение окон превращало «сначала загрузка, потом обновление поиска»
|
||||
-- в подбрасывание монеты. Час до 02:00 разводит их при типовой длительности прогона
|
||||
-- и остаётся раньше rosreestr_dkp_import (04:00-06:00) и
|
||||
-- asking_to_sold_ratio_refresh (06:00-07:00).
|
||||
-- ЧЕСТНАЯ ОГОВОРКА: гарантии всё равно нет — при аномально долгом прогоне (сеть
|
||||
-- ДОМ.РФ, ретраи) свежий year_built доедет до поиска на цикл позже. Ни блокировок,
|
||||
-- ни потери данных: следующее обновление matview его подхватит.
|
||||
-- Соседи в 01:00-02:00 — listing_source_snapshot и avito_city_sweep_kamensk_uralskiy;
|
||||
-- общих ресурсов нет (ДОМ.РФ ходит своим httpx, мимо прокси-пула).
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'domrf_kapremont_load',
|
||||
true,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) 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 у всех 49 502 строк — ни один из двух писателей 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;
|
||||
362
tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
Normal file
362
tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"""Разбор мёртвого кода #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_written_when_page_has_no_history() -> None:
|
||||
"""Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде.
|
||||
|
||||
Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие
|
||||
страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение
|
||||
о доме к наличию объявлений отношения не имеет.
|
||||
"""
|
||||
db = MagicMock()
|
||||
result = _result_with_meta(
|
||||
ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True)
|
||||
)
|
||||
result.history_items = []
|
||||
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
return_value=(42, 0.9, "fp"),
|
||||
):
|
||||
assert _save_yandex_history_items(db, result) == 0
|
||||
|
||||
assert _panorama_updates(db) == [{"hid": 42, "panorama": True}]
|
||||
|
||||
|
||||
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_window_does_not_collide_with_matview_refresh() -> None:
|
||||
"""Окно ДОМ.РФ не должно совпадать с refresh_search_matview (03:00-04:00 UTC).
|
||||
|
||||
Ревью #2689: планировщик берёт случайный момент внутри окна и гоняет источники
|
||||
параллельно — общее окно с тем заданием, которое переносит year_built в поиск,
|
||||
это подбрасывание монеты. Тест ловит откат окна обратно на 3.
|
||||
"""
|
||||
sql = MIGRATION.read_text(encoding="utf-8")
|
||||
values = sql.split("'domrf_kapremont_load',", 1)[1].split(")", 1)[0]
|
||||
tokens = [t.strip().rstrip(",") for t in values.splitlines()]
|
||||
hours = [int(t) for t in tokens if t.isdigit()]
|
||||
assert hours, "не нашли window_start_hour/window_end_hour в INSERT"
|
||||
start, end = hours[0], hours[1]
|
||||
matview_start, matview_end = 3, 4 # прод-значение scrape_schedules на 2026-08-06
|
||||
assert end <= matview_start or start >= matview_end, (
|
||||
f"окно {start}-{end} пересекается с refresh_search_matview "
|
||||
f"{matview_start}-{matview_end}"
|
||||
)
|
||||
|
||||
|
||||
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 снова читается как рабочий флаг"
|
||||
|
|
@ -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 == "рассветная"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ from app.services.estimator import (
|
|||
from app.services.scraper_settings import get_scraper_delay
|
||||
|
||||
|
||||
def _history_rows(db) -> list[dict]:
|
||||
"""Строки батча house_placement_history из мока сессии (фильтр по SQL, не по позиции)."""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
return call.args[1]
|
||||
return []
|
||||
|
||||
|
||||
def _sample_result(address: str = "Екатеринбург, ул. Учителей, 18") -> YandexValuationResult:
|
||||
return YandexValuationResult(
|
||||
address=address,
|
||||
|
|
@ -170,15 +178,22 @@ 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.
|
||||
rows = _history_rows(db)
|
||||
assert isinstance(rows, list) and len(rows) == 2
|
||||
db.commit.assert_called_once()
|
||||
# Два коммита: панорама (до истории) + батч истории. Раньше был один.
|
||||
assert db.commit.call_count == 2
|
||||
|
||||
|
||||
def test_save_history_items_empty_no_commit():
|
||||
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
|
||||
|
||||
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
|
||||
отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%).
|
||||
Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается —
|
||||
а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает.
|
||||
"""
|
||||
db = MagicMock()
|
||||
result = YandexValuationResult(
|
||||
address="x",
|
||||
|
|
@ -189,11 +204,13 @@ def test_save_history_items_empty_no_commit():
|
|||
house=ValuationHouseMeta(),
|
||||
history_items=[],
|
||||
)
|
||||
# match_or_create_house must NOT be called when there are no items (early return)
|
||||
with patch("app.services.estimator.match_or_create_house") as m:
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
return_value=(1, 0.9, "fingerprint"),
|
||||
) as m:
|
||||
saved = _save_yandex_history_items(db, result)
|
||||
assert saved == 0
|
||||
m.assert_not_called()
|
||||
m.assert_called_once()
|
||||
db.execute.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
|
@ -227,9 +244,20 @@ def test_save_history_items_ext_id_stable_across_calls():
|
|||
|
||||
|
||||
def test_save_history_items_db_error_rolls_back_batch():
|
||||
"""Any item failing rolls back the whole batch — batch semantics (finding #5)."""
|
||||
"""Any item failing rolls back the whole batch — batch semantics (finding #5).
|
||||
|
||||
#2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение
|
||||
доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает),
|
||||
а батч истории проходил бы успешно, и тест молча проверял бы не тот путь.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [RuntimeError("first row fails"), None]
|
||||
|
||||
def _fail_history(sql, *args, **kwargs):
|
||||
if "INSERT INTO house_placement_history" in str(sql):
|
||||
raise RuntimeError("first row fails")
|
||||
return MagicMock()
|
||||
|
||||
db.execute.side_effect = _fail_history
|
||||
result = _sample_result()
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
|
|
@ -238,4 +266,5 @@ def test_save_history_items_db_error_rolls_back_batch():
|
|||
saved = _save_yandex_history_items(db, result)
|
||||
assert saved == 0 # whole batch rolled back
|
||||
db.rollback.assert_called_once()
|
||||
db.commit.assert_not_called()
|
||||
# Панорама коммитится отдельно и раньше — её успех не отменяет отката истории.
|
||||
assert db.commit.call_count == 1
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ from scraper_kit.providers.yandex.valuation import (
|
|||
from app.services.estimator import _save_yandex_history_items
|
||||
|
||||
|
||||
def _history_rows(db) -> list[dict]:
|
||||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
return call.args[1]
|
||||
return []
|
||||
|
||||
|
||||
def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult:
|
||||
return YandexValuationResult(
|
||||
address="Россия, Свердловская область, Екатеринбург, ул. Куйбышева, 106",
|
||||
|
|
@ -84,7 +97,7 @@ def test_item_with_area_none_is_skipped() -> None:
|
|||
saved = _save_yandex_history_items(db, result)
|
||||
|
||||
assert saved == 1, f"Ожидали 1 сохранённый item, получили {saved}"
|
||||
rows = db.execute.call_args_list[0].args[1]
|
||||
rows = _history_rows(db)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["area"] == 50.0
|
||||
|
||||
|
|
@ -109,7 +122,7 @@ def test_item_with_area_zero_is_skipped() -> None:
|
|||
saved = _save_yandex_history_items(db, result)
|
||||
|
||||
assert saved == 1
|
||||
rows = db.execute.call_args_list[0].args[1]
|
||||
rows = _history_rows(db)
|
||||
assert rows[0]["area"] == 55.0
|
||||
|
||||
|
||||
|
|
@ -155,8 +168,11 @@ def test_all_invalid_area_returns_zero_no_crash() -> None:
|
|||
|
||||
assert saved == 0
|
||||
# db.execute не должен вызываться для пустого rows (нет INSERT)
|
||||
# Commit вызывается, rollback — нет.
|
||||
db.commit.assert_called_once()
|
||||
assert _history_rows(db) == []
|
||||
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
|
||||
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
|
||||
# ли история по площади.
|
||||
assert db.commit.call_count == 2
|
||||
db.rollback.assert_not_called()
|
||||
|
||||
|
||||
|
|
@ -200,7 +216,7 @@ def test_mixed_items_only_valid_saved() -> None:
|
|||
saved = _save_yandex_history_items(db, result)
|
||||
|
||||
assert saved == 2
|
||||
rows = db.execute.call_args_list[0].args[1]
|
||||
rows = _history_rows(db)
|
||||
assert len(rows) == 2
|
||||
areas = {r["area"] for r in rows}
|
||||
assert areas == {40.0, 60.0}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,19 @@ from scraper_kit.providers.yandex.valuation import (
|
|||
from app.services.estimator import _save_yandex_history_items
|
||||
|
||||
|
||||
def _history_rows(db) -> list[dict]:
|
||||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
return call.args[1]
|
||||
return []
|
||||
|
||||
|
||||
def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult:
|
||||
return YandexValuationResult(
|
||||
address="Россия, Свердловская область, Екатеринбург, улица Куйбышева, 106",
|
||||
|
|
@ -90,9 +103,8 @@ 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).
|
||||
rows = _history_rows(db)
|
||||
assert isinstance(rows, list) and len(rows) == 2
|
||||
for row in rows:
|
||||
assert row["house_id"] == 54321
|
||||
|
|
@ -125,7 +137,7 @@ def test_save_row_contains_removed_date():
|
|||
_save_yandex_history_items(db, result)
|
||||
|
||||
# args[1] is now the list-of-dicts passed to executemany
|
||||
rows = db.execute.call_args_list[0].args[1]
|
||||
rows = _history_rows(db)
|
||||
assert rows[0]["removed_date"] == date(2024, 5, 20)
|
||||
|
||||
|
||||
|
|
@ -152,7 +164,7 @@ def test_save_row_removed_date_none_when_active():
|
|||
):
|
||||
_save_yandex_history_items(db, result)
|
||||
|
||||
rows = db.execute.call_args_list[0].args[1]
|
||||
rows = _history_rows(db)
|
||||
assert rows[0]["removed_date"] is None
|
||||
|
||||
|
||||
|
|
@ -180,7 +192,7 @@ def test_save_handles_match_failure_gracefully():
|
|||
saved = _save_yandex_history_items(db, result)
|
||||
|
||||
assert saved == 1
|
||||
rows = db.execute.call_args_list[0].args[1]
|
||||
rows = _history_rows(db)
|
||||
assert rows[0]["house_id"] is None
|
||||
assert rows[0]["confidence"] == pytest.approx(0.0)
|
||||
assert rows[0]["notes"] is None
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue