chore(tradein): разбор мёртвого кода — подключить, удалить или задокументировать (#2674) #2689

Merged
bot-backend merged 2 commits from chore/2674-dead-code-sweep into main 2026-08-06 00:37:06 +00:00
11 changed files with 945 additions and 76 deletions

View file

@ -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]:

View file

@ -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",

View 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;

View 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 снова читается как рабочий флаг"

View file

@ -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 == "рассветная"

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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.424.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 {}