fix(tradein/yandex): снести признак «панорама» — его нет на площадке #2851
11 changed files with 280 additions and 219 deletions
|
|
@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import (
|
|||
estimate_via_cian_valuation,
|
||||
)
|
||||
from scraper_kit.providers.yandex.valuation import (
|
||||
ValuationHouseMeta,
|
||||
YandexValuationResult,
|
||||
YandexValuationScraper,
|
||||
)
|
||||
|
|
@ -987,13 +986,13 @@ def _save_yandex_history_items(
|
|||
|
||||
Batch semantics: single try/except; on any failure the batch rolls back.
|
||||
|
||||
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
|
||||
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
|
||||
без единого объявления в истории, до записи панорамы не доходила — на проде это
|
||||
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
|
||||
переноса: match_or_create_house теперь вызывается и для таких страниц (может
|
||||
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
|
||||
остальных 90% — новых сущностей класс не появляется, появляется недостающая доля.
|
||||
#2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
|
||||
возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
|
||||
в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с
|
||||
историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
|
||||
«панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
|
||||
match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
|
||||
тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
|
||||
"""
|
||||
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
||||
# — stable across re-runs, distinguishes pages for different addresses.
|
||||
|
|
@ -1030,9 +1029,6 @@ def _save_yandex_history_items(
|
|||
result.address,
|
||||
)
|
||||
|
||||
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
|
||||
_save_yandex_house_panorama(db, house_id, result.house)
|
||||
|
||||
if not result.history_items:
|
||||
return 0
|
||||
|
||||
|
|
@ -1117,58 +1113,13 @@ 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()
|
||||
# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
|
||||
# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
|
||||
# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
|
||||
# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
|
||||
# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
|
||||
# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
|
||||
# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
|
||||
|
||||
|
||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
|
|||
"commission_year": ["cian_serp", "yandex_realty_nb"],
|
||||
"commission_month": ["yandex_realty_nb"], # raw RU month name
|
||||
"developer_name": ["cian", "yandex_realty_nb"],
|
||||
"has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag
|
||||
# #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259).
|
||||
# В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала,
|
||||
# источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда,
|
||||
# потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на
|
||||
# проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML).
|
||||
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
|
||||
# Yandex Valuation enrichment (existing house attrs)
|
||||
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
|
||||
|
|
|
|||
179
tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
Normal file
179
tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
-- 259_houses_drop_has_panorama.sql
|
||||
-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области.
|
||||
--
|
||||
-- Dependencies: 031_houses_alter_yandex.sql (завела колонку),
|
||||
-- 154_market_contract_views.sql (внесла её в публичный контракт
|
||||
-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант
|
||||
-- gendesign_reader на этот view).
|
||||
-- Apply after: 258_houses_imv_transient_attempts.sql
|
||||
-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama),
|
||||
-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов
|
||||
-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок
|
||||
-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке
|
||||
-- yandex_valuation — молча проглоченный, но с WARNING в логах.
|
||||
--
|
||||
-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ──────────────────────────────────────
|
||||
-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса.
|
||||
-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.05–12.08.2026:
|
||||
-- страниц ............................................................. 1536
|
||||
-- has_panorama = true .................................................... 0
|
||||
-- has_panorama = false ................................................ 1536
|
||||
-- ключ отсутствует ....................................................... 0
|
||||
-- houses: 9468 строк, has_panorama непустых 12, из них true 0.
|
||||
--
|
||||
-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом
|
||||
-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же
|
||||
-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200:
|
||||
-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель,
|
||||
-- 2,50 м потолки, 46 объектов
|
||||
-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт.
|
||||
-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта
|
||||
-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama /
|
||||
-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную
|
||||
-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице:
|
||||
-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт ·
|
||||
-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») —
|
||||
-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме.
|
||||
--
|
||||
-- Ноль был механически гарантирован самим кодом и о предметной области не говорил
|
||||
-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится
|
||||
-- удалением, а не починкой разбора.
|
||||
--
|
||||
-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только
|
||||
-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не
|
||||
-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной
|
||||
-- истории её тоже не было.
|
||||
--
|
||||
-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ────────────────────────────────
|
||||
-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a
|
||||
-- column later is backward compatible, renaming/removing one is not». Это осознанное
|
||||
-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не
|
||||
-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч.
|
||||
-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель
|
||||
-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и
|
||||
-- никогда не станет ничем другим, — обещать данные, которых не существует.
|
||||
--
|
||||
-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся:
|
||||
-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что
|
||||
-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный
|
||||
-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что
|
||||
-- появится позже, без единого слова в логе.
|
||||
--
|
||||
-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE).
|
||||
-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он
|
||||
-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки
|
||||
-- внешний ETL получил бы permission denied на следующем же прогоне.
|
||||
--
|
||||
-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ──────────────────────────
|
||||
-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не
|
||||
-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в
|
||||
-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий
|
||||
-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать
|
||||
-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут
|
||||
-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено
|
||||
-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание =
|
||||
-- честный красный деплой через 5 секунд, миграция не помечается применённой.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP —
|
||||
-- безопасный re-run.
|
||||
-- - Без CASCADE.
|
||||
-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean),
|
||||
-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false.
|
||||
--
|
||||
-- Критерий приёмки (записан ДО применения):
|
||||
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
|
||||
-- 2. information_schema.columns по houses: has_panorama отсутствует.
|
||||
-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на
|
||||
-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк.
|
||||
-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на
|
||||
-- market.v_houses.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
|
||||
-- и в .claude/rules/sql.md § lock_timeout.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DROP VIEW IF EXISTS market.v_houses;
|
||||
|
||||
ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama;
|
||||
|
||||
-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна
|
||||
-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *.
|
||||
CREATE VIEW market.v_houses AS
|
||||
SELECT
|
||||
id,
|
||||
source,
|
||||
ext_house_id,
|
||||
url,
|
||||
slug,
|
||||
address,
|
||||
full_address,
|
||||
short_address,
|
||||
lat,
|
||||
lon,
|
||||
geom,
|
||||
year_built,
|
||||
house_type,
|
||||
house_class,
|
||||
material_walls,
|
||||
material_floors,
|
||||
series_name,
|
||||
total_floors,
|
||||
total_units,
|
||||
entrances,
|
||||
flat_count,
|
||||
is_emergency,
|
||||
passenger_elevators,
|
||||
cargo_elevators,
|
||||
has_concierge,
|
||||
closed_yard,
|
||||
has_playground,
|
||||
hot_water,
|
||||
heat_supply_type,
|
||||
gas_supply_type,
|
||||
overlap_type,
|
||||
parking_type,
|
||||
infrastructure_summary,
|
||||
infrastructure_walk_distance,
|
||||
developer_name,
|
||||
developer_key,
|
||||
management_company_id,
|
||||
rating,
|
||||
reviews_count,
|
||||
rating_score,
|
||||
rating_string,
|
||||
transport_accessibility_rate,
|
||||
advantages,
|
||||
banks,
|
||||
builders,
|
||||
houses_by_turn,
|
||||
corpus_count,
|
||||
commission_year,
|
||||
commission_month,
|
||||
total_area_ha,
|
||||
cadastral_number,
|
||||
house_fias_id,
|
||||
yandex_jk_id,
|
||||
yandex_jk_slug,
|
||||
cian_internal_house_id,
|
||||
cian_zhk_url,
|
||||
raw_payload,
|
||||
first_seen_at,
|
||||
last_scraped_at
|
||||
FROM public.houses;
|
||||
|
||||
COMMENT ON VIEW market.v_houses IS
|
||||
'Stable public contract over public.houses (#2130). Explicit column list is the '
|
||||
'stability promise — do not SELECT * against the base table from external '
|
||||
'consumers. raw_payload is included because it is read today by gendesign ETL '
|
||||
'#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at '
|
||||
'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama '
|
||||
'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно '
|
||||
'(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).';
|
||||
|
||||
-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155.
|
||||
GRANT SELECT ON market.v_houses TO gendesign_reader;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -248,3 +248,4 @@
|
|||
257_listings_backfill_yandex_source_url.sql
|
||||
258_houses_imv_transient_attempts.sql
|
||||
259_data_quality_drop_pct_cadastr.sql
|
||||
260_houses_drop_has_panorama.sql
|
||||
|
|
|
|||
|
|
@ -113,9 +113,17 @@ class TestYandexHousePriority:
|
|||
|
||||
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
|
||||
|
||||
def test_house_has_panorama_yandex_valuation_only(self) -> None:
|
||||
out = resolve_house_field("has_panorama", {"yandex_valuation": True})
|
||||
assert out is True
|
||||
def test_has_panorama_removed_from_house_priority(self) -> None:
|
||||
"""#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259).
|
||||
|
||||
В отличие от ceiling_height выше, это правило было ИСПОЛНИМО — колонка
|
||||
существовала, единственный источник её писал. Разрешать было нечего:
|
||||
yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде),
|
||||
потому что слова «панорам» на странице оценки нет вовсе.
|
||||
"""
|
||||
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
|
||||
|
||||
assert "has_panorama" not in HOUSE_FIELD_PRIORITY
|
||||
|
||||
def test_house_yandex_total_listings_yandex_valuation_only(self) -> None:
|
||||
out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42})
|
||||
|
|
@ -217,7 +225,6 @@ class TestYandexListingPriority:
|
|||
"corpus_count",
|
||||
"total_area_ha",
|
||||
"commission_month",
|
||||
"has_panorama",
|
||||
"yandex_total_listings",
|
||||
]
|
||||
for key in yandex_keys:
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
Каждая правка эпика — тест, который краснеет без неё:
|
||||
|
||||
подключено:
|
||||
- houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница
|
||||
не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»);
|
||||
- domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules —
|
||||
именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным;
|
||||
- filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash.
|
||||
|
|
@ -15,7 +13,11 @@
|
|||
читателя и писателя;
|
||||
- listings.merged_into, house_sources.raw_payload — колонки без писателя;
|
||||
- v_data_quality.price_disagreements_count — показатель, который не мог быть
|
||||
ненулевым.
|
||||
ненулевым;
|
||||
- houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке.
|
||||
Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя;
|
||||
подключённый писатель за 7 дней записал 12 значений, все false. Разница между
|
||||
«проводка оборвана» и «мерить нечего» видна не по коду, а по площадке.
|
||||
|
||||
задокументировано:
|
||||
- BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт —
|
||||
|
|
@ -29,19 +31,13 @@ from __future__ import annotations
|
|||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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"
|
||||
|
|
@ -50,118 +46,7 @@ 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: загрузчик ДОМ.РФ — оборванная проводка
|
||||
# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None:
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали
|
||||
# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None:
|
|||
"asking_to_sold_ratios_tiered",
|
||||
"asking_to_sold_tier_bounds",
|
||||
"price_disagreements_count",
|
||||
# Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета
|
||||
# источников. Гейт краснеет на любом из трёх, а не только на одном (первая
|
||||
# редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет).
|
||||
"has_panorama",
|
||||
]
|
||||
offenders: list[str] = []
|
||||
for path in _live_python_sources():
|
||||
|
|
@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None:
|
|||
assert "COMMENT ON VIEW v_price_divergence" in sql
|
||||
|
||||
|
||||
def test_has_panorama_dropped_together_with_the_contract_column() -> None:
|
||||
"""Снос колонки обязан пересобрать market.v_houses и вернуть ему грант.
|
||||
|
||||
has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку
|
||||
не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три
|
||||
части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен.
|
||||
"""
|
||||
sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8")
|
||||
assert "DROP VIEW IF EXISTS market.v_houses" in sql
|
||||
assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql
|
||||
assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока"
|
||||
|
||||
view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0]
|
||||
assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой"
|
||||
# Контракт — обещание стабильности: пересборка не должна заодно потерять соседей.
|
||||
for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"):
|
||||
assert kept in view_ddl, f"{kept} пропала из контракта при пересборке"
|
||||
assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, (
|
||||
"DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит "
|
||||
"permission denied на следующем прогоне"
|
||||
)
|
||||
|
||||
|
||||
def test_price_divergence_is_documented_as_structurally_empty() -> None:
|
||||
"""Оставленный задел обязан говорить, чем он НЕ является сегодня."""
|
||||
sql = MIGRATION.read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -178,21 +178,22 @@ def test_save_history_items_inserts_each():
|
|||
):
|
||||
saved = _save_yandex_history_items(db, result)
|
||||
assert saved == 2
|
||||
# 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE
|
||||
# houses.has_panorama — считаем именно вставки истории, а не все execute.
|
||||
# 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова:
|
||||
# #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой.
|
||||
rows = _history_rows(db)
|
||||
assert isinstance(rows, list) and len(rows) == 2
|
||||
# Два коммита: панорама (до истории) + батч истории. Раньше был один.
|
||||
assert db.commit.call_count == 2
|
||||
# Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе
|
||||
# с колонкой, хвост #2674, мигр. 259.
|
||||
assert db.commit.call_count == 1
|
||||
|
||||
|
||||
def test_save_history_items_empty_no_commit():
|
||||
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
|
||||
|
||||
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
|
||||
отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%).
|
||||
Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается —
|
||||
а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает.
|
||||
отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь
|
||||
match_or_create_house вызывается до возврата — а записей по-прежнему ноль:
|
||||
истории нет, вставлять нечего.
|
||||
"""
|
||||
db = MagicMock()
|
||||
result = YandexValuationResult(
|
||||
|
|
@ -246,9 +247,9 @@ 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).
|
||||
|
||||
#2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение
|
||||
доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает),
|
||||
а батч истории проходил бы успешно, и тест молча проверял бы не тот путь.
|
||||
#2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе
|
||||
и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы
|
||||
не тот путь, стоит появиться любому новому execute перед вставкой истории.
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
|
|
@ -266,5 +267,4 @@ 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()
|
||||
# Панорама коммитится отдельно и раньше — её успех не отменяет отката истории.
|
||||
assert db.commit.call_count == 1
|
||||
assert db.commit.call_count == 0
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]:
|
|||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
сломалась, как только у функции появился второй execute перед вставкой истории.
|
||||
Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
|
|
@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None:
|
|||
assert saved == 0
|
||||
# db.execute не должен вызываться для пустого rows (нет INSERT)
|
||||
assert _history_rows(db) == []
|
||||
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
|
||||
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
|
||||
# ли история по площади.
|
||||
assert db.commit.call_count == 2
|
||||
# Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит
|
||||
# (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259.
|
||||
assert db.commit.call_count == 1
|
||||
db.rollback.assert_not_called()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Unit tests for YandexValuationScraper — anonymous house-history scraper.
|
||||
|
||||
Fixture HTML simulates the Yandex valuation page body text containing:
|
||||
- House meta block (year, floors, type, ceiling, lift, total objects, panorama)
|
||||
- House meta block (year, floors, type, ceiling, lift, total objects)
|
||||
- 2-3 historical offer entries with full structure
|
||||
|
||||
Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг
|
||||
|
|
@ -78,7 +78,6 @@ def test_parse_house_meta_full():
|
|||
assert meta.ceiling_height == 2.50
|
||||
assert meta.has_lift is True
|
||||
assert meta.total_objects == 12
|
||||
assert meta.has_panorama is False
|
||||
|
||||
|
||||
def test_parse_house_meta_no_lift():
|
||||
|
|
@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift():
|
|||
assert meta.ceiling_height == 3.0
|
||||
|
||||
|
||||
def test_parse_house_meta_with_panorama():
|
||||
text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание"
|
||||
def test_parse_house_meta_brick_with_lift():
|
||||
"""#2674 (хвост): фикстура больше не содержит «Панорама».
|
||||
|
||||
Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором;
|
||||
зелёный он был по построению. На настоящей странице оценки этого слова нет —
|
||||
проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов.
|
||||
Остаток теста осмысленный: кирпич + лифт на короткой мета-строке.
|
||||
"""
|
||||
text = "7 объектов Дом 2010 года Лифт Кирпичное здание"
|
||||
meta = YandexValuationScraper._parse_house_meta(text)
|
||||
assert meta.has_panorama is True
|
||||
assert meta.has_lift is True
|
||||
assert meta.house_type == "brick"
|
||||
|
||||
|
|
@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items():
|
|||
Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item.
|
||||
"""
|
||||
text = (
|
||||
"Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт "
|
||||
"Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт "
|
||||
"1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже "
|
||||
"2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]:
|
|||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
сломалась, как только у функции появился второй execute перед вставкой истории.
|
||||
Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
|
|
|
|||
|
|
@ -62,7 +62,11 @@ class ValuationHouseMeta(BaseModel):
|
|||
ceiling_height: float | None = None # in meters, e.g. 2.5
|
||||
has_lift: bool | None = None
|
||||
total_objects: int | None = None # 'N объектов' (full archive count)
|
||||
has_panorama: bool = False # 'Панорама' label present
|
||||
# #2674 (хвост, мигр. 259): признак «панорама» снят с разбора. Слова «панорам» в
|
||||
# любом падеже на странице оценки нет вовсе — проверено боевым трактом 13.08.2026
|
||||
# на трёх адресах ЕКБ (полный HTML ~1.19 МБ, вхождений 0/0/0), при том что мета
|
||||
# разбиралась целиком (год/этажность/материал/потолки/объекты). Признак не
|
||||
# «переехал» — его в предметной области нет; на проде 0 true из 1536 страниц.
|
||||
|
||||
def validate_match(
|
||||
self,
|
||||
|
|
@ -331,7 +335,6 @@ class YandexValuationScraper(BaseScraper):
|
|||
ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None),
|
||||
has_lift="Лифт" in body_text,
|
||||
total_objects=int(objects_m.group(1)) if objects_m else None,
|
||||
has_panorama="Панорама" in body_text,
|
||||
)
|
||||
|
||||
def _parse_history_items(self, tree: HTMLParser, body_text: str) -> list[ValuationHistoryItem]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue