diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 9eab49fe..aa251ffe 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2230,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel): ) only_status: str = Field( default="pending", - description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.", + description=( + "Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор " + "'transient_error' на половине пакета; явное значение = только этот статус." + ), ) house_id: int | None = Field( default=None, @@ -2267,7 +2270,12 @@ async def scrape_house_imv_backfill( batch_size: сколько домов обработать за запуск (default 50). request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV реагирует на частые запросы с datacenter-IP. Не снижать < 3s. - only_status: по умолчанию 'pending'. Для retry failed — 'transient_error'. + only_status: по умолчанию 'pending' — и тогда половина пакета сама уходит на + повтор домов в 'transient_error' с непотраченным лимитом попыток (#2674: + раньше повтор существовал только как этот параметр, и за 41 прогон его + не передали ни разу — 1390 домов застряли навсегда). Явное значение + отключает автоповтор и обрабатывает РОВНО указанный статус, включая + дома, исчерпавшие лимит (imv_transient_attempts >= 3). house_id: обработать один дом (debug). Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию. diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 9030fd88..c087e656 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -613,9 +613,13 @@ class Settings(BaseSettings): cian_valuation_max_rub: float = 500_000_000 # ── #audit-5: data-age guards ───────────────────────────────────────────── - # sber_index_max_age_days: максимальный допустимый возраст последнего месяца - # СберИндекс-серии (дней). Если latest месяц старее — логируем warning. - sber_index_max_age_days: int = 35 + # #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению + # (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток), + # guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно + # место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог + # берёт из такта самой загрузки (scrape_schedules.default_params.interval_days), + # так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться. + # extra="ignore" в model_config защищает от startup-краха на leftover env var. # avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка # на тонком рынке (thin_market=True в AvitoImvSummary) + warning. avito_imv_thin_market_threshold: int = 10 @@ -643,6 +647,23 @@ class Settings(BaseSettings): # индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал. estimate_quarter_index_factor_min: float = 0.6 estimate_quarter_index_factor_max: float = 1.8 + # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), + # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. + # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). + # + # Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр + # известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м, + # 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность + # цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и + # координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт + # денежный — поэтому включение отдельным решением, после замера. + # + # Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с + # заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И + # сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к + # 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить, + # а не оставлять «на вырост». + estimate_quarter_from_coords_enabled: bool = False # ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ────────────── # Эстиматор систематически занижает верхние сегменты (live-бэктест n=561, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 923a7607..2c29d640 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -153,7 +153,20 @@ class DkpCorridor(BaseModel): low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор) median_ppm2: int # медиана ₽/м² high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор) - period_months: int # окно поиска сделок + period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date) + # #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/ + # median/high — включая city-wide widen, если сработал), НЕ по всей таблице. + # period_months отвечает на «где искали», а не «насколько свежи сделки»: прод + # 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок + # даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы + # враньём в пользу свежести именно для них. + # Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день + # квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974 + # сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно + # {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи + # на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад». + # None = сделки без даты (в проде не встречается) — потребитель молчит. + latest_deal_date: date | None = None class PriceTrendPoint(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index f3a95d17..c5af066b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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, ) @@ -490,6 +489,32 @@ def _repair_coefficient(repair_state: str | None) -> float: return _REPAIR_COEF.get(repair_state, 1.0) +# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state. +_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v} + + +def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float: + """#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а. + + Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом + (`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана + к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать + и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента + с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до + ×1.081, а сработавший blend возвращает половину этих −6% обратно вверх. + + Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная + эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ + применил к медиане, — чинится расхождение базисов, а не калибровка. + + Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение). + """ + base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or "")) + if base <= 0: + return 1.0 + return _repair_coefficient(target_repair) / base + + # ── Asking→sold correction ratio lookup (#648 Stage 3) ────────────────────── # Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент # ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator @@ -963,13 +988,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. @@ -1006,9 +1031,6 @@ def _save_yandex_history_items( result.address, ) - # Наблюдение о доме не зависит от того, есть ли на странице история объявлений. - _save_yandex_house_panorama(db, house_id, result.house) - if not result.history_items: return 0 @@ -1093,58 +1115,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) ───────────────────────────── @@ -1160,14 +1137,39 @@ def _fetch_house_imv_anchor( """Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`. В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand - скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по - house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем - запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе - самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии - house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). + скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку: + WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort: + None при любой ошибке / отсутствии house_id / пустой таблице — estimator + продолжает на гео-tier'ах (no regress). + + СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована + ~2951 домов, fresh» — неправда по обоим пунктам, #2674): + 2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) — + поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из + них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после + фикса параметров 2026-08-06, всего 47. + Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic' + и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по + объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217 + (46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла + «панель» вместо «монолита»). + + ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка): + · Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка + находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у + 224 из 224, то есть просто выключил бы механизм. + · Массовая переоценка — не чинит корень и упирается в очередь: 1628 из + 2633 домов уже в imv_status='ok', а батч берёт только pending/ + transient_error, где сейчас 7144 дома при темпе ~25/прогон. + · Корень — не свежесть, а разные базисы ремонта; починено в #2677 + (`_anchor_repair_factor`), см. ниже. Returns dict {recommended_price, lower_price, higher_price, market_count, - rooms, area_m2} или None. + rooms, area_m2, renovation_type} или None. + + #2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito. + Без него якорь нельзя привести к базису ремонта оцениваемой квартиры + (см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op. """ if target_house_id is None: return None @@ -1177,7 +1179,7 @@ def _fetch_house_imv_anchor( text( """ SELECT recommended_price, lower_price, higher_price, - market_count, rooms, area_m2 + market_count, rooms, area_m2, renovation_type FROM house_imv_evaluations WHERE house_id = CAST(:hid AS bigint) AND recommended_price > 0 @@ -1359,6 +1361,80 @@ def _lookup_quarter_indexes( return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows} +# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов, +# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились — +# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр +# от DaData по адресу, а не KNN-подсказка), 2026-08-12: +# порог ответ выдан чужой квартал точность покрытие +# 15 м 475 26 94.5% 19.8% +# 25 м 748 59 92.1% 31.2% +# 50 м 1341 271 79.8% 55.9% +# 100 м 2090 644 69.2% 87.2% +# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане +# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м: +# точность 92%, и это ровно тот компромисс, который видно в замере, а не +# заимствованная у соседнего модуля константа. +_TARGET_QUARTER_RADIUS_M = 25.0 +# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на +# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже. +_TARGET_QUARTER_DEG_GATE = 0.0015 + + +def _lookup_target_quarter_by_coords( + db: Session, + lat: float | None, + lon: float | None, +) -> str | None: + """Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м). + + Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на + проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда. + Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number` + (tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не + к координатам аналога. + + Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен + только 3-й компонент кадастрового номера (квартал), а он у соседних зданий + одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не + полный кадастр, чтобы вызывающий код не принял его за кадастр дома. + + None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет + или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M. + """ + if lat is None or lon is None: + return None + try: + row = db.execute( + text( + """ + WITH p AS ( + SELECT ST_SetSRID( + ST_MakePoint(CAST(:lon AS double precision), + CAST(:lat AS double precision)), 4326) AS g + ) + SELECT cb.cad_num + FROM cad_buildings_local cb, p + WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision)) + AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision) + ORDER BY cb.geom <-> p.g + LIMIT 1 + """ + ), + { + "lat": lat, + "lon": lon, + "deg": _TARGET_QUARTER_DEG_GATE, + "radius": _TARGET_QUARTER_RADIUS_M, + }, + ).first() + except Exception as exc: + logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc) + return None + if row is None: + return None + return _quarter_from_cadastre(str(row[0])) + + def _apply_quarter_index( *, base_median_ppm2: float, @@ -1493,7 +1569,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: """#794: monthly {period_month: index_value} for region from sber_price_index. Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error. - #audit-5a: если latest месяц серии старее sber_index_max_age_days → warning. + + #2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest + периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по + построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на + закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых + наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени — + нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip + (event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor, + и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт), + а не календарь. """ for dash in SBER_COEFF_DASHBOARDS: try: @@ -1521,21 +1606,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} if not series: continue - # #audit-5a: data-age guard — предупреждаем о stale СберИндексе. - latest = max(series) - today = datetime.now(tz=UTC).date() - age_days = (today - latest).days - if age_days > settings.sber_index_max_age_days: - logger.warning( - "sber_index stale #audit-5a: latest=%s age=%d days" - " (> sber_index_max_age_days=%d) region=%s dash=%s" - " — time-adjustment may be outdated", - latest.isoformat(), - age_days, - settings.sber_index_max_age_days, - region, - dash, - ) return series return {} @@ -1700,6 +1770,10 @@ def _fetch_dkp_corridor( series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION) adjusted: list[float] = [] factors_applied: list[float] = [] + # #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted — + # `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы + # заявленную свежесть коридора, не участвуя в его границах. + latest_deal: date | None = None for r in rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1711,6 +1785,8 @@ def _fetch_dkp_corridor( factor = _sber_time_factor(series, deal_month) adjusted.append(float(ppm2) * factor) factors_applied.append(factor) + if dd is not None and (latest_deal is None or dd > latest_deal): + latest_deal = dd ppm2_values = sorted(adjusted) # #oblast-D widen: a single street in a small non-EKB town can easily have @@ -1763,6 +1839,7 @@ def _fetch_dkp_corridor( city_rows = [] city_adjusted: list[float] = [] + city_latest: date | None = None for r in city_rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1773,6 +1850,8 @@ def _fetch_dkp_corridor( deal_month = date(dd.year, dd.month, 1) factor = _sber_time_factor(series, deal_month) city_adjusted.append(float(ppm2) * factor) + if dd is not None and (city_latest is None or dd > city_latest): + city_latest = dd if len(city_adjusted) > len(ppm2_values): logger.info( @@ -1783,6 +1862,10 @@ def _fetch_dkp_corridor( city, ) ppm2_values = sorted(city_adjusted) + # #2846: коридор теперь описывает city-выборку — и возраст обязан + # переехать вместе с числами, иначе подпись осталась бы от street- + # выборки, которую на витрине уже никто не видит. + latest_deal = city_latest if not ppm2_values: return None @@ -1805,6 +1888,7 @@ def _fetch_dkp_corridor( "median_ppm2": int(_percentile(ppm2_values, 0.5)), "high_ppm2": int(_percentile(ppm2_values, 0.90)), "period_months": period_months, + "latest_deal_date": latest_deal, } @@ -2695,6 +2779,9 @@ def _price_from_inputs( ratio_resolver: Callable[[float | None], tuple[float | None, str | None]], quarter_index_lookup: Callable[[str], tuple[float, int] | None], quarter_indexes_lookup: Callable[[list[str]], dict[str, float]], + # Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется + # ровно в одном месте, блок #764, и только ради 3-го компонента (квартала). + # Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели. target_house_cadnum: str | None, dadata_coarse: bool, geo: GeocodeResult, @@ -3058,6 +3145,7 @@ def _price_from_inputs( anchor_total: int | None = None anchor_higher: int | None = None anchor_label: str | None = None + anchor_repair_note: str = "" if imv_anchor is not None and imv_anchor.get("recommended_price"): anchor_total = int(imv_anchor["recommended_price"]) anchor_higher = ( @@ -3076,6 +3164,22 @@ def _price_from_inputs( _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) + # #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его + # не правим), а в blend уходит якорь, приведённый к базису ремонта target'а — + # тому же, в котором median_price уже посчитан выше (repair_coef). + _rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state) + if _rep_k != 1.0: + anchor_total = round(anchor_total * _rep_k) + anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None + anchor_repair_note = ", приведённой к состоянию ремонта квартиры" + logger.info( + "imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f" + " → anchor %d", + imv_anchor.get("renovation_type"), + repair_state, + _rep_k, + anchor_total, + ) elif imv_eval is not None and imv_eval.recommended_price: anchor_total = int(imv_eval.recommended_price) anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None @@ -3125,8 +3229,8 @@ def _price_from_inputs( median_price = new_median median_ppm2 = new_ppm2 explanation = (explanation or "") + ( - f" Оценка скорректирована по {anchor_label} " - f"({anchor_used / 1_000_000:.1f} млн ₽)." + f" Оценка скорректирована по {anchor_label}" + f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)." ) sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"}) # Диапазон расширяем даже если медиану не двигали. @@ -3164,13 +3268,16 @@ def _price_from_inputs( and median_price > 0 and area_m2 ): + # target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по + # координатам цели (_lookup_target_quarter_by_coords, вызывающий код). + # Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» — + # снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал + # цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м + # от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза + # по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой). + # Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя + # заменить чужим кварталом, он и есть предмет поправки. target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum) - if target_quarter is None: - for lot in listings_clean: - cq = _quarter_from_cadastre(lot.get("building_cadastral_number")) - if cq is not None: - target_quarter = cq - break if target_quarter is not None: qindex_result = quarter_index_lookup(target_quarter) @@ -4240,6 +4347,22 @@ async def estimate_quality( # ── Coarse-geo signals ──────────────────────────────────────────────────── dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2 + # ── #764: квартал ЦЕЛИ для квартального индекса ────────────────────────── + # Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) → + # квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога + # сюда больше не попадает вообще (см. блок #764 в _price_from_inputs). + # Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на + # точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config. + target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None + if ( + settings.estimate_quarter_from_coords_enabled + and not target_quarter_cadnum + and geo is not None + ): + target_quarter_cadnum = await asyncio.to_thread( + _lookup_target_quarter_by_coords, db, geo.lat, geo.lon + ) + # ── DB-callable wrappers injected into pure pricing ─────────────────────── def _ratio_resolver( appm2: float | None, @@ -4284,7 +4407,7 @@ async def estimate_quality( ratio_resolver=_ratio_resolver, quarter_index_lookup=_qi_lookup, quarter_indexes_lookup=_qis_lookup, - target_house_cadnum=dadata.house_cadnum if dadata else None, + target_house_cadnum=target_quarter_cadnum, dadata_coarse=dadata_coarse, geo=geo, dadata_qc_geo=dadata.qc_geo if dadata else None, diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index a89eadf6..8e02aae4 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str: # ── Page 3: Deals ──────────────────────────────────────────────────────────── +_ROMAN_QUARTER = ("I", "II", "III", "IV") + + +def deals_as_of_label(estimate: AggregatedEstimate) -> str | None: + """#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки. + + Раньше страница печатала «Период сделок: 08.2025 – 08.2026» — окно ПОИСКА, + посчитанное как `today - period_months*30 … today`. Правым концом оно обещало + сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде + (замер 2026-08-12) — I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно + по тем сделкам, из которых страница строит диапазон и таблицу примеров. + + Гранулярность — квартал: rosreestr пишет deal_date = первый день квартала + (#1995, ровно то, что помечает AnalogLot.date_precision == "quarter"). + Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания — + сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока + такого нет) подписывается месяцем. + + None — сделок нет либо ни у одной нет даты: подписывать нечего. + """ + dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date] + if not dated: + return None + newest, precision = max(dated, key=lambda p: p[0]) + if precision == "day": + return f"по {newest.strftime('%m.%Y')}" + return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}" + def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] n_deals = len(estimate.actual_deals) - today = dt.date.today() - period_start = today - dt.timedelta(days=estimate.period_months * 30) + # #2846: «Период сделок» показывал окно поиска правым концом = сегодня. + # Реальная граница — as-of по показанным сделкам; окна поиска на странице + # больше нет (оно ничего не говорило о данных). None → строку не печатаем. + deals_as_of = deals_as_of_label(estimate) # Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа # (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%», @@ -1597,9 +1627,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) - - + {f''' + ''' + if deals_as_of else ""}
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Период сделок - {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
Сделки{_mono(deals_as_of)}
Источники данных
diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index f3cdb12e..7785116a 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -372,6 +372,134 @@ def _names_unrecognized_locality(address: str) -> bool: return bool(_LOCALITY_MARKER_RE.search(normalized)) +# ── Постфактум-инвариант подмены города (#2590) ────────────────────────────── +# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры. +# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и +# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе +# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до +# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он +# использовал, и если названный топоним туда не дожил — топоним подменён. +# +# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера +# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в +# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и +# сам целевой город. +_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]") +# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого +# города — продукт РФ-only, новых значений у неё не появится (в отличие от +# списка городов области, ради ухода от которого всё и делается). +_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b") +# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса +# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева, +# мопра» — перекрёсток) читается как топоним и ложно отбраковывается. +_STREET_LEVEL_RE = re.compile( + r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная" + r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп" + r"|корпус|лит|литера|снт|сад|гск)\b" +) +# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам: +# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул. +# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет). +_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b") +_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b") +# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается +# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить. +_LOCALITY_TYPE_WORDS = frozenset( + { + "поселок", + "пос", + "село", + "деревня", + "дер", + "город", + "гор", + "округ", + "муниципальный", + "городской", + "сельское", + "поселение", + "тер", + "территория", + "станция", + "пгт", + "рп", + } +) +_WORD_RE = re.compile(r"[а-я][а-я-]*") + + +def _fold(value: str) -> str: + """lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов.""" + return " ".join(value.lower().replace("ё", "е").split()) + + +def _claimed_locality(address: str) -> str | None: + """Имя населённого пункта, названное в тексте адреса, или None. + + Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса + (страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже + НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП. + + None означает «НП не назван» — это основной трафик формы («Малышева 30»), и + для него инвариант не применяется вовсе. + """ + for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)): + segment = segment.strip() + if not segment: + continue + if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment): + return None # улица/дом: будь НП назван, он шёл бы раньше + if ( + _COUNTRY_RE.search(segment) + or _REGION_LEVEL_RE.search(segment) + or _DISTRICT_LEVEL_RE.search(segment) + ): + continue + name = " ".join( + w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3 + ) + if name: + return name + return None + + +def _city_substituted(address: str, result: GeocodeResult) -> bool: + """True если провайдер подменил названный в адресе НП Екатеринбургом (#2590). + + Три условия вместе: + 1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`); + 2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не + пережил геокодинг; + 3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по + собственному ответу провайдера — он называет Екатеринбург либо не + называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»; + тогда «внутри ЕКБ» подтверждают координаты). + + Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка, + Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило, + и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А + корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex + пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр + не проходит. Проверяется не география посёлка, а факт «топоним потерян». + + Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂ + «М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим — + пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без + приставки («пионерский, советская»), понижается до `locality` — честная + деградация, координаты не теряются. + """ + claimed = _claimed_locality(address) + if claimed is None or _EKATERINBURG_RE.search(claimed): + return False + answer = _fold(result.full_address or "") + if any(word in answer for word in claimed.split()): + return False # топоним дожил до ответа — провайдер искал там, где просили + if not is_within_ekb_bbox(result.lat, result.lon): + return False + answer_locality = _claimed_locality(answer) + return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality)) + + def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool: """Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582). @@ -1287,6 +1415,42 @@ async def suggest( # ── Public API ─────────────────────────────────────────────────────────────── async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None: + """Геокодинг с кэшем + постфактум-проверка подмены города (#2590). + + Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант + применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники, + включая попадание в кэш: отравленная запись, записанная до этого фикса, + больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута + (обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется). + + Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`. + `locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его + как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade + оценки, (б) через `tasks.geocode_missing` проставляет листингу + `geo_precision='city'`, а этот признак исключает листинг из пула аналогов + (`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`). + То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть + за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и + видимая, а не отказ. + """ + result = await _geocode_resolve(address, db, city_hint) + if result is None or not _city_substituted(address, result): + return result + logger.warning( + "geocode city substitution (#2590): %r → %r (%.5f, %.5f) provider=%s — " + "названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality", + address[:80], + (result.full_address or "")[:80], + result.lat, + result.lon, + result.provider, + ) + return replace(result, confidence="locality", city_ambiguous=True) + + +async def _geocode_resolve( + address: str, db: Session, city_hint: str | None = None +) -> GeocodeResult | None: """Геокодинг с кэшем. Cadastral FDW → Nominatim → None. Args: diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index edb02137..7dd6f1b9 100644 --- a/tradein-mvp/backend/app/services/house_imv_backfill.py +++ b/tradein-mvp/backend/app/services/house_imv_backfill.py @@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat UPDATE houses SET imv_status = 'ok', last_imv_attempt_at = NOW(), - imv_error_reason = NULL + imv_error_reason = NULL, + imv_transient_attempts = 0 WHERE id = :hid """), {"hid": house_id}, @@ -424,12 +425,19 @@ def _mark_status( status: str, reason: str | None = None, ) -> None: + # #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом + # падал по временной причине», а не «сколько раз его трогали». no_params / + # no_address / not_found счётчик не двигают: они не занимают retry-слот. db.execute( text(""" UPDATE houses SET imv_status = :s, last_imv_attempt_at = NOW(), - imv_error_reason = :r + imv_error_reason = :r, + imv_transient_attempts = CASE + WHEN :s = 'transient_error' THEN imv_transient_attempts + 1 + ELSE imv_transient_attempts + END WHERE id = :hid """), {"hid": house_id, "s": status, "r": reason}, @@ -443,6 +451,104 @@ _IMVStatus = Literal[ "ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error" ] +# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем +# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08) +# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039 +# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на +# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует, +# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно. +# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status= +# 'transient_error' и считаются как +# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3. +_MAX_TRANSIENT_ATTEMPTS = 3 + +# Доля пакета под повтор transient_error. Половина — потому что остальные слоты +# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке +# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер +# головы очереди на 12.08), так что pending на половине пакета всё равно идёт +# быстрее, чем на целом до правки. +_RETRY_SLOTS_SHARE = 0.5 + +# Дом без пригодных параметров backfill всё равно пометит no_params — но только +# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним +# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ +# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»). +# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по +# imv_error_reason продолжают работать. +# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё +# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом. +# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы +# поштучный путь. +_PREMARK_UNUSABLE_SQL = text(""" + WITH unusable AS ( + SELECT h.id, + CASE WHEN NOT EXISTS ( + SELECT 1 FROM listings l + WHERE l.house_id_fk = h.id + AND l.rooms IS NOT NULL + AND l.area_m2 IS NOT NULL) + THEN 'no listings with rooms+area' + ELSE 'unknown house_type' + END AS reason + FROM houses h + WHERE h.imv_status = ANY(CAST(:statuses AS text[])) + AND h.lat IS NOT NULL + AND h.lon IS NOT NULL + AND h.address IS NOT NULL + AND ( + NOT EXISTS ( + SELECT 1 FROM listings l + WHERE l.house_id_fk = h.id + AND l.rooms IS NOT NULL + AND l.area_m2 IS NOT NULL) + OR COALESCE( + NULLIF(TRIM(( + SELECT mode() WITHIN GROUP (ORDER BY l.house_type) + FROM listings l + WHERE l.house_id_fk = h.id + AND l.rooms IS NOT NULL + AND l.area_m2 IS NOT NULL)), ''), + NULLIF(TRIM(h.house_type), '') + ) IS NULL + ) + ) + UPDATE houses + SET imv_status = 'no_params', + last_imv_attempt_at = NOW(), + imv_error_reason = unusable.reason + FROM unusable + WHERE houses.id = unusable.id +""") + +# Основная очередь: один статус, как и было (only_status — публичный параметр +# admin-API, семантику не трогаем). +_QUEUE_SQL = text(""" + SELECT id, address, full_address, lat, lon + FROM houses + WHERE imv_status = :status + AND lat IS NOT NULL + AND lon IS NOT NULL + AND address IS NOT NULL + ORDER BY last_imv_attempt_at NULLS FIRST, id + LIMIT :batch +""") + +# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending +# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST +# transient_error не попал бы в пакет, пока не кончится pending (по замеру +# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход. +_RETRY_QUEUE_SQL = text(""" + SELECT id, address, full_address, lat, lon + FROM houses + WHERE imv_status = 'transient_error' + AND imv_transient_attempts < :max_attempts + AND lat IS NOT NULL + AND lon IS NOT NULL + AND address IS NOT NULL + ORDER BY last_imv_attempt_at NULLS FIRST, id + LIMIT :batch +""") + @dataclass class HouseIMVBackfillResult: @@ -452,6 +558,23 @@ class HouseIMVBackfillResult: errors: int = 0 duration_sec: float = field(default=0.0) status_counts: dict[str, int] = field(default_factory=dict) + # #2674: сколько домов пакета пришло из retry-очереди transient_error и + # сколько помечено no_params до пакета (без запроса к площадке). + retried: int = 0 + premarked: int = 0 + + +def _premark_unusable(db: Session, statuses: list[str]) -> int: + """Пометить no_params дома, по которым запрос к площадке невозможен. → сколько. + + Не новое поведение, а тот же вердикт _process_one_house одним запросом: на + 12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area, + 1812 без house_type) — каждый занимал слот пакета и паузу, чтобы получить + ответ, который виден в SQL. + """ + res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses}) + db.commit() + return int(res.rowcount or 0) def _beat(heartbeat: Callable[[], None] | None) -> None: @@ -480,7 +603,11 @@ async def backfill_house_imv( batch_size: max houses to process (ignored when house_id given). request_delay_sec: sleep between Avito API calls (default 5s — anti-bot). only_status: process houses with this imv_status (default 'pending'). - Use 'transient_error' to retry failures. + Use 'transient_error' to retry failures. При значении по умолчанию + часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор + transient_error с непотраченным лимитом попыток (#2674) — явно + переданный only_status этот проход отключает, оператор получает + ровно то, что попросил, включая исчерпавшие лимит дома. house_id: process a single specific house (debug). heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES домов — caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies @@ -509,23 +636,45 @@ async def backfill_house_imv( .all() ) else: - rows = ( + # Повторный проход только на расписании (only_status по умолчанию): явный + # only_status от оператора — это ручной запрос ровно одного статуса. + retry_lane = only_status == "pending" + + statuses = [only_status] + (["transient_error"] if retry_lane else []) + result.premarked = _premark_unusable(db, statuses) + if result.premarked: + logger.info( + "house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area " + "или house_type) — слоты пакета не потрачены", + result.premarked, + ) + + retry_rows: list = [] + if retry_lane: + retry_rows = ( + db.execute( + _RETRY_QUEUE_SQL, + { + "max_attempts": _MAX_TRANSIENT_ATTEMPTS, + "batch": int(batch_size * _RETRY_SLOTS_SHARE), + }, + ) + .mappings() + .all() + ) + result.retried = len(retry_rows) + + # Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на + # 12.08) возвращается pending — пакет не простаивает. + fresh_rows = ( db.execute( - text(""" - SELECT id, address, full_address, lat, lon - FROM houses - WHERE imv_status = :status - AND lat IS NOT NULL - AND lon IS NOT NULL - AND address IS NOT NULL - ORDER BY last_imv_attempt_at NULLS FIRST, id - LIMIT :batch - """), - {"status": only_status, "batch": batch_size}, + _QUEUE_SQL, + {"status": only_status, "batch": max(batch_size - result.retried, 0)}, ) .mappings() .all() ) + rows = list(fresh_rows) + list(retry_rows) result.checked = len(rows) if not rows: @@ -534,9 +683,11 @@ async def backfill_house_imv( return result logger.info( - "house_imv_backfill: %d houses (status=%r delay=%.1fs)", + "house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)", result.checked, only_status, + result.retried, + result.premarked, request_delay_sec, ) @@ -610,11 +761,14 @@ async def backfill_house_imv( result.duration_sec = time.time() - t0 logger.info( - "house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s", + "house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d " + "retried=%d premarked=%d %.1fs %s", result.checked, result.saved, result.skipped, result.errors, + result.retried, + result.premarked, result.duration_sec, result.status_counts, ) diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index 89d71531..2e56f1eb 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -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"], diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 64ba11e2..3abf3360 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -447,6 +447,12 @@ async def _job_house_imv_backfill( # алерт — но пустая очередь при ежедневном расписании это и правда сигнал. "total_seen": result.checked, "new_count": result.saved, + # #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error) + # и сколько домов ушло в no_params до пакета одним запросом. Без этих + # двух счётчиков в scrape_runs.counters проверить, что застрявшие + # действительно возвращаются в очередь, можно только по houses. + "retried": result.retried, + "premarked": result.premarked, } # Честный статус (#2674, тот же класс, что #2670/#2657): успех — это # «сделали то, что собирались», а не «не поймали известное исключение». diff --git a/tradein-mvp/backend/app/services/sber_index.py b/tradein-mvp/backend/app/services/sber_index.py index 7a4d646c..8331b2d2 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -303,6 +303,18 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st #1348: blocking psycopg work — must run via asyncio.to_thread, never directly on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard). + + #2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией + (limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в + DO UPDATE ставил одну и ту же метку всем строкам ряда — на проде все 639 строк + несли время последнего прогона, включая период 2017-01. Как признак свежести + колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период», + то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым + периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка + 2026-08-06 и она останется — такт публикации до этого PR невосстановим. + Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно — + scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted), + и именно оттуда его берёт tasks/sber_freshness_monitor. """ for city_label, period_month, segment, dash, value in rows_to_upsert: db.execute( @@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st ON CONFLICT (city, period_month, dashboard) DO UPDATE SET index_value_rub_m2 = EXCLUDED.index_value_rub_m2, - segment = EXCLUDED.segment, - fetched_at = now() + segment = EXCLUDED.segment + -- fetched_at НЕ трогаем (#2846): она = «впервые увидели период». """ ), { diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 0e304651..00d91cf9 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -17,8 +17,9 @@ Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detai wiring into the production scheduled orchestrator (previously only reachable manually). Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher -per run (async context manager, source="domclick" -- dedicated residential proxy pool, -see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via +per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула; +выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800), +на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via domclick_session.load_session(db) and threaded into every fetch_detail() call. NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere): @@ -40,9 +41,15 @@ Exception triad differs from Avito: Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished): блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными. - No IP-rotation/cooldown recovery step exists here - (DomClick uses one dedicated residential proxy, not a rotating pool) -- an - aborted run simply retries the remaining backlog next window. + No IP-rotation/cooldown recovery step exists here -- an aborted run simply + retries the remaining backlog next window. + УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated + residential proxy, not a rotating pool». Это перестало быть правдой на миграции + 253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть + отсутствие ротации больше НЕ следует из «ротировать нечего» — это просто + непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой + комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по + длительности до блока 111-332 с, получает свой бюджет). ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 487179d5..cde46a41 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -1,56 +1,68 @@ -"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1. +"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода). -Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет -СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning, -когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба -`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует -данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит -(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness -видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. +ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846). -Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и -поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага -публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. +Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток +(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по +построению: `period_month` — метка ПЕРВОГО числа месяца, поэтому на закрытии месяца +возрасту уже ≥30; плюс собственный лаг публикации источника. За 31 сутки прямых +наблюдений монитора (07-13 … 08-12, scrape_runs.counters) возраст лежал в 46..76 и +НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени — ноль бит. -#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с -LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING -событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его -застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor) -писал ERROR с самого начала — расходилась только эта джоба. +Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор +мерил не источник, а нашу же пилу. Миграция 212 (такт 28 → 7) обещала потолок +возраста ≈46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне +загрузки шестидневной давности (08-06, errors=0, upserted=639) — источник просто не +опубликовал июль. Двенадцать суток подряд (08-01 … 08-12) монитор писал ERROR при +исправной загрузке. Потолок 53 держится, только если источник публикует строго +помесячно; он не публикует. -ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов -монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего -собственного такта загрузки: - 13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65 - 17.07 alert=0 age 46 latest=июнь (день загрузки) -Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же -считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок -46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать -такое до ERROR без починки такта значило бы завести ежедневное ложное событие на -две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ -такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает -«источник/загрузка встали», а не «мы давно не ходили». +ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду +нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН +максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» — это вопрос +«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода: -Порог алерта (документирование выбора): - Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). - Монитор: age > sber_index_max_age_days + lag_allowance. - lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг - публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл - ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток. - Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста - ~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл - поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри - рабочего диапазона — если такт загрузки будут менять, пересчитай потолок - (пол + interval_days) и сверь с 60. + последний полный прогон свежий → наш max == max источника → источник не публиковал, + молчание ПРАВИЛЬНОЕ (возраст = лаг источника); + последний полный прогон старый → мы не забрали → тревога про ЗАГРУЗЧИК. -Задача синхронная (DB-only, один SELECT max(period_month)) — запускается -kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в -run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ -функция evaluate_sber_freshness() (frozen-now тестируется без БД). +ЛОВУШКА: `status='done'` НЕ означает успех — прогон id=37 (2026-05-31) имеет +{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий +3 табло × 3 региона прошли: errors — счётчик по всему прогону). -Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи -достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего -оценивать). +ПОРОГ — не круглое число, а такт самой загрузки: `scrape_schedules.default_params +.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик +запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с +тактом порог не может: поменяли такт — порог поехал следом. Тревога после +MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети) +поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14 +суток; на прод-истории такое состояние ДОСТИЖИМО — разрывы между полными прогонами +были 14.8 и 20 суток (05-31→06-15 и 07-17→08-06). + +ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из +estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у +dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблице маскирует +отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же +серию — список импортируется из estimator, дублировать его тут нельзя. + +ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а +загрузка останется исправной — монитор промолчит: по нашим данным «источник не +публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт +публикации источника ретроспективно невосстановим — его затёр апсерт +(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at +не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период», +т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал» +имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026). + +ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с +LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще. + +Задача синхронная (DB-only) — запускается kit-scheduler'ом через +product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает +ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД). + +Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed +только если у оценщика вообще нет серии (нечего оценивать). """ from __future__ import annotations @@ -62,153 +74,239 @@ from datetime import UTC, date, datetime from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION logger = logging.getLogger(__name__) __all__ = [ - "DEFAULT_LAG_ALLOWANCE_DAYS", + "DEFAULT_PULL_INTERVAL_DAYS", + "MISSED_PULL_CYCLES", + "SBER_FRESHNESS_PULL_SOURCE", "SberFreshnessVerdict", "check_sber_freshness", "evaluate_sber_freshness", ] -# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate -# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days. -DEFAULT_LAG_ALLOWANCE_DAYS = 25 +# Джоба-загрузчик, чей такт и успешность мы и мониторим. +SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull" -# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями -# sber_price_index для областного уровня. -SBER_MONITOR_CITY = "Свердловская область" +# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг +# окна (поглощаем), 2 = загрузка встала (алерт). +MISSED_PULL_CYCLES = 2 -_LATEST_SBER_PERIOD_SQL = text(""" +# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7). +DEFAULT_PULL_INTERVAL_DAYS = 7 + +_LATEST_PERIOD_SQL = text(""" SELECT max(period_month) AS latest FROM sber_price_index WHERE city = CAST(:city AS text) + AND dashboard = CAST(:dash AS text) -- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка -- (новостройки) = направленно неверная коррекция. Зеркалит фильтр -- estimator._load_sber_index_series. AND (segment IS NULL OR segment ILIKE '%вторичн%') """) +# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно: +# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без +# CAST(... AS int): counters других источников планировщик может отфильтровать позже +# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел +# оператор > численный. +_LAST_COMPLETE_PULL_SQL = text(""" + SELECT max(finished_at) AS last_pull + FROM scrape_runs + WHERE source = CAST(:src AS text) + AND counters @> CAST('{"errors": 0}' AS jsonb) + AND counters -> 'upserted' > CAST('0' AS jsonb) +""") + +# Такт загрузки — из той же строки, по которой планировщик считает next_run_at. +_PULL_INTERVAL_SQL = text(""" + SELECT default_params ->> 'interval_days' AS interval_days + FROM scrape_schedules + WHERE source = CAST(:src AS text) +""") + @dataclass(frozen=True) class SberFreshnessVerdict: - """Вердикт свежести СберИндекса по max(period_month).""" + """Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта.""" latest_period: date - age_days: int + age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги + pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было + max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки stale: bool def evaluate_sber_freshness( latest_period: date, now: datetime, - max_age_days: int, + *, + last_complete_pull_at: datetime | None, + pull_interval_days: int, ) -> SberFreshnessVerdict: - """Чистая логика: устарел ли latest период СберИндекса. + """Чистая логика: отстала ли загрузка от собственного такта. - stale = age_days > max_age_days, где age_days = now.date() - latest_period. - `max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance), - вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД. + stale = полных прогонов не было ВООБЩЕ, либо последний старше + MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в + вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш + max(period_month) равен максимуму источника по построению, и его возраст — это + лаг ПУБЛИКАЦИИ, на который мы повлиять не можем. """ age_days = (now.date() - latest_period).days - stale = age_days > max_age_days + max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days + if last_complete_pull_at is None: + return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True) + pull_lag_days = (now - last_complete_pull_at).days return SberFreshnessVerdict( latest_period=latest_period, age_days=age_days, - stale=stale, + pull_lag_days=pull_lag_days, + max_pull_lag_days=max_pull_lag_days, + stale=pull_lag_days > max_pull_lag_days, ) +def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None: + """Табло, которое возьмёт оценщик, и его latest период. + + Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло + из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло. + """ + for dash in SBER_COEFF_DASHBOARDS: + row = db.execute( + _LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash} + ).first() + latest = row.latest if row is not None else None + if latest is not None: + return dash, latest + return None + + +def _pull_interval_days(db: Session) -> int: + """Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS).""" + row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first() + raw = row.interval_days if row is not None else None + try: + return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS + except (TypeError, ValueError): + logger.warning( + "sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d", + raw, + DEFAULT_PULL_INTERVAL_DAYS, + ) + return DEFAULT_PULL_INTERVAL_DAYS + + def check_sber_freshness( db: Session, run_id: int, params: dict | None = None, # type: ignore[type-arg] now: datetime | None = None, ) -> dict[str, int]: - """Проверить свежесть СберИндекса по max(period_month) и алертить при staleness. + """Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании. Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). - Читает один SELECT max(period_month) вторичного сегмента по региону, считает - вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops) - и финализирует run. + Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона + sber_index_pull, такт загрузки из scrape_schedules. Вердикт — чистой функцией. - Params (default_params jsonb): - lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25). - `now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC). + `params` больше ничего не настраивает: порог берётся из такта самой загрузки + (унаследованный default_params.lag_allowance_days=25 монитора игнорируется — + он кодировал мёртвый календарный порог). `now` инъектируется в тестах. - Returns counters {latest_year, latest_month, age_days, alert}. - mark_failed только если sber_price_index пуста/недоступна (нечего оценивать); + Returns counters {latest_year, latest_month, age_days, pull_lag_days, + max_pull_lag_days, alert}. + mark_failed только если у оценщика нет серии вообще (нечего оценивать); при алерте прогон помечается done (это монитор, не сбой джобы). """ - params = params or {} now = now or datetime.now(UTC) counters: dict[str, int] = { "latest_year": 0, "latest_month": 0, "age_days": 0, + "pull_lag_days": -1, + "max_pull_lag_days": 0, "alert": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) - row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() - latest: date | None = row.latest if row is not None else None - if latest is None: + found = _load_estimator_dashboard(db) + if found is None: # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. logger.error( - "sber freshness: sber_price_index пуст/недоступен для region=%s " - "(вторичка) — оценить свежесть нельзя", - SBER_MONITOR_CITY, + "sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк " + "для region=%s (вторичка); оценить нечего", + list(SBER_COEFF_DASHBOARDS), + SBER_TIME_ADJUST_REGION, ) runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters) return counters - lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS)) - max_age_days = settings.sber_index_max_age_days + lag_days - verdict = evaluate_sber_freshness(latest, now, max_age_days) + dashboard, latest = found + last_pull_row = db.execute( + _LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE} + ).first() + last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None + verdict = evaluate_sber_freshness( + latest, + now, + last_complete_pull_at=last_complete_pull_at, + pull_interval_days=_pull_interval_days(db), + ) counters = { "latest_year": latest.year, "latest_month": latest.month, "age_days": verdict.age_days, + "pull_lag_days": verdict.pull_lag_days, + "max_pull_lag_days": verdict.max_pull_lag_days, "alert": int(verdict.stale), } if verdict.stale: - # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) — - # 9 срабатываний на проде дали ноль событий. См. докстринг модуля. + # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR). logger.error( - "sber freshness: max(period_month)=%s устарел на %d дней " - "(> порога %d = sber_index_max_age_days %d + lag %d); " - "СберИндекс time-adjustment ДКП-сделок мог отстать — " - "проверь sber_index_pull и доступность новых периодов источника", + "sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон " + "%s (%s суток назад, порог %d = %d такта × %d суток; " + "status='done' с errors>0 за успех НЕ считается). " + "Наш max(period_month)=%s (табло %s) мог разойтись с источником — " + "проверь sber_index_pull: планировщик, сеть, /api/sowa 404", + last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ", + verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "∞", + verdict.max_pull_lag_days, + MISSED_PULL_CYCLES, + verdict.max_pull_lag_days // MISSED_PULL_CYCLES, latest, - verdict.age_days, - max_age_days, - settings.sber_index_max_age_days, - lag_days, + dashboard, ) else: logger.info( - "sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) " - "region=%s — алерта нет", + "sber freshness: загрузка в такте — последний полный прогон %d суток назад " + "(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен " + "максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, " + "не наше отставание — алерта нет", + verdict.pull_lag_days, + verdict.max_pull_lag_days, latest, + dashboard, verdict.age_days, - max_age_days, - SBER_MONITOR_CITY, ) runs_mod.mark_done(db, run_id, counters) logger.info( - "check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d", + "check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d " + "pull_lag_days=%d age_days=%d", run_id, latest, + dashboard, counters["alert"], + counters["pull_lag_days"], counters["age_days"], ) return counters diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 51e7412c..48771f0b 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе ведёт на сайт застройщика, а не на realty.yandex.ru/offer//. Парсер отвергает такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон -целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может -разобрать, а размер отброшенного видно в counters.unenrichable_pending. +целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать. + +Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий +у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по +собранному из него каноническому URL страница отдаётся и парсится. Поэтому в +очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены: +url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько +не адресуемо вообще (ни offer-URL, ни числового source_id). Why curl_cffi and not YandexDetailScraper.fetch_detail: fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS @@ -51,6 +57,8 @@ from app.services.proxy_egress import resolve_proxy_url logger = logging.getLogger(__name__) __all__ = [ + "CANONICAL_URL_SQL", + "OFFER_ID_PATTERN", "OFFER_URL_PATTERN", "YandexDetailBackfillResult", "run_yandex_detail_backfill", @@ -63,8 +71,7 @@ __all__ = [ # # Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют # source_url на сайт застройщика (macroserver.ru, prospect-federation.ru, -# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из -# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer//. +# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. # # Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один # застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон: @@ -72,6 +79,39 @@ __all__ = [ # Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим. OFFER_URL_PATTERN = "/offer/[0-9]+" +# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ──────────────── +# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на +# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде: +# +# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная; +# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк, +# вставленных после самой свежей его строки, id 2583989), и выйти из него +# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое +# множество и обязано быть константой — вопрос был не «почему не растёт», а +# «правда ли они непригодны». +# +# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId +# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из +# него собирается — это инвариант #2235 (`_canonical_source_url` в +# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164 +# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси, +# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая +# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php. +# +# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни +# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235 +# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ +# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной +# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес, +# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки. +# +# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой +# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией: +# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят +# на сайты застройщиков вместо Яндекса. +OFFER_ID_PATTERN = "^[0-9]+$" +CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'" + @dataclass class YandexDetailBackfillResult: @@ -80,6 +120,11 @@ class YandexDetailBackfillResult: attempted: int = 0 enriched: int = 0 failed: int = 0 + # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из + # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к + # прогону; замер на месте = очередь снова читается не тем признаком. + url_from_offer_id: int = 0 + # Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id. unenrichable_pending: int = 0 duration_sec: float = field(default=0.0) @@ -88,6 +133,7 @@ class YandexDetailBackfillResult: "attempted": self.attempted, "enriched": self.enriched, "failed": self.failed, + "url_from_offer_id": self.url_from_offer_id, "unenrichable_pending": self.unenrichable_pending, "duration_sec": int(self.duration_sec), } @@ -132,51 +178,86 @@ async def run_yandex_detail_backfill( # SNAPSHOT: single SELECT at start -- NOT re-selected in loop. # Priority: is_active DESC (active first), scraped_at DESC (newest first). - # Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает - # (см. комментарий у константы): в очередь не берём то, что заведомо - # непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером. + # В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо + # сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес + # собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире + # этого условия — гарантированный parse→None пачкой и обрыв по брейкеру. snapshot = ( db.execute( text( - """ - SELECT id, source_url + f""" + SELECT id, + CASE + WHEN source_url ~ CAST(:offer_url_pattern AS text) + THEN source_url + ELSE {CANONICAL_URL_SQL} + END AS source_url FROM listings WHERE source = 'yandex' AND detail_enriched_at IS NULL - AND source_url IS NOT NULL - AND source_url ~ CAST(:offer_url_pattern AS text) + AND ( + ( + source_url IS NOT NULL + AND source_url ~ CAST(:offer_url_pattern AS text) + ) + OR source_id ~ CAST(:offer_id_pattern AS text) + ) ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST LIMIT CAST(:batch_size AS int) """ + # f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля, + # не пользовательский ввод. Всё изменяемое — bind-параметры. ), - {"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN}, + { + "batch_size": batch_size, + "offer_url_pattern": OFFER_URL_PATTERN, + "offer_id_pattern": OFFER_ID_PATTERN, + }, ) .mappings() .all() ) # Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено - # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). - counters.unenrichable_pending = int( - db.execute( - text( - """ - SELECT count(*) - FROM listings - WHERE source = 'yandex' - AND detail_enriched_at IS NULL - AND source_url IS NOT NULL - AND source_url !~ CAST(:offer_url_pattern AS text) - """ - ), - {"offer_url_pattern": OFFER_URL_PATTERN}, - ).scalar_one() - ) - if counters.unenrichable_pending: + # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно + # разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как + # «тут делать нечего» и держало 3535 квартир вне обогащения неделю. + pending = db.execute( + text( + """ + SELECT + count(*) FILTER ( + WHERE source_id ~ CAST(:offer_id_pattern AS text) + ) AS url_from_offer_id, + count(*) FILTER ( + WHERE source_id IS NULL + OR source_id !~ CAST(:offer_id_pattern AS text) + ) AS unenrichable_pending + FROM listings + WHERE source = 'yandex' + AND detail_enriched_at IS NULL + AND ( + source_url IS NULL + OR source_url !~ CAST(:offer_url_pattern AS text) + ) + """ + ), + {"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN}, + ).one() + counters.url_from_offer_id = int(pending.url_from_offer_id) + counters.unenrichable_pending = int(pending.unenrichable_pending) + if counters.url_from_offer_id: logger.info( - "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: " - "source_url ведёт не на карточку Яндекса (%s), парсер их отвергает " - "до сети", + "yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый " + "source_url не ведёт на карточку Яндекса; адресуем их по offerId из " + "source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)", + run_id, + counters.url_from_offer_id, + ) + if counters.unenrichable_pending: + logger.warning( + "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни " + "offer-URL (%s), ни числового source_id — адресовать их нечем", run_id, counters.unenrichable_pending, OFFER_URL_PATTERN, diff --git a/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql new file mode 100644 index 00000000..05f29a7c --- /dev/null +++ b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql @@ -0,0 +1,150 @@ +-- 257_listings_backfill_yandex_source_url.sql +-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838). +-- +-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`, +-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235, +-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки, +-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared` +-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку +-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки. +-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` +-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются +-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты. +-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция. +-- +-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»): +-- +-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк +-- ------------------------------------------------------- ----- +-- всего 3535 +-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535 +-- из них source_id NULL/нечисловой (нечем адресовать) 0 +-- из них source_url IS NULL 0 +-- из них is_active 3522 +-- +-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356, +-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167, +-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114, +-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост. +-- +-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем): +-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк, +-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет. +-- Множество может только уменьшаться (удаление строк), не расти. +-- +-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь. +-- scraper_kit/providers/yandex/serp.py::_canonical_source_url: +-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex» +-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL +-- "'https://realty.yandex.ru/offer/' || source_id || '/'" +-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно; +-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление +-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою +-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py). +-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+') +-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки. +-- +-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО: +-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0; +-- * два кандидата с одинаковым новым URL внутри самого множества: 0 +-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql); +-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0. +-- +-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже): +-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0 +-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0 +-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0 +-- +-- было → стало (10 строк, взяты по id DESC): +-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/ +-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/ +-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/ +-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/ +-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/ +-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/ +-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/ +-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/ +-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/ +-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/ +-- (префикс «стало» везде один: https://realty.yandex.ru/offer//) +-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же +-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK. +-- +-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено). +-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был +-- raw_payload->>'address') здесь НЕ работает: +-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там +-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type, +-- site_name — адреса нет ни под одним именем; +-- * listings.house_url / newbuilding_url у всех 3535 = NULL; +-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE +-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить +-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не +-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его +-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки, +-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика +-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе +-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес). +-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат: +-- +-- UPDATE listings l +-- SET source_url = b.old_source_url +-- FROM yandex_source_url_backfill_257 b +-- WHERE l.id = b.listing_id +-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/'; +-- +-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал +-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая +-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто +-- удалить — на приложение она не влияет, читателей у неё нет. +-- +-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО: +-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py — +-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там +-- COALESCE в апсерте так же намеренно не трогали); +-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по +-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая +-- история того, что отдал gate-API; +-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай» +-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт); +-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка. +-- +-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql +-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула). +-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL → +-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT +-- DO NOTHING делают повтор безопасным и при частичном откате. +-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py +-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и +-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед +-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 ( + listing_id bigint PRIMARY KEY, + old_source_url text NOT NULL, + changed_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE yandex_source_url_backfill_257 IS + 'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). ' + 'Только для отката; читателей в приложении нет, удаляется без последствий.'; + +INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url) +SELECT id, source_url + FROM listings + WHERE source = 'yandex' + AND source_url !~ '/offer/[0-9]+' + AND source_id ~ '^[0-9]+$' +ON CONFLICT (listing_id) DO NOTHING; + +UPDATE listings + SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/' + WHERE source = 'yandex' + AND source_url !~ '/offer/[0-9]+' + AND source_id ~ '^[0-9]+$'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql new file mode 100644 index 00000000..12f72b4b --- /dev/null +++ b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql @@ -0,0 +1,41 @@ +-- 258_houses_imv_transient_attempts.sql +-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674). +-- +-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь +-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию +-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен +-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из +-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All +-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой +-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе +-- 108 из 108 POST /fetch-json = 200). +-- +-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически +-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из +-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт +-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд. +-- +-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error', +-- прежние разрезы по imv_status/imv_error_reason работают как работали, а +-- «застряли окончательно» — это +-- SELECT count(*) FROM houses +-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3; +-- +-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по +-- imv_status IN ('pending','transient_error') с сортировкой по +-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на +-- выборке в тысячи строк. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE houses + ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN houses.imv_transient_attempts IS + 'Сколько раз подряд домовая IMV-оценка падала в transient_error. ' + 'Растёт только на transient_error, обнуляется успехом. ' + '>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql new file mode 100644 index 00000000..16312e53 --- /dev/null +++ b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql @@ -0,0 +1,145 @@ +-- 259_data_quality_drop_pct_cadastr.sql +-- Purpose (#2674, третий показатель того же класса): убрать v_data_quality.pct_cadastr. +-- +-- 214 убрала outliers_flagged, 216 — price_disagreements_count по одному доводу: ноль, +-- гарантированный устройством системы, читается как «проверили — чисто», хотя честно он +-- означает «мы это не считаем». pct_cadastr — третий такой же, поэтому и действие то же: +-- не переключать источник, а снять показатель. +-- +-- ── ЧИСЛА С ПРОДА (2026-08-13, точный count) ──────────────────────────────── +-- v_data_quality.pct_cadastr .................... 0.000000000000000000000000 +-- знаменатель витрины (listings_active) ......... 45 198 (в listings всего 99 304) +-- listings.cadastral_number IS NOT NULL ......... 0 из 99 304 (и 0 из 45 198 активных) +-- deals.cadastral_number ........................ 0 из 96 974 +-- houses.cadastral_number (DaData) .............. 2 648 из 9 468 ← ДРУГОЙ объект +-- listings.building_cadastral_number ............ 30 970 из 99 304 ← ДРУГОЙ объект +-- +-- ── ЭТО НЕ ДЕФЕКТ ИЗМЕРИТЕЛЯ (контроль на здоровом образце в тех же данных) ── +-- Тот же CTE active_listings и тот же шаблон `count(*) WHERE IS NOT NULL * 100.0 +-- / NULLIF(count(*), 0)` в соседних строках витрины даёт 95.61% (pct_geocoded), 39.82% +-- (pct_description), 65.10% (pct_year_built). Ровно 0% — про колонку, а не про арифметику. +-- +-- ── ПОЧЕМУ НОЛЬ СТРУКТУРНЫЙ ───────────────────────────────────────────────── +-- listings.cadastral_number — кадастр КВАРТИРЫ. Единственное место в коде, которое его +-- вообще читает, — providers/cian/serp.py:886 (`offer.get("cadastralNumber")`); в парсерах +-- avito/yandex/domclick/n1 слов cadastr/kadastr нет ни разу, то есть для ЧЕТЫРЁХ площадок +-- из пяти ноль гарантирован НАШИМ кодом и о предметной области не говорит ничего. Пусто +-- при этом везде, где мы этот номер храним (три таблицы выше) — то же уже записано в +-- app/services/matching/houses.py: «площадки кадастр не отдают». +-- +-- ── ПОЧЕМУ НЕЛЬЗЯ «ПОЧИНИТЬ ОДНОЙ СТРОКОЙ», ПЕРЕКЛЮЧИВ НА СОСЕДНЮЮ КОЛОНКУ ── +-- Напрашивается считать по listings.building_cadastral_number (31.19% всего, 29.47% у +-- активных). Под подписью «доля объявлений с кадастром» это НОВАЯ ложь вместо старой: +-- * это кадастр ЗДАНИЯ, и в listings у него РОВНО ОДИН писатель — наш ночной KNN ≤50 м +-- по локальному зеркалу ЕГРН (tasks/cadastral_geo_match.py:161; проверено `git grep` +-- по origin/main: других INSERT/UPDATE этой колонки нет). Он не «тот же кадастр из +-- другого места», а наша производная; +-- * #2674 замерил ключ как неинъективный (656 из 3 260 значений накрывают >1 здание ГАР, +-- 20.1%; 751 из 2 864 зданий получают >1 значение, 26.2%) и прямо запретил считать его +-- идентичностью здания; +-- * разброс по площадкам среди активных геокодированных (cian 33.8%, yandex 20.3%, +-- avito 42.0%, domclick 49.7%) — про точность НАШИХ координат и охват зеркала по ЕКБ, +-- а не про качество объявления. +-- Переименовать подпись мало: честное имя было бы «доля объявлений, которым ночной KNN +-- подобрал здание в 50 м» — это другой показатель, и заводить его надо отдельно и +-- осознанно, а не под видом починки этого. Авторитетный кадастр здания у нас есть — +-- houses.cadastral_number из DaData (2 648/9 468 домов), но он про ДОМА, а витрина считает +-- ОБЪЯВЛЕНИЯ; подставить его в эту строку — снова назвать одно другим. +-- +-- ── ЦЕНА ПРАВКИ ──────────────────────────────────────────────────────────── +-- Читателей у витрины в коде нет (grep по /app/app в живом backend-контейнере пуст; +-- /api/v1/admin/scraper/data-quality считает свои метрики сам и кадастр не показывает +-- вовсе) — это ручной psql-снимок. Зависимых объектов у view тоже нет (pg_depend по +-- 'v_data_quality'::regclass, прод 13.08: 0 строк), поэтому CASCADE не нужен и не должен +-- появиться: в этом продукте `DROP ... CASCADE` уже терял гранты FDW-пользователю (C3). +-- +-- ── ПОРЯДОК И БЛОКИРОВКА ─────────────────────────────────────────────────── +-- CREATE OR REPLACE VIEW колонку УДАЛИТЬ не может → DROP VIEW → CREATE VIEW (тот же +-- порядок, что 214/216). DROP VIEW берёт ACCESS EXCLUSIVE, поэтому `SET LOCAL +-- lock_timeout` (см. scripts/check-migration-lock-timeout.py). В отличие от 222, которая +-- обошлась CREATE OR REPLACE, здесь COMMENT ON VIEW надо выставить ЗАНОВО: DROP уносит +-- комментарий вместе с объектом. +-- +-- Тело SELECT скопировано из 222_db_audit_cleanup.sql (последний DDL; сверено с живым +-- pg_get_viewdef на проде 13.08 — совпадает) минус строка pct_cadastr. Из CTE убран +-- ставший ненужным cadastral_number: 222 завела явный список колонок ровно затем, чтобы +-- view не держал column-level зависимость на то, чего не показывает. +-- +-- Dependencies: 216_dead_code_sweep.sql (текст COMMENT ON VIEW), 222_db_audit_cleanup.sql +-- (последний DDL v_data_quality). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Идемпотентно: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT — повторный прогон даёт тот +-- же результат. + +BEGIN; + +-- Ждём лок не дольше 5 s: сам DROP мгновенный, но ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами (#2791/#2792). +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS v_data_quality; + +-- DDL идентичен 222, минус строка pct_cadastr и минус cadastral_number в CTE. +CREATE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, description, house_id_fk, is_active + 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 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; + +-- Текст 216 + абзац про pct_cadastr. Выставляем заново, потому что DROP VIEW выше унёс +-- прежний комментарий вместе с объектом. +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+ источниками»), ' + 'ноль в нём — честный ответ, а не мнимое благополучие. ' + '#2674 (мигр. 259): pct_cadastr убран по тому же доводу — считал ' + 'listings.cadastral_number (кадастр КВАРТИРЫ), а его не отдаёт ни одна площадка: ' + '0 из 99 304 объявлений, 0 из 96 974 deals, единственный читающий его парсер — ' + 'cian/serp.py. Показатель НЕ переведён на listings.building_cadastral_number: та ' + 'колонка — кадастр ЗДАНИЯ и на 100% производная нашего ночного KNN ≤50 м ' + '(tasks/cadastral_geo_match.py), неинъективного как ключ здания (#2674: 20.1% ' + 'значений накрывают >1 здание ГАР); под подписью «доля объявлений с кадастром» она ' + 'мерила бы покрытие нашего геокодера, а не качество объявлений.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql new file mode 100644 index 00000000..c53c6ce5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql @@ -0,0 +1,179 @@ +-- 260_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; diff --git a/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql new file mode 100644 index 00000000..751f9e36 --- /dev/null +++ b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql @@ -0,0 +1,270 @@ +-- 261_listings_search_mv_drop_placeholder_columns.sql +-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv: +-- distance_to_metro_m, last_price_change, photos_count. +-- +-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов), +-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст +-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 — +-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql +-- (суточный REFRESH ... CONCURRENTLY). +-- Apply after: 260_houses_drop_has_panorama.sql +-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому +-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен. +-- +-- ── ЧТО ЗА НОЛЬ ──────────────────────────────────────────────────────────── +-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении +-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ: +-- имена зарезервировали в 050, реализацию не подключили никогда. +-- +-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк): +-- null_frac = 1.0 у 5 колонок: cadastral_number, district, +-- distance_to_metro_m, last_price_change, photos_count. +-- Сносим три. После применения колонок с null_frac = 1.0 останется 2 +-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают, +-- см. 216/search_query.py; district — вынесен решением владельца, ниже). +-- +-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти: +-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main` +-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним). +-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки +-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории +-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный +-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет. +-- +-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет +-- services/search_query.py:138 и объявляет schemas/search_response.py:44 +-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее +-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857. +-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`). +-- +-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ─────────────────────────────────────── +-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW +-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает. +-- Единственный путь — пересоздание, как в 094. +-- +-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite +-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и +-- деплой честно покраснеет; CASCADE снёс бы её молча. +-- +-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ───────────── +-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после +-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную). +-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не +-- предположено: +-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного +-- стороннего grantee; column-level грантов нет; pg_default_acl пуст. +-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не +-- доказывает: information_schema не показывает материализованные представления +-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко +-- проглядеть.) +-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history — +-- на витрину ему не давали. +-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием +-- файла и его применением на проде может пройти неделя, и ручной слепок к тому +-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому +-- врать не может. +-- +-- ── ИНДЕКСЫ ──────────────────────────────────────────────────────────────── +-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один). +-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный +-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py, +-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с +-- «cannot refresh materialized view concurrently ... no unique index». +-- +-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ───────────────────────────────────────── +-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели +-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026): +-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm +-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s +-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в +-- 9-17 s, но он делает вдвое больше работы (строит + сливает). +-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset +-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и +-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день. +-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным +-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен. +-- +-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под +-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой +-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY → +-- честный красный деплой через 5 s, миграция не помечается применённой, повторный +-- деплой пройдёт. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому +-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо +-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094). +-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources. +-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же +-- способом. Восстанавливать нечего — значений не существовало. +-- +-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения): +-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в +-- _schema_migrations (а не «деплой зелёный»). +-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change / +-- photos_count отсутствуют; district на месте, тип text. +-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'. +-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx. +-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец +-- и никого больше). +-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк. +-- 7. Следующий ночной refresh_search_matview завершается status='done' +-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс). +-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит +-- удалённых — их там и не было). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке. +SET LOCAL lock_timeout = '5s'; + +-- ── 1. Снимок ACL ДО сноса ───────────────────────────────────────────────── +-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст. +-- Владельца исключаем: CREATE вернёт его права сам. +-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут +-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном +-- на одноразовой БД, а не рассуждением. +CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee, + a.privilege_type, + a.is_grantable, + NULL::text AS column_name +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +CROSS JOIN LATERAL aclexplode(c.relacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner +UNION ALL +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END, + a.privilege_type, + a.is_grantable, + quote_ident(att.attname) +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped +CROSS JOIN LATERAL aclexplode(att.attacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner; + +-- ── 2. Пересоздание витрины без трёх заглушек ────────────────────────────── +DROP MATERIALIZED VIEW IF EXISTS listings_search_mv; + +CREATE MATERIALIZED VIEW listings_search_mv AS +SELECT + l.id AS listing_id, + l.source, + l.source_url, + l.address, + l.geom, + l.lat, + l.lon AS lng, + l.rooms, + l.area_m2 AS total_area, + l.floor, + l.total_floors, + l.price_rub, + l.price_per_m2, + l.cadastral_number, + l.is_active, + l.scraped_at, + -- House denorm + h.id AS house_id, + h.year_built, + h.house_class, + h.developer_name, + h.rating AS house_rating, + h.reviews_count AS house_ratings_count, + -- Cross-source aggregates + (SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count, + (SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources, + (SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito, + (SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian, + (SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex, + -- Price percentile within house + (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2) + FROM listings ll + WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2, + -- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API + -- (schemas/search_response.py), снос — ломающее изменение контракта, решение + -- владельца (#2857). Соседние distance_to_metro_m / last_price_change / + -- photos_count сняты здесь: у них не было ни одного читателя. + NULL::text AS district, + -- Trigram-ready columns + l.address AS address_trgm, + -- Aggregated tsv (description + address + developer_name) + to_tsvector('russian', + coalesce(l.description, '') || ' ' || + coalesce(l.address, '') || ' ' || + coalesce(h.developer_name, '') + ) AS tsv +FROM listings l +LEFT JOIN houses h ON h.id = l.house_id_fk +WHERE l.is_active = true + AND COALESCE(l.canonical, true) = true; + +-- ── 3. Те же 6 индексов (050/094) ────────────────────────────────────────── +-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку. +CREATE UNIQUE INDEX listings_search_mv_id_idx + ON listings_search_mv (listing_id); + +CREATE INDEX listings_search_mv_geom_idx + ON listings_search_mv USING GIST (geom); + +CREATE INDEX listings_search_mv_filters_idx + ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC); + +CREATE INDEX listings_search_mv_address_trgm_idx + ON listings_search_mv USING GIN (address_trgm gin_trgm_ops); + +CREATE INDEX listings_search_mv_tsv_idx + ON listings_search_mv USING GIN (tsv); + +CREATE INDEX listings_search_mv_sources_idx + ON listings_search_mv (has_avito, has_cian, has_yandex); + +-- ── 4. Возврат грантов, снятых в п.1 ─────────────────────────────────────── +-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит +-- из системного каталога, поэтому подставляется как есть. +-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT +-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя, +-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP + EXECUTE format( + 'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s', + r.privilege_type, + CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END, + r.grantee, + CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END + ); + RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %', + r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee; + END LOOP; +END +$$; + +-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ──────────── +-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий +-- приёмки по pg_stats нечем проверить. +ANALYZE listings_search_mv; + +COMMENT ON MATERIALIZED VIEW listings_search_mv IS + 'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки ' + 'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в ' + 'определении, ноль читателей во всём репозитории. district оставлен намеренно: ' + 'он объявлен в schemas/search_response.py, его снос — ломающее изменение ' + 'контракта API и решение владельца. Единственный читатель витрины — ' + 'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён ' + 'по той же причине, что и по market.v_houses.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 71642631..6b449178 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -245,3 +245,8 @@ 250_drop_duplicate_expires_at_index.sql 251_listings_drop_ceiling_height.sql 254_listings_backfill_avito_rating_glued_address.sql +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 +261_listings_search_mv_drop_placeholder_columns.sql diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index ce167d78..d156690c 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -8,7 +8,7 @@ }, "low": { "coverage_pct": 82.09, - "mape_pct": 13.2, + "mape_pct": 12.67, "n": 276, "n_covered": 220 }, @@ -26,22 +26,22 @@ ], "expected_sold": { "overall": { - "mape_pct": 13.18, - "median_bias_pct": -3.71, + "mape_pct": 12.63, + "median_bias_pct": -3.74, "n": 269, "n_no_analogs": 0, - "p25_pct": -16.11, - "p75_pct": 8.92 + "p25_pct": -15.17, + "p75_pct": 8.67 }, "per_rooms": { "0": { "label": "студия", - "mape_pct": 19.38, - "median_bias_pct": 18.1, + "mape_pct": 16.96, + "median_bias_pct": 16.96, "n": 35, "n_no_analogs": 0, "p25_pct": 1.5, - "p75_pct": 34.32 + "p75_pct": 38.82 }, "1": { "label": "1к", @@ -50,50 +50,50 @@ "n": 93, "n_no_analogs": 0, "p25_pct": -14.43, - "p75_pct": 6.98 + "p75_pct": 8.15 }, "2": { "label": "2к", - "mape_pct": 18.26, - "median_bias_pct": -12.2, + "mape_pct": 17.39, + "median_bias_pct": -11.71, "n": 74, "n_no_analogs": 0, - "p25_pct": -24.35, + "p25_pct": -23.07, "p75_pct": -0.36 }, "3": { "label": "3к", - "mape_pct": 9.34, - "median_bias_pct": -3.08, + "mape_pct": 7.79, + "median_bias_pct": -4.05, "n": 43, "n_no_analogs": 0, "p25_pct": -10.26, - "p75_pct": 4.68 + "p75_pct": 3.82 }, "4": { "label": "4+", - "mape_pct": 16.3, - "median_bias_pct": 3.34, + "mape_pct": 16.38, + "median_bias_pct": -1.32, "n": 24, "n_no_analogs": 0, "p25_pct": -14.91, - "p75_pct": 15.11 + "p75_pct": 15.41 } }, "per_segment": { "бизнес": { - "mape_pct": 14.65, + "mape_pct": 13.65, "median_bias_pct": -10.54, "n": 46, - "p25_pct": -22.93, + "p25_pct": -27.15, "p75_pct": -1.31 }, "комфорт": { - "mape_pct": 11.8, - "median_bias_pct": -4.61, + "mape_pct": 10.14, + "median_bias_pct": -5.01, "n": 101, - "p25_pct": -17.07, - "p75_pct": 6.04 + "p25_pct": -15.49, + "p75_pct": 4.37 }, "премиум": { "mape_pct": 68.92, @@ -103,11 +103,11 @@ "p75_pct": -68.92 }, "эконом": { - "mape_pct": 13.71, - "median_bias_pct": 2.54, + "mape_pct": 14.2, + "median_bias_pct": 3.33, "n": 115, - "p25_pct": -9.8, - "p75_pct": 25.5 + "p25_pct": -8.69, + "p75_pct": 27.3 }, "элит": { "mape_pct": 33.2, diff --git a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py index 8e5e1163..4495830f 100644 --- a/tradein-mvp/backend/tests/matching/test_conflict_resolution.py +++ b/tradein-mvp/backend/tests/matching/test_conflict_resolution.py @@ -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: diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 30011d7b..fe555229 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -59,3 +59,13 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy # дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234: # на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе. tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint + +# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`. +# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст +# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут +# по-настоящему (Postgres + схема из data/sql) — там прогон и был красным на +# origin/main и зелёным на ветке. Статическая половина файла +# (test_retry_queue_is_not_bound_to_only_status) БД не требует и идёт в обоих лэйнах. +tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses +tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself +tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py index fb33b16e..d4d34cae 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py @@ -15,6 +15,7 @@ import json import os import re import sys +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -25,6 +26,9 @@ sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 from app.tasks.yandex_detail_backfill import ( # noqa: E402 + CANONICAL_URL_SQL, + OFFER_ID_PATTERN, + OFFER_URL_PATTERN, YandexDetailBackfillResult, run_yandex_detail_backfill, ) @@ -55,13 +59,21 @@ def _make_snapshot(n: int) -> list[dict]: ] -def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock: - """Fake Session: execute() отдаёт снапшот через .mappings().all(), а - .scalar_one() — размер отброшенной (непарсимой) части очереди.""" +def _mock_db( + snapshot: list[dict], + unenrichable: int = 0, + url_from_offer_id: int = 0, +) -> MagicMock: + """Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() — + остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из + source_id / адресовать нечем).""" db = MagicMock() sel = MagicMock() sel.mappings.return_value.all.return_value = snapshot - sel.scalar_one.return_value = unenrichable + sel.one.return_value = SimpleNamespace( + url_from_offer_id=url_from_offer_id, + unenrichable_pending=unenrichable, + ) db.execute.return_value = sel return db @@ -510,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None: assert result.unenrichable_pending == 3535 assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535 + + +# --------------------------------------------------------------------------- +# «Непригодно» — ярлык, а не диагноз (2026-08-12) +# --------------------------------------------------------------------------- + + +def _render_canonical_sql(offer_id: str) -> str: + """Считает CANONICAL_URL_SQL как строку: '||' — конкатенация, source_id — значение.""" + parts = [p.strip() for p in CANONICAL_URL_SQL.split("||")] + return "".join(offer_id if p == "source_id" else p.strip("'") for p in parts) + + +def test_recovered_url_equals_producer_canonical_form() -> None: + """Адрес, вычисленный из source_id, — тот же, что пишет продюсер, и парсер его примет. + + Шесть прогонов подряд unenrichable_pending равнялся ровно 3535 не потому, что + счётчик застыл (SELECT живой), а потому что множество замкнуто: продюсер после + #2235 таких строк больше не создаёт, а выйти оттуда нельзя — source_url пишется + только при вставке. Ярлык «непригодны» был неверен: у всех есть offerId, и по + собранному из него URL страница парсится (прод-проба 2026-08-12, 6/6). + + Сторож держит ровно это: формула восстановления в SQL не должна разъехаться с + `_canonical_source_url` продюсера, а результат — пройти гейт парсера. + """ + from scraper_kit.providers.yandex.serp import _canonical_source_url + + offer_id = "7416316697684470413" # реальный source_id прод-строки с macroserver.ru + recovered = _render_canonical_sql(offer_id) + + assert re.match(OFFER_ID_PATTERN, offer_id) + assert re.search(OFFER_URL_PATTERN, recovered), recovered + for stored_url, is_offer_url in _PROD_QUEUE_HEAD: + if is_offer_url: + continue # у этих сохранённый адрес уже канонический, чинить нечего + assert _canonical_source_url(stored_url, offer_id) == recovered, stored_url + + +@pytest.mark.asyncio +async def test_pending_counter_split_by_reason() -> None: + """Остаток очереди делится по ПРИЧИНЕ, и восстановимое не зовётся непригодным. + + Одно число на две разные судьбы («адрес чиним» и «адресовать нечем») читается + как «тут делать нечего» — так 3535 квартир простояли неделю вне обогащения. + """ + db = _mock_db([], unenrichable=0, url_from_offer_id=3535) + runs = MagicMock() + session_cls, _session = _make_session_ctx([]) + + with ( + patch(_ASYNC_SESSION, session_cls), + patch(_RUNS, runs), + patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()), + ): + result = await run_yandex_detail_backfill( + db, run_id=43, params={"batch_size": 10, "budget_sec": 60} + ) + + assert result.url_from_offer_id == 3535 + assert result.unenrichable_pending == 0 + counters = runs.mark_done.call_args.args[2] + assert counters["url_from_offer_id"] == 3535 + assert counters["unenrichable_pending"] == 0 + + # Снапшот-SELECT берёт такие строки в работу по вычисленному адресу, а не + # выбрасывает: без этой ветки они не попадут в очередь никогда. + snapshot_sql = str(db.execute.call_args_list[0].args[0]) + assert "OR source_id ~ CAST(:offer_id_pattern AS text)" in snapshot_sql + assert CANONICAL_URL_SQL in snapshot_sql diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py index ced39ac9..37d2df85 100644 --- a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py +++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py @@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None: assert "DROP COLUMN IF EXISTS is_outlier" in sql +_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b") + + +def _latest_v_data_quality() -> tuple[str, str]: + """(текст последней миграции, создающей v_data_quality; тело её SELECT). + + Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой + DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую + миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический: + деплой применяет файлы отсортированными, последний по имени — последний в проде. + """ + creators = sorted( + p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8")) + ) + assert creators, "не найдено ни одной миграции, создающей v_data_quality" + sql = creators[-1].read_text(encoding="utf-8") + hit = _VIEW_MARKER.search(sql) + assert hit is not None + return sql, sql[hit.end() :].split(";")[0] + + def test_latest_v_data_quality_no_longer_reports_outliers() -> None: - """Действующее определение v_data_quality (последняя миграция, которая его - создаёт) не упоминает is_outlier. + """Действующее определение v_data_quality не упоминает is_outlier. Red на origin/main: там последним был 095_dead_schema.sql со строкой `(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged` — показатель, который не мог быть ненулевым, потому что колонку не писал никто. - - Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой - DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту - миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический: - деплой применяет файлы отсортированными, последний по имени — последний в проде. """ - marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b") - creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8"))) - assert creators, "не найдено ни одной миграции, создающей v_data_quality" - latest = creators[-1].read_text(encoding="utf-8") - hit = marker.search(latest) - assert hit is not None - body = latest[hit.end() :].split(";")[0] + _, body = _latest_v_data_quality() assert "outliers_flagged" not in body assert "is_outlier" not in body + + +def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None: + """Тот же класс, третий случай: pct_cadastr (мигр. 259). + + Считался по listings.cadastral_number — кадастру КВАРТИРЫ, которого не отдаёт ни + одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому + показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61% + читался как измеренное качество данных. + + Red на origin/main: последний DDL там — 222_db_audit_cleanup.sql, в нём строка + `... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте. + + Замена источника на listings.building_cadastral_number — НЕ починка: та колонка + про ЗДАНИЕ и целиком производная нашего ночного KNN ≤50 м, который #2674 + замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление + в этой витрине. + """ + sql, body = _latest_v_data_quality() + assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality" + assert "cadastral_number" not in body, ( + "в витрину подставили другой кадастр — под подписью «доля объявлений с " + "кадастром» это новая ложь вместо старой (см. шапку 259)" + ) + # DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана + # выставить его заново, иначе объяснение «почему показателя нет» молча теряется. + if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql): + assert "COMMENT ON VIEW v_data_quality" in sql diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py new file mode 100644 index 00000000..6a4ce8be --- /dev/null +++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py @@ -0,0 +1,226 @@ +"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры. + +Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient` +(«требует ремонта» −6%, «евро» +10%), а домовой якорь из `house_imv_evaluations` +запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15` +и сам blend клали два разных базиса на одну шкалу — и, поскольку blend +однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало +половину его −6% обратно наверх. + +Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни +разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог +пересекали 19 — 9 из них «требует ремонта», и на них старый код давал медиану +на 3.3–9.8 % выше базис-согласованной (суммарно +4.13 млн ₽). +""" + +import os +from typing import Any + +# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +from app.services.estimator import _fetch_house_imv_anchor + +# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки +# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и +# упали на ЦИФРАХ (красный по существу), а не на collection error. + +# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽. +_PPM2 = 150_000.0 +_AREA = 40.0 +_BASE_MEDIAN = int(_PPM2 * _AREA) + + +def _make_listing(price_per_m2: float) -> dict[str, Any]: + return { + "id": int(price_per_m2), + "source": "avito", + "address": "ЕКБ, ул. Учителей, 18", + "price_rub": int(price_per_m2 * _AREA), + "price_per_m2": price_per_m2, + "area_m2": _AREA, + "rooms": 1, + "floor": 4, + "total_floors": 16, + "lat": 56.838, + "lon": 60.595, + "listing_date": None, + "days_on_market": None, + "source_url": f"https://example.test/{int(price_per_m2)}", + "photo_urls": [], + "distance_m": 10.0, + } + + +_ANALOGS = [ + _make_listing(135_000.0), + _make_listing(140_000.0), + _make_listing(145_000.0), + _make_listing(_PPM2), + _make_listing(155_000.0), + _make_listing(160_000.0), + _make_listing(165_000.0), +] + + +def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any: + """estimate_quality со всеми I/O застабленными; домовой якорь форсирован.""" + from app.schemas.trade_in import TradeInEstimateInput + from app.services.estimator import estimate_quality + from app.services.geocoder import GeocodeResult + + payload = TradeInEstimateInput( + address="ЕКБ, ул. Учителей, 18", + area_m2=_AREA, + rooms=1, + floor=4, + total_floors=16, + repair_state=repair_state, + ) + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", + provider="nominatim", + ) + + async def _run() -> Any: + with ( + patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(_ANALOGS), False, "S"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch( + "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor), + ): + return await estimate_quality(payload, MagicMock()) + + return anyio.run(_run) + + +def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]: + return { + "recommended_price": recommended, + "lower_price": int(recommended * 0.97), + "higher_price": int(recommended * 1.05), + "market_count": 500, + "rooms": 1, + "area_m2": _AREA, + "renovation_type": renovation, + } + + +# ── чистая функция ─────────────────────────────────────────────────────────── + + +def test_repair_factor_is_ratio_of_the_same_coefficients() -> None: + """Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое.""" + from app.services.estimator import _anchor_repair_factor + + # строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94) + assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94 + # строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00) + assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05 + # строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10) + assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94 + + +def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None: + """Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать». + + Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном + ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в + 1.0 при дрейфе вокабуляра тихо вернул бы дефект — поэтому базис, а не no-op. + """ + from app.services.estimator import _anchor_repair_factor + + assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94 + assert _anchor_repair_factor(None, "needs_repair") == 0.94 + # Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента. + assert _anchor_repair_factor(None, None) == 1.0 + assert _anchor_repair_factor("cosmetic", None) == 1.0 + assert _anchor_repair_factor(None, "standard") == 1.0 + + +# ── SQL-граница: без колонки правка молча выродится в no-op ────────────────── + + +def test_anchor_query_selects_renovation_type() -> None: + """`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0. + + Проверка строковая намеренно — это единственная точка, где видно, доедет ли + колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону + границы получает dict уже от вызывающего и такую регрессию не увидит. + """ + db = MagicMock() + db.execute.return_value.mappings.return_value.first.return_value = None + _fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA) + sql = str(db.execute.call_args[0][0]) + assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0] + + +# ── сквозь estimate_quality: цена, которую видит пользователь ──────────────── + + +def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None: + """«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт. + + median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000 + — якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000. + Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000 + < 6 486 000 → медиана остаётся 5 640 000 (−8.6 % к старому поведению). + """ + est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair") + assert est.median_price_rub == int(_BASE_MEDIAN * 0.94) + assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "") + + +def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None: + """Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен. + + median = 6 000 000 (coef 1.0). Старый порог 6 900 000 — якорь 7 000 000 его + перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro' + стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 → медиана не двигается. + """ + est = _run_estimate(_anchor(7_000_000, "euro"), None) + assert est.median_price_rub == _BASE_MEDIAN + + +def test_blend_still_fires_and_reports_the_rebased_anchor() -> None: + """Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится. + + median = 5 640 000, якорь 'cosmetic' 10 000 000 → в базисе клиента 9 400 000 + > 6 486 000 → blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении — + ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы + с карточкой, где показана сырая оценка Avito). + """ + est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair") + assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2 + explanation = est.confidence_explanation or "" + assert "9.4 млн ₽" in explanation + assert "приведённой к состоянию ремонта квартиры" in explanation + # Карточка Avito остаётся сырой — чужое число мы не правим. + assert est.avito_imv is not None + assert est.avito_imv.recommended_price == 10_000_000 diff --git a/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py new file mode 100644 index 00000000..cef84cae --- /dev/null +++ b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py @@ -0,0 +1,86 @@ +"""#2687: распознанный QRATOR-блок домкликового свипа — это 'platform', не 'unknown'. + +Ветка `if counters.blocked:` в `run_domclick_city_sweep` входится ТОЛЬКО когда +скрейпер поднял `DomClickBlockedError` (площадка отдала QRATOR block-страницу). Это +ровно определение `BAN_KIND_PLATFORM`. До правки свип звал `mark_banned` без +`ban_kind`, и все его баны падали в дефолт 'unknown' (#2764) — диагноз был +УСТАНОВЛЕН (блок распознан), но не передавался, и мониторинг не мог отличить +«площадка отбила» (platform) от «тракт/прокси» (infra). + +Прод 2026-08-12: domclick_city_sweep 3751/3675/3594 все `banned` с `blocked=1`, +`ban_kind='unknown'`. На старом коде тест ниже падает (ban_kind='unknown'). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.pipeline import run_domclick_city_sweep + +PFX = "scraper_kit.orchestration.pipeline" + + +class _BanKindRecorder: + """Ловит ban_kind, переданный в mark_banned (остальное — no-op).""" + + def __init__(self) -> None: + self.ban_kind: str | None = None + self.marked: list[str] = [] + + def is_cancelled(self, db: Any, run_id: int) -> bool: + return False + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + return None + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.marked.append("done") + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.marked.append("failed") + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any + ) -> None: + self.marked.append("banned") + self.ban_kind = kw.get("ban_kind") + + +async def _drive_blocked() -> _BanKindRecorder: + recorder = _BanKindRecorder() + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)]) + scraper.blocked = True # распознанный QRATOR-блок + scraper.geo_filtered = 0 + scraper.fetch_errors = 1 + scraper.buckets_completed = 2 + scraper.buckets_total = 6 + with ( + patch(f"{PFX}.DomClickScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(39, 0))), + patch(f"{PFX}.runs", recorder), + ): + await run_domclick_city_sweep( + MagicMock(), + config=SimpleNamespace(browser_http_endpoint="http://x:9000"), + matcher=MagicMock(), + run_id=1, + city_id=4, + pages=1, + request_delay_sec=0.0, + ) + return recorder + + +async def test_qrator_block_marks_platform_ban_kind() -> None: + """Блок распознан → ban_kind='platform'. На старом коде было 'unknown' (дефолт).""" + recorder = await _drive_blocked() + assert recorder.marked[-1] == "banned" + assert recorder.ban_kind == "platform" diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py index 37fc10fe..7fa59d45 100644 --- a/tradein-mvp/backend/tests/test_2800_per_source_probe.py +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -153,28 +153,38 @@ async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.Monke @pytest.mark.parametrize( - ("source", "must_contain"), + ("source", "must_contain", "path_suffix"), [ - ("avito", "www.avito.ru"), - ("cian", "ekb.cian.ru"), - ("yandex", "realty.yandex.ru"), + ("avito", "www.avito.ru", "/robots.txt"), + ("cian", "ekb.cian.ru", "/robots.txt"), + ("yandex", "realty.yandex.ru", "/robots.txt"), # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. - ("domclick", "bff-search-web.domclick.ru"), + # + # #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*, + # robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4 + # в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по + # нему зелёная ровно тогда, когда свип получает блок. + ("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"), ], ) async def test_probe_asks_the_working_host_of_each_source( - monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str ) -> None: seen: dict[str, Any] = {} + # Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» — + # 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа. + _body = ( + '{"result":{"snippetsCount":678}}' if source == "domclick" else "
User-agent: *
" + ) class _Resp: status_code = 200 - text = '{"html": "
User-agent: *
"}' + text = "{}" @staticmethod def json() -> dict[str, str]: - return {"html": "
User-agent: *
"} + return {"html": _body} class _Client: def __init__(self, **_kw: Any) -> None: ... @@ -196,7 +206,10 @@ async def test_probe_asks_the_working_host_of_each_source( assert ok is True assert must_contain in seen["payload"]["url"] - assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет + # #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это + # боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть + # нагрузка на площадку остаётся минимальной, а защита — той же, что у работы. + assert path_suffix in seen["payload"]["url"] # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. assert seen["payload"]["source"] == "generic" @@ -233,7 +246,10 @@ async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.Monkey assert ok is False # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. assert fail_kind == "page" - assert "not robots.txt" in detail + # #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от + # площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика). + # Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили. + assert "no marker 'User-agent'" in detail async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: @@ -454,3 +470,72 @@ async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPat lease = acquire(db, "domclick") # type: ignore[arg-type] assert lease is not None release(db, lease.id) # type: ignore[arg-type] + + +# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ── + + +@pytest.mark.parametrize( + ("api_body", "expect_ok"), + [ + # Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом. + # На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True, + # то есть «пара здорова» ровно там, где свип получает блок. Красный на main. + ("Ошибкаqrator captcha", False), + # Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность + # ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай + # и провалила бы этот, то есть тест не может позеленеть от глухого отказа. + # На origin/main этот случай тоже красный, но по другой причине — там проба + # спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА. + ('{"result":{"snippetsCount":678}}', True), + ], +) +async def test_domclick_probe_sees_the_block_that_robots_txt_hides( + monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool +) -> None: + """Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок. + + Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип + `QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и + 5 банов на все четыре площадки при ежедневном блоке Домклика. + """ + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, body: str) -> None: + self._body = body + + def json(self) -> dict[str, str]: + return {"html": self._body} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + asked = str(json["url"]) + # Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт. + if asked.endswith("/robots.txt"): + return _Resp("
User-agent: *
") + return _Resp(api_body) + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="domclick" + ) + + assert ok is expect_ok + if not expect_ok: + # Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy". + assert fail_kind == "page" + # Спрашивали именно боевой путь, а не robots.txt того же хоста. + assert "/api/offers/" in seen["payload"]["url"] diff --git a/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py new file mode 100644 index 00000000..9df5b583 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py @@ -0,0 +1,145 @@ +"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674). + +`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо +в определении: district, distance_to_metro_m, last_price_change, photos_count. +Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию +не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты) +и они сняты миграцией 261; district оставлен намеренно — он объявлен в +schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее +изменение контракта (решение владельца, вынесено отдельно в #2857). + +Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её +актуального определения (самый старший NN среди файлов, создающих витрину) и +смотрит на состав списка. Переформатирование SQL, перестановка строк или смена +`NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит. + +Без БД и сети: миграции читаются как текст, разбираются в структуру. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" + +MV = "listings_search_mv" +# Сняты 261: ноль читателей во всём репозитории на момент сноса. +DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count") + + +def _strip_sql_comments(sql: str) -> str: + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) + return re.sub(r"--[^\n]*", "", sql) + + +def _latest_definition() -> str: + """Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид.""" + creators = [ + p + for p in SQL_DIR.glob("*.sql") + if re.search( + rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b", + _strip_sql_comments(p.read_text("utf-8")), + re.I, + ) + ] + assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда" + return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8") + + +def mv_columns() -> list[str]: + """Имена колонок витрины в порядке объявления. + + Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым + той же глубины, имя колонки — последний идентификатор элемента (алиас после + `AS` либо хвост `l.foo`). + """ + sql = _strip_sql_comments(_latest_definition()) + body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1] + + depth, items, cur = 0, [], [] + for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I): + t = token.group(0) + if t == "(": + depth += 1 + elif t == ")": + depth -= 1 + elif depth == 0 and t == ",": + items.append("".join(cur)) + cur = [] + continue + elif depth == 0 and t.upper() == "FROM": + break + cur.append(t) + items.append("".join(cur)) + + return [item.split()[-1].split(".")[-1] for item in items if item.split()] + + +def test_placeholder_columns_are_gone_from_the_matview() -> None: + """Red => витрина снова обещает поля, которых не заполняет (#2857). + + Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел + после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот. + """ + cols = mv_columns() + still_there = [c for c in DROPPED if c in cols] + assert not still_there, ( + f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя " + "читается снаружи как «данные есть, просто у этого объекта пусто» — это " + "хуже мёртвого кода, потому что видно в контракте." + ) + + +def test_district_is_deliberately_kept() -> None: + """Red => district снесли заодно, а он в схеме ответа API. + + schemas/search_response.py объявляет `district: str | None`, services/search_query.py + его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857), + а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа. + """ + assert "district" in mv_columns(), ( + f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: " + "ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой." + ) + + +def test_search_api_selects_only_columns_the_matview_has() -> None: + """Настоящий инвариант: то, что просит API, витрина обязана иметь. + + Именно эта проверка отличает «список колонок» от «поиска подстроки»: она + краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх + известных именах. + """ + from app.schemas.search import SearchParams + from app.services.search_query import build_search_query + + sql, _ = build_search_query(SearchParams()) + selected = [ + c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip() + ] + missing = [c for c in selected if c not in mv_columns()] + assert not missing, ( + f"services/search_query.py просит у {MV} колонки, которых в её определении нет: " + f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из " + "schemas/search_response.py." + ) + + +def test_unique_index_for_concurrent_refresh_survives_recreation() -> None: + """Red => ночной REFRESH ... CONCURRENTLY упадёт. + + app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание + refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает + «cannot refresh materialized view concurrently ... no unique index» — а витрина, + которую пересоздали и забыли проиндексировать, молчит до самой ночи. + """ + sql = _strip_sql_comments(_latest_definition()) + assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), ( + f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — " + "REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен." + ) diff --git a/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py new file mode 100644 index 00000000..cc0ba37a --- /dev/null +++ b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py @@ -0,0 +1,295 @@ +"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона. + +#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket +heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), — +но единственным входом оставил админку. У avito full-load админского эндпоинта нет +вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799 +на origin/main). То есть боевой путь возобновления не существовал ни одного дня. + +Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных +прогонах с ЖИВОЙ незабранной точкой — avito_full_load 242, cian_full_load 134, +avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из +84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08. + +Красный прогон на origin/main: + 1. `test_scheduler_hands_checkpoint_to_pipeline` — планировщик отдаёт в пайплайн + resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках); + 2. `test_partial_bucket_is_not_complete` — бакет с выпавшей страницей приезжает в + on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще); + 3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` — TypeError: `_on_bucket` + на main принимает два аргумента, признаку полноты некуда приехать. +Тесты ладдера (`_resume_decision`) на main падают с AttributeError — функции нет. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration import scheduler as sched +from scraper_kit.orchestration.pipeline import run_avito_full_load +from scraper_kit.providers.avito.serp import AvitoScraper + +PFX = "scraper_kit.orchestration.pipeline" + +# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона +# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание». +_PARAMS = { + "concurrency": 1, + "interval_days": 7, + "secondary_only": True, + "request_delay_sec": 7.0, + "price_cap_per_bucket": 1400, +} + + +def _candidate(**over: Any) -> SimpleNamespace: + """Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде.""" + base = { + "prev_id": 3547, + "prev_status": "cancelled", + "prev_counters": { + "unique_fetched": 5496, + "done_buckets": [f"room_1_komn:{i}:0" for i in range(35)], + }, + "same_params": True, + "age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08 + "interval_days": "7", + } + base.update(over) + return SimpleNamespace(**base) + + +class _FakeDb: + """Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE.""" + + def __init__(self, row: Any) -> None: + self.row = row + self.written: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: # update_heartbeat пишет вердикт + self.written.append(params) + return MagicMock() + return MagicMock(fetchone=lambda: self.row) + + def commit(self) -> None: + pass + + +# ── 1. Главное: планировщик обязан отдать точку в пайплайн ─────────────────── + + +@pytest.mark.parametrize( + ("job", "pipeline_fn"), + [ + (sched._job_avito_full_load, "run_avito_full_load"), + (sched._job_avito_full_load_exhaustive, "run_avito_full_load"), + (sched._job_cian_full_load, "run_cian_full_load"), + ], +) +async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None: + """Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля. + + Падает на origin/main: планировщик передаёт литеральный None — 433 корзины за 90 + суток перебирались заново, включая 35 корзин прогона 3547. + """ + db = _FakeDb(_candidate()) + captured: dict[str, Any] = {} + + async def _spy(*_a: Any, **kw: Any) -> None: + captured.update(kw) + + with patch.object(sched, pipeline_fn, _spy): + await job(db, 4000, dict(_PARAMS), MagicMock()) + + assert captured["resume_run_id"] == 3547 + + +async def test_verdict_lands_in_counters_of_new_run() -> None: + """Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах. + + Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому + молчаливый отказ подхватить неотличим от отсутствия правки. + """ + db = _FakeDb(_candidate(prev_status="zombie")) + with patch.object(sched, "run_avito_full_load", AsyncMock()): + await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock()) + + assert db.written, "вердикт о подхвате не записан в counters нового прогона" + written = db.written[-1]["counters"] + assert '"resume_reason": "status_zombie"' in written + assert '"resume_candidate": 3547' in written + + +# ── 2. Ладдер отказов: у каждого нуля своя причина ─────────────────────────── + + +@pytest.mark.parametrize( + ("row", "reason"), + [ + (None, "no_prev_run"), + (_candidate(prev_status="done"), "status_done"), + (_candidate(prev_status="zombie"), "status_zombie"), + (_candidate(same_params=False), "params_changed"), + (_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"), + (_candidate(age_h=200.0), "checkpoint_stale"), + (_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"), + ], +) +def test_resume_refusals_are_named(row: Any, reason: str) -> None: + """«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы.""" + resume_id, verdict = sched._resume_decision(row) + assert resume_id is None + assert verdict["resume_reason"] == reason + assert verdict["resume_from"] is None + + +def test_resume_chain_is_bounded() -> None: + """Цепочка возобновлений считается и упирается в потолок, а не тянется вечно. + + Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход + обязан начаться раньше, чем сводка объявит источник просроченным. + """ + assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1 + _id, first = sched._resume_decision(_candidate()) + assert first["resume_chain"] == 1 + _id2, second = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1}) + ) + assert second["resume_chain"] == sched._MAX_RESUME_CHAIN + third_id, third = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}) + ) + assert third_id is None and third["resume_reason"] == "chain_limit" + + +def test_stale_threshold_follows_the_source_tick() -> None: + """Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой. + + cian ходит раз в 3 суток, avito — раз в 7; одна и та же точка возрастом 100 ч для + первого просрочена, для второго свежая. Плюс сутки — сетка запуска (см. + _resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе + точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) — нет. + """ + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None + + +# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ───────────────── + + +def _serp_config() -> SimpleNamespace: + return SimpleNamespace( + scraper_fetch_mode="curl_cffi", + browser_http_endpoint="http://browser.test/fetch", + scraper_proxy_url=None, + avito_proxy_max_rotations=0, + avito_serp_ok_not_banned=True, + avito_proxy_rotate_settle_s=0.0, + proxy_rotate_attempts=1, + proxy_rotate_attempt_timeout_s=1.0, + scraper_skip_seen_today=False, + ) + + +@pytest.mark.parametrize( + ("page2_html", "expected_complete"), + [(None, False), ("", True)], +) +async def test_partial_bucket_is_not_complete( + page2_html: str | None, expected_complete: bool +) -> None: + """Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан». + + Контрольная половина обязательна: реализация «всегда False» тоже прошла бы + одностороннюю проверку, но убила бы возобновление целиком. + + Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты + в протоколе нет — частичный бакет неотличим от целого и попадает в done_buckets. + """ + scraper = AvitoScraper(_serp_config()) + scraper.request_delay_sec = 0.0 + calls: list[tuple[str, bool]] = [] + + def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + calls.append((key, complete)) + + async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None: + return page2_html if page == 2 else f"" + + with ( + patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page), + patch.object( + AvitoScraper, + "_parse_html", + lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")], + ), + ): + await scraper._paginate_leaf_bucket( + room_slug="kvartiry_1_komnatnye", + room_label="room_1_komn", + lo=0, + hi=3999999, + html="", + max_pages=3, + seen={}, + price_cap_per_bucket=1400, + max_pages_per_bucket=100, + concurrency=2, + secondary_only=False, + on_bucket=_on_bucket, + skip_buckets=None, + expected_total=3 * 50, + ) + + assert [c[1] for c in calls] == [expected_complete] + + +async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None: + """Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал. + + Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить + частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда. + """ + finals: list[dict[str, Any]] = [] + + class _Recorder: + def is_cancelled(self, *_a: Any, **_k: Any) -> bool: + return False + + def update_heartbeat(self, *_a: Any, **_k: Any) -> None: + pass + + def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None: + finals.append(dict(counters)) + + async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None: + on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True) + on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False) + + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_all_secondary = _fetch + + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))), + patch(f"{PFX}.runs", _Recorder()), + ): + counters = await run_avito_full_load( + MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock() + ) + + assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"] + assert finals[0]["partial_buckets"] == 1 + assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены" diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py index ae084d93..22324746 100644 --- a/tradein-mvp/backend/tests/test_alerts_become_events.py +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -102,14 +102,32 @@ def test_harness_itself_drops_warnings() -> None: class _FakeMonitorDB: - """Session-мок мониторов свежести: один SELECT max(...).""" + """Session-мок мониторов свежести. - def __init__(self, latest: date | None) -> None: + Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает + ещё время последнего ПОЛНОГО прогона загрузки и её такт — именно они, а не + календарный возраст периода, решают, быть ли тревоге. + """ + + def __init__( + self, + latest: date | None, + last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC), + interval_days: str = "7", + ) -> None: self._latest = latest + self._last_pull = last_pull + self._interval_days = interval_days def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) result = MagicMock() - result.first.return_value = MagicMock(latest=self._latest) + if "scrape_runs" in sql: + result.first.return_value = MagicMock(last_pull=self._last_pull) + elif "scrape_schedules" in sql: + result.first.return_value = MagicMock(interval_days=self._interval_days) + else: + result.first.return_value = MagicMock(latest=self._latest) return result def rollback(self) -> None: @@ -122,10 +140,13 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None: monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None) -def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: - """Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие.""" +def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузка встала (полный прогон 20 суток назад при такте 7) → событие. + + 20 суток — реальный разрыв прод-истории между полными прогонами 07-17 и 08-06. + """ _patch_runs(monkeypatch, sber_mon) - db = _FakeMonitorDB(date(2026, 6, 1)) + db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC)) with glitchtip_events() as events: out = sber_mon.check_sber_freshness( db, # type: ignore[arg-type] @@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: assert out["alert"] == 1 assert any( "sber freshness" in t for t in event_texts(events) - ), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает" + ), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает" -def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: - """Свежие данные — ни одного события (иначе алерт-усталость).""" +def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет. + + Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR: + возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость + от таких событий и делает настоящий отказ незаметным. + """ _patch_runs(monkeypatch, sber_mon) - db = _FakeMonitorDB(date(2026, 6, 1)) + db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон) with glitchtip_events() as events: out = sber_mon.check_sber_freshness( db, # type: ignore[arg-type] run_id=2, params={}, - now=datetime(2026, 6, 20, tzinfo=UTC), + now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC), ) assert out["alert"] == 0 assert event_texts(events) == [] @@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None params={}, now=datetime(2026, 8, 6, tzinfo=UTC), ) - assert any("sber_price_index пуст" in t for t in event_texts(events)) + assert any("у оценщика нет серии" in t for t in event_texts(events)) def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 5ac78324..8a386aa4 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path(): patch("app.services.house_imv_backfill.save_imv_result") as mock_save, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) @@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params(): patch("app.services.house_imv_backfill._mark_status") as mock_mark, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) @@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found(): patch("app.services.house_imv_backfill._mark_status") as mock_mark, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py index bdb6f976..5e6e0129 100644 --- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -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") diff --git a/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py new file mode 100644 index 00000000..85ed4502 --- /dev/null +++ b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py @@ -0,0 +1,208 @@ +"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа. + +Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и +period_months — ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и +слово «ФАКТИЧЕСКИЕ» читалось как «недавние». + +Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein): + + deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01 + (223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк, + месяцы ровно {01, 04, 07, 10} → deal_date это метка КВАРТАЛЬНОЙ пачки. + + Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15% + +окно 12 мес + ppm²-банды, включая city-wide widen): + свежайшая I кв. 2026 — 654 (74.2%) + коридора нет вовсе — 165 (18.7%) + свежайшая IV кв. 2025 — 62 ( 7.0%) + → у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего + максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу. + +Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в +dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.schemas.trade_in import AnalogLot, DkpCorridor +from app.services.estimator import _fetch_dkp_corridor + +_ADDRESS = "Екатеринбург, ул. Гоголя, 18" + + +def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]: + return {"price_per_m2": ppm2, "deal_date": deal_date} + + +def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any: + """Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen. + + Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем + _load_sber_index_series, затем (опционально) city-wide widen. + """ + db = MagicMock() + calls: list[Any] = [] + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + calls.append(query) + sql = str(query) + if "sber" in sql.lower(): + result.mappings.return_value.all.return_value = [] + elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql: + result.mappings.return_value.all.return_value = street_rows + elif "deal_city_price_bands" in sql: + result.mappings.return_value.all.return_value = city_rows or [] + else: + result.mappings.return_value.all.return_value = [] + return result + + db.execute.side_effect = _execute + return db + + +def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def] + return _fetch_dkp_corridor( + _db(street_rows, city_rows), + address=_ADDRESS, + rooms=2, + area=60.0, + city="екатеринбург" if city_rows is None else "нижний тагил", + ) + + +# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ───────────────────────────────── + + +def test_latest_deal_date_reaches_the_response_schema() -> None: + """Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки. + + На origin/main падает AttributeError — поля в схеме нет, а Pydantic v2 + молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» — нет. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))]) + assert raw is not None + corridor = DkpCorridor(**raw) + assert corridor.latest_deal_date == date(2026, 1, 1) + + +def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None: + """Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025. + + Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы + (2026-01-01) для них — чужая, более свежая дата. + """ + raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_deal_without_price_does_not_donate_its_freshness() -> None: + """Строка без ppm² не входит в границы коридора — не входит и в его возраст. + + Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не + участвуя ни в одном из чисел под ней. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))]) + assert raw is not None + assert raw["count"] == 1 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None: + """#oblast-D widen: числа переехали на city-выборку — дата обязана тоже. + + Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для + глаза; но на экране после widen'а стоят city-числа, которые кончаются + III кв. 2025. Подпись описывает то, что показано. + """ + street = [_deal(200_000, date(2026, 1, 1))] + city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))] + raw = _fetch(street, city) + assert raw is not None + assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)" + assert raw["latest_deal_date"] == date(2025, 7, 1) + + +def test_no_deals_means_no_corridor_and_nothing_to_date() -> None: + """count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать.""" + assert _fetch([]) is None + + +def test_period_months_is_not_a_freshness_claim() -> None: + """Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа. + + Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев» + (mappers.ts) — а v1 печатал. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["period_months"] == 12 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +# ── PDF: то же обещание, та же подпись ────────────────────────────────────── + + +def _estimate_with_deals(dates: list[date]) -> Any: + est = MagicMock() + est.actual_deals = [ + AnalogLot( + address="Екатеринбург, ул. Гоголя", + area_m2=60.0, + rooms=2, + floor=None, + total_floors=None, + price_rub=12_000_000, + price_per_m2=200_000, + listing_date=d, + days_on_market=None, + date_precision="quarter", + source="rosreestr", + ) + for d in dates + ] + return est + + +def test_pdf_deals_page_dates_the_deals_it_shows() -> None: + """PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка. + + До правки страница печатала «Период сделок: 08.2025 – 08.2026», где правый + конец = сегодня: окно поиска, выданное за период данных. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == ( + "по I кв. 2026" + ) + assert deals_as_of_label(_estimate_with_deals([])) is None + + +def test_quarter_label_matches_every_deal_date_present_on_prod() -> None: + """Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка. + + Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + expected = { + date(2026, 1, 1): "по I кв. 2026", + date(2025, 10, 1): "по IV кв. 2025", + date(2025, 7, 1): "по III кв. 2025", + date(2025, 4, 1): "по II кв. 2025", + date(2025, 1, 1): "по I кв. 2025", + date(2024, 10, 1): "по IV кв. 2024", + date(2024, 7, 1): "по III кв. 2024", + date(2024, 4, 1): "по II кв. 2024", + date(2024, 1, 1): "по I кв. 2024", + } + for d, label in expected.items(): + assert deals_as_of_label(_estimate_with_deals([d])) == label diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index 6935c5fb..f6cb2430 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -331,18 +331,24 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None: # --------------------------------------------------------------------------- -# Fix 5a — sber staleness warning +# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846) +# +# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой +# возраст недостижим по построению (period_month — метка первого числа + лаг +# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки +# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим» +# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место — +# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ. # --------------------------------------------------------------------------- -def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series логирует warning при stale серии.""" +def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None: + """Серия отдаётся как есть; календарного warning'а в горячем пути больше нет.""" import logging from app.services.estimator import _load_sber_index_series - # Серия с единственным месяцем 2 года назад - stale_month = date(2024, 1, 1) + stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"period_month": stale_month, "index_value_rub_m2": 100_000.0} @@ -351,33 +357,9 @@ def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None with caplog.at_level(logging.WARNING, logger="app.services.estimator"): series = _load_sber_index_series(mock_db, region="Свердловская область") - assert len(series) == 1 - assert stale_month in series - # Warning о staleness должен быть залогирован + assert series == {stale_month: 100_000.0} stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}" - - -def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series НЕ логирует warning при свежей серии.""" - import logging - - from app.services.estimator import _load_sber_index_series - - # Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога). - today = datetime.now(tz=UTC).date() - fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца - mock_db = MagicMock() - mock_db.execute.return_value.mappings.return_value.all.return_value = [ - {"period_month": fresh_month, "index_value_rub_m2": 128_000.0} - ] - - with caplog.at_level(logging.WARNING, logger="app.services.estimator"): - series = _load_sber_index_series(mock_db, region="Свердловская область") - - assert len(series) == 1 - stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}" + assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}" # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py new file mode 100644 index 00000000..d92a9ab9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py @@ -0,0 +1,216 @@ +"""Квартал ЦЕЛИ для индекса #764 берётся у цели, а не у аналога. + +Дефект (прод, замер 2026-08-12): `dadata.house_cadnum` пуст в 15 из 15 применений +квартального индекса, и `target_quarter` молча брался из кадастра ПЕРВОГО аналога с +кадастром. Аналог-донор стоял в 201-4087 м от цели; индексы кварталов на такой +дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 — то есть +«индекс квартала цели» на витрине был индексом квартала соседа, и он двигал цену +(применённые на проде множители: 0.60 ×3, 0.73 ×2, 0.76 ×2, 0.79 ×2, 0.80, 1.03 ×3, +1.08, 1.09). + +Паттерн: os.environ.setdefault перед импортом (как test_estimator_quarter_index.py). +Фикстуры лотов/payload переиспользуются оттуда же; harness здесь свой, потому что +тестам нужны РАЗНЫЕ индексы у разных кварталов (общий harness отдаёт один индекс +всем кварталам → factor всегда 1.0, подмена квартала невидима) и контроль над +координатным резолвом квартала цели. +""" + +from __future__ import annotations + +import os +from contextlib import ExitStack +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import anyio + +from tests.test_estimator_quarter_index import ( + _AREA, + _BASE_PPM2, + _make_fake_dadata, + _make_fake_geo_qi, + _make_listing_qi, + _make_payload_qi, +) + +_TARGET_Q = "66:41:0204016" +_NEIGH_CHEAP_Q = "66:41:0601022" +_NEIGH_RICH_Q = "66:41:0601033" +_INDEXES = {_TARGET_Q: 1.2, _NEIGH_CHEAP_Q: 0.8, _NEIGH_RICH_Q: 1.6} + +_UNPATCHED = object() # sentinel: координатный резолвер не подменяем + + +def _run_estimate( + *, + analogs: list[dict[str, Any]], + dadata_cadnum: str | None, + coords_quarter: Any = _UNPATCHED, + coords_enabled: bool = True, +): + """estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES. + + coords_quarter: + * `_UNPATCHED` (default) — координатный резолвер НЕ подменяется, db-мок + отдаёт «зданий рядом нет» (`.first() → None`), т.е. свой квартал цели + неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) — + старый код подставит квартал аналога, на чём тест и падает. + * str / None — явная подмена резолвера. + + coords_enabled — значение `estimate_quarter_from_coords_enabled` на время + прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины + включают его явно. + """ + from app.services.estimator import estimate_quality, settings + + db = MagicMock() + # «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на + # пути оценки (всё остальное I/O застаблено) — координатный резолв квартала. + db.execute.return_value.first.return_value = None + + dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None + + def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals): + idx = _INDEXES.get(quarter_cad_number) + return (idx, 30) if idx is not None else None + + def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + return {q: _INDEXES[q] for q in quarter_cad_numbers if q in _INDEXES} + + async def _run(): + stubs = [ + patch( + "app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo_qi()), + ), + patch( + "app.services.estimator.dadata_clean_address", + new=AsyncMock(return_value=dadata_obj), + ), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._fetch_analogs", + return_value=(list(analogs), False, "W"), + ), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", return_value=None), + patch( + "app.services.estimator._get_or_fetch_imv_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), + patch("app.services.estimator._lookup_quarter_index", side_effect=_fake_lookup_index), + patch( + "app.services.estimator._lookup_quarter_indexes", + side_effect=_fake_lookup_indexes, + ), + ] + if coords_quarter is not _UNPATCHED: + stubs.append( + patch( + "app.services.estimator._lookup_target_quarter_by_coords", + return_value=coords_quarter, + ) + ) + stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled)) + with ExitStack() as stack: + for stub in stubs: + stack.enter_context(stub) + return await estimate_quality(_make_payload_qi(), db) + + return anyio.run(_run) + + +def _analogs_two_quarters() -> list[dict[str, Any]]: + """5 аналогов в дешёвом квартале + 5 в дорогом, одинаковая цена ₽/м². + + Первым в listings_clean идёт дешёвый — именно его кадастр старый код брал за + кадастр ЦЕЛИ. avg_analog_index = (0.8 + 1.6) / 2 = 1.2 при равных весах. + Разные этажи обязательны: физический ключ _dedup_cross_source иначе схлопнет + все лоты в один. + """ + cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + rich = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_RICH_Q}:{200 + i}", floor=8 + i) + for i in range(5) + ] + return cheap + rich + + +def test_neighbour_cadastre_no_longer_sets_target_quarter() -> None: + """Свой квартал цели неизвестен → поправки нет (а не «квартал первого аналога»). + + КРАСНЫЙ на старом коде: там target_quarter = 66:41:0601022 (дешёвый сосед, + index 0.8), avg = 1.2 → factor 0.667 → медиана падает на треть, и пользователю + пишется «Учтена локация квартала (индекс цен квартала ×0.67)». + """ + est = _run_estimate(analogs=_analogs_two_quarters(), dadata_cadnum=None) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "") + + +def test_target_quarter_from_own_coords_drives_correction() -> None: + """Квартал цели снят по ЕЁ координатам → поправка считается от него. + + index цели 1.2 / avg аналогов 1.2 → factor 1.00: цена не двигается, потому что + уровень аналогов и уровень цели совпали. Старый код взял бы 0.8 у соседа и + ×0.67 без всяких данных о цели. + """ + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_TARGET_Q, + ) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала ×1.00" in (est.confidence_explanation or "") + + +def test_own_cadastre_wins_over_coords() -> None: + """Свой кадастр DaData приоритетнее координатного резолва — резолв не зовётся.""" + analogs_cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate(analogs=analogs_cheap, dadata_cadnum=f"{_TARGET_Q}:350") + + assert spy.call_count == 0 + # target 1.2 / avg 0.8 = 1.5 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5) + + +def test_coords_resolver_dormant_by_default() -> None: + """Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще. + + Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно: + точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ — + нет, поэтому включение — отдельное решение (см. config, критерий и дата). + """ + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_UNPATCHED, + coords_enabled=False, + ) + + assert spy.call_count == 0 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "") diff --git a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py index b5d6ebbe..5962af60 100644 --- a/tradein-mvp/backend/tests/test_estimator_yandex_integration.py +++ b/tradein-mvp/backend/tests/test_estimator_yandex_integration.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_geocoder_city_substitution.py b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py new file mode 100644 index 00000000..b6626d94 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py @@ -0,0 +1,277 @@ +"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ». + +Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры. +Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не +находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой +«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк, +2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`, +6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов. + +Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город) +и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После +ответа различимы: провайдер сам пишет, какой НП он использовал. + +Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные). +""" + +from __future__ import annotations + +import contextlib +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +import pytest + +from app.services.geocoder import ( + GeocodeResult, + GeocodeSuggestion, + _city_substituted, + _claimed_locality, + geocode, +) + +# ── _claimed_locality: какой НП назван в тексте ────────────────────────────── + + +@pytest.mark.parametrize( + "address,expected", + [ + # НП назван явно + ("реж, ленина", "реж"), + ("арамиль, красноармейская ул, 118дк1", "арамиль"), + ("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось + # Уровни выше НП пропускаются, НП находится дальше + ("свердловская обл., г.о. рефтинский", "рефтинский"), + ("россия, свердловская область, реж, ленина 5", "реж"), + # НП НЕ назван — основной трафик формы оценки, инвариант не применяется + ("малышева 30", None), + ("ул. титова/монтерская/смоленская, стр. 2", None), + ("улица декабристов, 16-18литж · р-н октябрьский", None), + ("мкр-н широкая речка, ул. анатолия муранова,18", None), + # Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним + ("малышева, мопра", "малышева"), + ], +) +def test_claimed_locality(address: str, expected: str | None) -> None: + assert _claimed_locality(address) == expected + + +# ── _city_substituted: подмена vs честный ответ ────────────────────────────── + + +def _res(full_address: str, lat: float, lon: float) -> GeocodeResult: + return GeocodeResult( + lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact" + ) + + +@pytest.mark.parametrize( + "address,full_address,lat,lon", + [ + # Живые записи прод-кэша из тела #2590 (все provider=yandex) + ( + "реж, ленина", + "Россия, Свердловская область, Екатеринбург, улица Ленина", + 56.83953, + 60.61244, + ), + ( + "реж, краснофлотцев", + "Россия, Свердловская область, Екатеринбург, улица Краснофлотцев", + 56.88996, + 60.63184, + ), + ( + "арамиль, заводская", + "Россия, Свердловская область, Екатеринбург, Заводская улица", + 56.83043, + 60.55170, + ), + ( + "свердловская обл., г.о. рефтинский", + "Россия, Свердловская область, Екатеринбург", + 56.83744, + 60.59764, + ), + # Города вне списка 37 и без структурной приставки — дыра, оставленная #2589 + ( + "талица, кузнецова", + "Россия, Свердловская область, Екатеринбург, улица Кузнецова", + 56.84226, + 60.64557, + ), + ( + "верхотурье, герцена", + "Россия, Свердловская область, Екатеринбург, улица Герцена", + 56.744397, + 60.677299, + ), + # Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от + # улицы Советской, куда провайдер поставил точку (живая запись прода). + ( + "пос. кедровка, советская ул.,5", + "Россия, Свердловская область, Екатеринбург, Советская улица, 5", + 56.8506, + 60.6282, + ), + ], +) +def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None: + assert _city_substituted(address, _res(full_address, lat, lon)) is True + + +@pytest.mark.parametrize( + "address,full_address,lat,lon", + [ + # Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address. + # Тот же вход, что и в подмене выше: разделяет их не география, а + # сохранность топонима (граница из #2777 — посёлки не ловятся + # «городским» признаком, и здесь ловить их и не нужно). + ( + "пос. кедровка, советская ул.,5", + "5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", + 56.9902, + 60.7166, + ), + ( + "шувакиш, ленина 4", + "4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург", + 56.9295, + 60.4938, + ), + # НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза + ( + "малышева 30", + "Россия, Свердловская область, Екатеринбург, улица Малышева, 30", + 56.8389, + 60.6057, + ), + # Явный Екатеринбург в адресе — не подмена по определению + ("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686), + ("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943), + # Честный ответ по другому городу области: провайдер назвал НП, координаты + # в его собственном bbox — под инвариант не подпадает + ("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813), + # Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит + # внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено + ( + "режевской, ленина", + "Россия, Свердловская область, Берёзовский, улица Ленина", + 56.9088, + 60.8107, + ), + # Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не + # является подменой города + ( + "малышева, мопра", + "Россия, Свердловская область, Екатеринбург, улица Малышева", + 56.8389, + 60.6057, + ), + # Тот же НП, но за пределами ЕКБ — инвариант молчит + ("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897), + ], +) +def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None: + assert _city_substituted(address, _res(full_address, lat, lon)) is False + + +# ── geocode(): сквозной эффект ─────────────────────────────────────────────── + + +@contextlib.contextmanager +def _tiers(*, cached=None, nominatim=None, geoportal=None): + with contextlib.ExitStack() as stack: + stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached)) + stack.enter_context(patch("app.services.geocoder._cache_put")) + stack.enter_context( + patch("app.services.geocoder._geoportal_house_match", return_value=geoportal) + ) + stack.enter_context( + patch("app.services.geocoder._cadastral_house_match", return_value=None) + ) + stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[])) + stack.enter_context( + patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim)) + ) + stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock())) + yield + + +async def test_geocode_downgrades_substituted_result() -> None: + """«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact. + + Координаты остаются (честная деградация, не отказ), но confidence падает до + `locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse` + включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу + `geo_precision='city'`, что исключает его из пула аналогов. + """ + db = MagicMock() + substituted = GeocodeResult( + lat=56.83953, + lon=60.61244, + full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5", + provider="nominatim", + confidence="exact", + ) + with _tiers(nominatim=substituted): + result = await geocode("Реж, Ленина 5", db) + + assert result is not None + assert result.confidence == "locality" + assert result.city_ambiguous is True + assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены + + +async def test_geocode_poisoned_cache_hit_downgraded() -> None: + """Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже + НЕ как точная — без единого DELETE в `geocode_cache`.""" + db = MagicMock() + poisoned = GeocodeResult( + lat=56.83744, + lon=60.59764, + full_address="Россия, Свердловская область, Екатеринбург", + provider="cache", + confidence="exact", + ) + with _tiers(cached=poisoned): + result = await geocode("Арамиль, Красноармейская ул, 118дк1", db) + + assert result is not None + assert result.confidence == "locality" + assert result.city_ambiguous is True + + +async def test_geocode_ekb_address_unaffected() -> None: + """Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города) + остаётся `exact` — инвариант не трогает основной трафик.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house" + ) + with _tiers(geoportal=hit): + result = await geocode("Екатеринбург, Малышева 30", db) + + assert result is not None + assert result.confidence == "exact" + assert result.city_ambiguous is False + + +async def test_geocode_settlement_inside_ekb_kept_exact() -> None: + """Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не + отбраковывается — граница, о которой предупреждает #2777.""" + db = MagicMock() + honest = GeocodeResult( + lat=56.9902, + lon=60.7166, + full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", + provider="nominatim", + confidence="exact", + ) + with _tiers(nominatim=honest): + result = await geocode("пос. Кедровка, Советская ул., 5", db) + + assert result is not None + assert result.confidence == "exact" diff --git a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py index c36aa891..98f057aa 100644 --- a/tradein-mvp/backend/tests/test_house_imv_params_honesty.py +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -138,7 +138,11 @@ async def test_unknown_house_type_skips_request_and_marks_house() -> None: patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval, patch.object(hib, "_mark_status") as mock_mark, ): - db.execute.return_value.mappings.return_value.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + db.execute.return_value.mappings.return_value.all.side_effect = [[], houses] result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0) mock_eval.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py new file mode 100644 index 00000000..b414aefc --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py @@ -0,0 +1,267 @@ +"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную. + +Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон +(`only_status`, дефолт 'pending'). Возможность повтора существовала и была +задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026) +её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине, +которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в +'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026. + +Что проверяем — по существу, а не по настройке: + * дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами + (тот же вызов, что делает расписание) — на старом коде он не попадает; + * pending при этом из пакета НЕ вытесняется; + * дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а + ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его; + * дом без пригодных параметров помечается no_params БЕЗ запроса к площадке. + +Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие +строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml +поднимает Postgres и накатывает схему). +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import house_imv_backfill as hib + +# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py). +_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь +_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём +_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью +_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса +_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS) + + +def _live_session() -> Any | None: + """Session к достижимому не-заглушечному Postgres, иначе None.""" + try: + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _seed(db: Any) -> None: + from sqlalchemy import text as _t + + # url/source/ext_house_id — NOT NULL в houses (002_core_tables). + db.execute( + _t( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, " + " imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES " + "(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, " + " 'transient_error', 0, NOW() - INTERVAL '10 days')," + "(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, " + " 'transient_error', :cap, NOW() - INTERVAL '10 days')," + "(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, " + " 'pending', 0, NULL)," + "(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, " + " 'pending', 0, NULL)" + ), + { + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + "n": _H_NO_PARAMS, + # Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана + # работать и на СТАРОМ коде, иначе красный прогон получится про + # отсутствующий атрибут, а не про застрявший дом. Синхронность + # литерала и константы стережёт test_transient_attempts_counter_*. + "cap": 3, + }, + ) + # Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS. + # source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE. + db.execute( + _t( + "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, " + " house_id_fk, rooms, area_m2, floor, total_floors, house_type) " + "VALUES " + "(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, " + " 2, 52.0, 3, 9, 'brick')," + "(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, " + " 2, 52.0, 3, 9, 'brick')," + "(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, " + " 2, 52.0, 3, 9, 'brick')" + ), + { + "l1": 910601, + "l2": 910602, + "l3": 910603, + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + }, + ) + db.commit() + + +def _cleanup(db: Any) -> None: + from sqlalchemy import text as _t + + db.rollback() + db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)")) + db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)}) + db.commit() + db.close() + + +async def _run(db: Any, **kwargs: Any) -> Any: + """Прогнать backfill с замоканным вызовом площадки. + + Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден», + поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен — + проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать + сайдкар. + """ + err = hib.IMVAddressNotFoundError("address not found (test)") + with ( + patch.object(hib.settings, "avito_imv_use_browser_fetcher", False), + patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)), + ): + return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None: + """Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом. + + На старом коде выборка — `WHERE imv_status = :status` с единственным + значением 'pending', поэтому дом остаётся в 'transient_error' навсегда. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + # Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию. + result = await _run(db, batch_size=50) + + picked = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert picked[_H_STUCK] != "transient_error", ( + "дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет " + f"обычного прогона; фактически статус {picked[_H_STUCK]!r} " + f"(в пакет прогона попало домов: {result.checked})" + ) + # pending не вытеснен повтором. + assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете" + # Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл. + assert picked[_H_EXHAUSTED] == "transient_error" + assert result.retried == 1 + # Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен. + assert picked[_H_NO_PARAMS] == "no_params" + assert result.premarked == 1 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_explicit_only_status_still_takes_exhausted_houses() -> None: + """Явный only_status — ручной запрос оператора: лимит попыток его не режет.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + result = await _run(db, batch_size=50, only_status="transient_error") + + statuses = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert ( + statuses[_H_EXHAUSTED] != "transient_error" + ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + # Автоповтора поверх явного запроса нет: pending не тронут. + assert statuses[_H_PENDING] == "pending" + assert result.retried == 0 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_transient_attempts_counter_only_counts_transient() -> None: + """Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем. + + Без верхней границы у повтора не было бы выхода; без обнуления успехом дом, + однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован" + + def _attempts() -> int: + return int( + db.execute( + _t("SELECT imv_transient_attempts FROM houses WHERE id = :i"), + {"i": _H_STUCK}, + ).scalar() + ) + + hib._mark_status(db, _H_STUCK, "transient_error", "boom") + assert _attempts() == 1 + hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден") + assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают" + db.execute( + _t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"), + {"i": _H_STUCK}, + ) + db.commit() + assert _attempts() == 0 + finally: + _cleanup(db) + + +def test_retry_queue_is_not_bound_to_only_status() -> None: + """Статика (бежит и без БД): retry-выборка не зависит от параметра only_status. + + Ровно этот дефект и застрял на проде: единственный путь к 'transient_error' + шёл через значение, которое никто не передавал. + """ + retry_sql = str(hib._RETRY_QUEUE_SQL.text) + assert "imv_status = 'transient_error'" in retry_sql + assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status" + assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход" + # psycopg v3: только CAST(:x AS type), никогда :x::type. + for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)): + assert "::" not in sql diff --git a/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py new file mode 100644 index 00000000..6a8ce9b3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py @@ -0,0 +1,79 @@ +"""Static guards for migration 257 (#2838): лечение yandex source_url. + +Живой БД у юнит-теста нет, поэтому проверяем то, что ломается тихо и навсегда: + + 1. Форма адреса не разъехалась с продюсером. В файле стоит ДОСЛОВНО + `CANONICAL_URL_SQL` из app/tasks/yandex_detail_backfill.py, а тот уже сверен с + `_canonical_source_url` продюсера в test_yandex_detail_backfill.py. Сцепление + транзитивное: поменяют форму у продюсера — красным станет тот тест, поменяют в + миграции — этот. + 2. Отбор — теми же константами, что у очереди (#2838), а не «похожими» regexp'ами. + 3. Бэкап прежних значений пишется ДО UPDATE. Переставь местами — таблица отката + наполнится уже канонизированными адресами, и откат станет no-op'ом, который + выглядит рабочим. + 4. Недеструктивность и транзакционность: одна колонка, BEGIN/COMMIT, lock_timeout. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from app.tasks.yandex_detail_backfill import ( + CANONICAL_URL_SQL, + OFFER_ID_PATTERN, + OFFER_URL_PATTERN, +) + +_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "data" + / "sql" + / "257_listings_backfill_yandex_source_url.sql" +) + + +def _executable_sql() -> str: + """SQL без `--`-комментариев: шапка объясняет прозой и упоминает всё подряд.""" + lines = [raw.split("--", 1)[0] for raw in _MIGRATION.read_text(encoding="utf-8").splitlines()] + return "\n".join(line for line in lines if line.strip()) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def test_migration_exists() -> None: + assert _MIGRATION.exists(), f"missing migration: {_MIGRATION}" + + +def test_canonical_url_matches_producer_formula() -> None: + """Выражение UPDATE — тот же литерал, что CANONICAL_URL_SQL (и, значит, продюсер).""" + assert _flat(CANONICAL_URL_SQL) in _flat(_executable_sql()) + + +def test_selects_rows_by_the_same_constants_as_the_queue() -> None: + """Отбор — константами очереди #2838, иначе миграция чинит не то множество.""" + sql = _executable_sql() + assert f"'{OFFER_URL_PATTERN}'" in sql + assert f"'{OFFER_ID_PATTERN}'" in sql + assert "source = 'yandex'" in sql + + +def test_backup_is_written_before_the_update() -> None: + sql = _executable_sql() + assert sql.index("INSERT INTO yandex_source_url_backfill_257") < sql.index("UPDATE listings") + + +def test_transactional_and_lock_timeout_after_begin() -> None: + sql = _executable_sql() + assert "BEGIN;" in sql and "COMMIT;" in sql + assert sql.index("BEGIN;") < sql.index("SET LOCAL lock_timeout") + assert sql.index("SET LOCAL lock_timeout") < sql.index("UPDATE listings") + + +def test_touches_only_source_url_and_deletes_nothing() -> None: + sql = _executable_sql().upper() + for forbidden in ("DELETE", "DROP", "TRUNCATE", "IS_ACTIVE"): + assert forbidden not in sql, forbidden + assert sql.count("SET SOURCE_URL") == 1 diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py index 726d991e..fafe6f41 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -1,13 +1,20 @@ -"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1. +"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846). Покрывает: - 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): - - fresh: age <= max_age_days (алерта нет); - - stale: age > max_age_days (алерт); - - граница порога (== max_age_days → нет алерта; +1 день → алерт). - 2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag). - 3. Свойства миграции 180 (по образцу test_deals_freshness_monitor). - 4. Регистрацию в kit product_handlers (registry остаётся зелёным). + 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте / + загрузка встала / полных прогонов не было вовсе / граница порога. + 2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность: + при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой. + 3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы + отставшее табло). + 4. Свойства миграций 180/212 + регистрацию в kit product_handlers. + +Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index): + latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток), + dinamika-tsen-obyavlenii = 2026-05-01 (103); + последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639); + такт загрузки interval_days = 7; + прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается. """ from __future__ import annotations @@ -22,7 +29,6 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import settings from app.services.product_handlers import build_product_handlers from app.tasks import sber_freshness_monitor as mon @@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql" _MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql" -# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12). -_MAY_2026 = date(2026, 5, 1) - - -# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── +# Прод-состояние 2026-08-12. +_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток +_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103 +_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639 +_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days +_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера def _now(y: int, m: int, d: int) -> datetime: return datetime(y, m, d, tzinfo=UTC) -def test_fresh_within_max_age() -> None: - """age=30 ≤ max_age_days=60 — свежий, алерта нет.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60) - assert v.stale is False - assert v.age_days == 30 - assert v.latest_period == _MAY_2026 +# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── -def test_stale_beyond_max_age() -> None: - """Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60) - assert v.stale is True +def test_loader_in_cadence_no_alert_even_at_age_72() -> None: + """Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим. + + После полного прогона наш max(period_month) равен максимуму источника ПО + ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера, + а не наше отставание. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) assert v.age_days == 72 + assert v.pull_lag_days == 6 + assert v.stale is False + + +def test_loader_stalled_alerts_at_the_same_age() -> None: + """Тот же возраст периода, но полный прогон 20 суток назад → тревога. + + 20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), + pull_interval_days=_PULL_INTERVAL, + ) + assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше + assert v.pull_lag_days == 20 + assert v.max_pull_lag_days == 14 + assert v.stale is True + + +def test_no_complete_pull_ever_alerts() -> None: + """Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=None, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.stale is True + assert v.pull_lag_days == -1 def test_threshold_boundary_exact_no_alert() -> None: - """Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >).""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60) - assert v.age_days == 60 + """Ровно на пороге (2 такта) алерта ещё нет — строгое >.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 14 assert v.stale is False -def test_threshold_boundary_next_day_alert() -> None: - """Порог + 1 день (age=61) — первый алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60) - assert v.age_days == 61 +def test_threshold_boundary_next_day_alerts() -> None: + """Порог + 1 сутки — первый алерт (два такта подряд пропущены).""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 15 assert v.stale is True +def test_threshold_follows_pull_cadence() -> None: + """Порог — производная такта загрузки, а не константа: такт 28 → порог 56.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), # 20 суток + pull_interval_days=28, + ) + assert v.max_pull_lag_days == 56 + assert v.stale is False # при месячном такте 20 суток — норма + + # ── check_sber_freshness (FakeDB) ───────────────────────────────────────────── class _Row: - def __init__(self, latest: date | None) -> None: - self.latest = latest + def __init__(self, **kw: Any) -> None: + self.__dict__.update(kw) class _FakeResult: - def __init__(self, latest: date | None) -> None: - self._latest = latest + def __init__(self, row: _Row | None) -> None: + self._row = row - def first(self) -> _Row: - return _Row(self._latest) + def first(self) -> _Row | None: + return self._row class _FakeDB: - def __init__(self, latest: date | None) -> None: - self._latest = latest + """Отвечает на три запроса монитора; пригоден и для старой версии кода. + + Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой + запрос отдаём максимум по всем табло, ровно как это делал бы Postgres. + """ + + def __init__( + self, + latest_by_dash: dict[str, date], + last_pull: datetime | None = _LAST_FULL_PULL, + interval_days: str | None = str(_PULL_INTERVAL), + ) -> None: + self._latest_by_dash = latest_by_dash + self._last_pull = last_pull + self._interval_days = interval_days self.rolled_back = False + self.asked_dashboards: list[str] = [] def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - return _FakeResult(self._latest) + sql = str(stmt) + params = params or {} + if "scrape_runs" in sql: + return _FakeResult(_Row(last_pull=self._last_pull)) + if "scrape_schedules" in sql: + return _FakeResult(_Row(interval_days=self._interval_days)) + if "dash" in params: + self.asked_dashboards.append(params["dash"]) + return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"]))) + # Старый монитор: max(period_month) по всей таблице. + latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None + return _FakeResult(_Row(latest=latest)) def rollback(self) -> None: self.rolled_back = True @@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: return calls -def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-05-31: age=30 ≤ 35+25=60 → нет алерта. - out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type] - assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0} - assert calls["done"] == out - assert calls["failed"] is None +def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB: + """Прод-состояние 2026-08-12 (оба табло вторички).""" + return _FakeDB( + {"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026}, + last_pull=last_pull, + ) -def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prod_replay_healthy_loader_silent_source_no_alert( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина. + + На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки + подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации + Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же. + """ calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-07-12 (прод): age=72 > 60 → алерт. - out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] - assert out["alert"] == 1 + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["age_days"] == 72 - assert out["latest_month"] == 5 - # Монитор НЕ падает при алерте — прогон done, а не failed. + assert out["alert"] == 0 assert calls["done"] == out assert calls["failed"] is None -def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: +def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None: + """Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой. + + На main оба состояния дают alert=1 (вердикт зависит только от календаря) — + сторож не умеет зеленеть, что и было исходным дефектом. + """ + _patch_runs(monkeypatch) + healthy = mon.check_sber_freshness( + _prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type] + run_id=2, + params={}, + now=_PROD_NOW, + ) + stalled = mon.check_sber_freshness( + _prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type] + run_id=3, + params={}, + now=_PROD_NOW, + ) + assert healthy["age_days"] == stalled["age_days"] == 72 + assert (healthy["alert"], stalled["alert"]) == (0, 1) + + +def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит. + + Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт — + SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь + ровно то состояние, что дал бы прод с одним лишь id=37. + """ + _patch_runs(monkeypatch) + out = mon.check_sber_freshness( + _prod_db(last_pull=None), # type: ignore[arg-type] + run_id=4, + params={}, + now=_PROD_NOW, + ) + assert out["alert"] == 1 + assert out["pull_lag_days"] == -1 + + +def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None: + """Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице).""" + _patch_runs(monkeypatch) + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert db.asked_dashboards[0] == "real_estate_deals" + assert (out["latest_year"], out["latest_month"]) == (2026, 6) + + +def test_falls_back_to_next_dashboard_when_first_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Первое табло пусто → берём следующее, как и оценщик.""" + _patch_runs(monkeypatch) + db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026}) + out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["latest_month"] == 5 + assert out["age_days"] == 103 + + +def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одного табло с данными — оценивать нечего, это сбой монитора.""" calls = _patch_runs(monkeypatch) - db = _FakeDB(None) - out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] + out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["alert"] == 0 assert calls["done"] is None assert calls["failed"] is not None -def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None: +def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + """Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь. + + Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180). + С любым её значением вердикт один и тот же — порог берётся из такта загрузки. + """ _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено. - out = mon.check_sber_freshness( - db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12) - ) # type: ignore[arg-type] - assert out["alert"] == 1 + base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type] + tweaked = mon.check_sber_freshness( + _prod_db(), # type: ignore[arg-type] + run_id=9, + params={"lag_allowance_days": 0}, + now=_PROD_NOW, + ) + assert base["alert"] == tweaked["alert"] == 0 -def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None: - """Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS.""" +def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None: + """Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет.""" _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS - # Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт. - exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold) - out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type] - assert out_exact["age_days"] == threshold - assert out_exact["alert"] == 0 - out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type] - assert out_over["alert"] == 1 + db = _FakeDB( + {"real_estate_deals": _JUN_2026}, + last_pull=_now(2026, 7, 23), # 20 суток + interval_days="28", + ) + out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == 56 + assert out["alert"] == 0 + + +def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем.""" + _patch_runs(monkeypatch) + db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None) + out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS # ── Миграция 180 ────────────────────────────────────────────────────────────── @@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None: assert re.search(r"\b10\b", sql), "window_end_hour 10 missing" -def test_migration_180_lag_allowance_25() -> None: - sql = _MIGRATION_180.read_text("utf-8") - assert "lag_allowance_days" in sql - assert "25" in sql - - def test_migration_180_no_psycopg_trap() -> None: sql = _MIGRATION_180.read_text("utf-8") assert not re.search(r":\w+::", sql) -# ── Миграция 212: такт загрузки не должен пересекать порог монитора ─────────── +# ── Миграция 212: такт загрузки = источник порога ───────────────────────────── # -# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74 -# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника. -# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора. +# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят +# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при +# полном прогоне шестидневной давности, потолок 53 держался бы только если бы +# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода +# не должен расходиться с тактом, который сеет миграция. def test_migration_212_makes_pull_cadence_weekly() -> None: @@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None: assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type) -def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None: - """Инвариант: пол возраста + такт загрузки < порога монитора. - - Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог = - sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас - 7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали. - """ +def test_default_pull_interval_matches_migration_212() -> None: + """Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой.""" interval_days = int( - re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) - ) - observed_floor_days = 46 - threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS - assert observed_floor_days + interval_days < threshold, ( - f"такт {interval_days}д даёт потолок возраста " - f"{observed_floor_days + interval_days}д при пороге {threshold}д — " - "монитор снова будет мерить наш такт, а не застой источника" + re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr] ) + assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days + + +# ── Один порог, а не два ────────────────────────────────────────────────────── + + +def test_no_second_calendar_threshold_in_settings() -> None: + """#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться.""" + from app.core.config import settings + + assert not hasattr(settings, "sber_index_max_age_days") # ── Регистрация в kit registry ───────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py index 44d6ac26..a21a9509 100644 --- a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -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() diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index 21d6f605..c6196e84 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -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 дней В продаже" ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 308d4cb1..4b90d5ef 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation_save.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation_save.py @@ -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]): diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts index e441325a..ec10ab75 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -175,6 +175,10 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = { median_ppm2: 162_000, high_ppm2: 178_000, period_months: 24, + // #2846: свежайшая сделка ВЫБОРКИ. Совпадает с прод-максимумом на 2026-08-12 + // (deals.max(deal_date) = 2026-01-01) — оффлайн-превью должно показывать + // ту же подпись «по I кв. 2026», что и живая страница. + latest_deal_date: "2026-01-01", }, price_trend: [ { month: "2025-11", ppm2: 168_000 }, diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 1b89abe9..652bd91e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -11,6 +11,7 @@ import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, import { asHouseType, asRepairState } from "@/types/trade-in"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { sourceDot, sourceLabel } from "@/lib/source-registry"; +import { dealsAsOfLabel } from "@/lib/rosreestr"; import { HeroTransparency } from "./HeroTransparency"; interface Props { @@ -175,6 +176,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0; const showDkp = !!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0; + // #2846: возраст ОТОБРАННЫХ сделок. Раньше здесь стояло «за {period_months} + // мес» — окно ПОИСКА, выданное за период данных: «12 мес» читалось как «сделки + // по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026. + // null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает. + const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter"); // Progressive enrichment state const needsHouseType = estimate.house_type === null; @@ -547,6 +553,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд. + {dkpAsOf ? ` · ${dkpAsOf}` : ""} )} @@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
- {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП за{" "} - {dkp.period_months} мес + {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП + {dkpAsOf ? ` · ${dkpAsOf}` : ""}
)} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts new file mode 100644 index 00000000..9b8f6480 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts @@ -0,0 +1,175 @@ +// #2846 — плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» обязана назвать возраст. +// +// До правки автор mappers.ts честно писал: «No "ЗА N МЕСЯЦЕВ" — we have no honest +// period to show», потому что единственным кандидатом был period_months — ОКНО +// ПОИСКА. Он был прав про поле и неправ про молчание: слово «ФАКТИЧЕСКИЕ» +// читается как «недавние», а свежайшая пачка Росреестра на проде (замер +// 2026-08-12) — I кв. 2026, 223 дня от её начала. +// +// Плитку кормят ТРИ РАЗНЫЕ выборки (street-deals → dkp_corridor → actual_deals), +// поэтому здесь проверяется каждая: подпись обязана описывать тот набор, чьи +// числа сейчас на экране, а не тот, который удобнее. + +import { describe, expect, it } from "vitest"; + +import { mapResultPanel } from "../mappers"; +import { dealsAsOfLabel } from "@/lib/rosreestr"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { + AggregatedEstimate, + AnalogLot, + StreetDealsResponse, +} from "@/types/trade-in"; + +/** ppm-строка плитки ДКП — там же, где «₽/м² · N сделок». */ +function dkpPpm( + e: AggregatedEstimate, + sd: StreetDealsResponse | null = null, +): string { + const card = mapResultPanel(e, sd).cards.find((c) => + c.title.join(" ").includes("ДКП"), + ); + if (!card) throw new Error("плитка ДКП пропала из панели результата"); + return card.ppm; +} + +function deal(over: Partial): AnalogLot { + return { + address: "ул. Гоголя, 18", + area_m2: 60, + rooms: 2, + floor: 4, + total_floors: 9, + price_rub: 12_000_000, + price_per_m2: 200_000, + listing_date: "2026-01-01", + days_on_market: null, + photo_url: null, + source: "rosreestr", + source_url: null, + distance_m: null, + tier: "T1_per_street", + date_precision: "quarter", + lat: null, + lon: null, + ...over, + }; +} + +function streetDeals(deals: AnalogLot[]): StreetDealsResponse { + return { + street: "Гоголя", + period_from: "2025-08-12", + period_to: "2026-08-12", + count: deals.length, + median_price_rub: 12_000_000, + median_price_per_m2: 200_000, + range_low_rub: 10_000_000, + range_high_rub: 14_000_000, + deals, + }; +} + +// Ветка 2: только коридор, без street-deals и без actual_deals. +const CORRIDOR_ONLY: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + actual_deals: [], + area_m2: 60, + dkp_corridor: { + count: 9, + low_ppm2: 150_000, + median_ppm2: 162_000, + high_ppm2: 178_000, + period_months: 24, + latest_deal_date: "2026-01-01", + }, +}; + +describe("#2846 возраст выборки ДКП на плитке", () => { + it("коридор: возраст выборки доезжает до ppm-строки", () => { + // Красный на origin/main: latest_deal_date некуда положить и некому прочесть. + expect(dkpPpm(CORRIDOR_ONLY)).toContain("по I кв. 2026"); + }); + + it("подпись называет ВЕРХНЮЮ границу свежести, а не окно поиска", () => { + // period_months = 24 у той же карточки. «за 24 месяца» при сделках января — + // ровно та подмена, из-за которой подписи не было вовсе. + const ppm = dkpPpm(CORRIDOR_ONLY); + expect(ppm).not.toContain("24"); + expect(ppm).not.toContain("мес"); + }); + + it("street-deals: подпись берётся у выборки, которая реально на экране", () => { + // Эта ветка приоритетнее коридора в resolveDealTier. Коридор тут заявляет + // I кв. 2026 — если бы подпись читалась из него, она омолодила бы чужие числа. + const ppm = dkpPpm( + CORRIDOR_ONLY, + streetDeals([ + deal({ listing_date: "2025-10-01" }), + deal({ listing_date: "2025-07-01" }), + ]), + ); + expect(ppm).toContain("по IV кв. 2025"); + expect(ppm).not.toContain("2026"); + }); + + it("actual_deals: третья ветка тоже датирована", () => { + const ppm = dkpPpm({ + ...FIXTURE_ESTIMATE, + dkp_corridor: null, + actual_deals: [ + deal({ listing_date: "2025-04-01" }), + deal({ listing_date: "2025-07-01" }), + ], + }); + expect(ppm).toContain("по III кв. 2025"); + }); + + it("сделок нет вовсе — подписи нет, а не «по …» под прочерком", () => { + const ppm = dkpPpm({ + ...FIXTURE_ESTIMATE, + dkp_corridor: null, + actual_deals: [], + }); + expect(ppm).toBe("—"); + }); + + it("оценка старше деплоя бэкенда (поля нет) — молчим, как раньше", () => { + const legacy = { ...CORRIDOR_ONLY.dkp_corridor! }; + delete (legacy as { latest_deal_date?: string | null }).latest_deal_date; + const ppm = dkpPpm({ ...CORRIDOR_ONLY, dkp_corridor: legacy }); + expect(ppm).toContain("сделок"); + expect(ppm).not.toContain("кв."); + }); +}); + +describe("#2846 форма подписи", () => { + it("все 9 живых deal_date прода → римский квартал", () => { + // Не выдумано: ровно `SELECT DISTINCT deal_date FROM deals` на 2026-08-12. + expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026"); + expect(dealsAsOfLabel("2025-10-01")).toBe("по IV кв. 2025"); + expect(dealsAsOfLabel("2025-07-01")).toBe("по III кв. 2025"); + expect(dealsAsOfLabel("2025-04-01")).toBe("по II кв. 2025"); + expect(dealsAsOfLabel("2025-01-01")).toBe("по I кв. 2025"); + expect(dealsAsOfLabel("2024-10-01")).toBe("по IV кв. 2024"); + expect(dealsAsOfLabel("2024-07-01")).toBe("по III кв. 2024"); + expect(dealsAsOfLabel("2024-04-01")).toBe("по II кв. 2024"); + expect(dealsAsOfLabel("2024-01-01")).toBe("по I кв. 2024"); + }); + + it("date-only ISO не уезжает на предыдущий квартал в западных зонах", () => { + // "2026-01-01" — UTC-полночь; локальные геттеры дали бы 31.12.2025 → IV кв. + expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026"); + expect(dealsAsOfLabel("2026-04-01")).toBe("по II кв. 2026"); + }); + + it("источник с настоящей датой подписывается месяцем, а не кварталом", () => { + expect(dealsAsOfLabel("2026-05-12", "day")).toBe("по 05.2026"); + }); + + it("датировать нечего — null, а не заглушка", () => { + expect(dealsAsOfLabel(null)).toBeNull(); + expect(dealsAsOfLabel(undefined)).toBeNull(); + expect(dealsAsOfLabel("не дата")).toBeNull(); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 2af10439..777918d1 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -76,6 +76,7 @@ import type { } from "./types"; import { tokens } from "./tokens"; import { sourceLabel as registrySourceLabel } from "@/lib/source-registry"; +import { dealsAsOfLabel } from "@/lib/rosreestr"; // Total number of source slots — meta "N / M". Single source of truth is // SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES). @@ -599,12 +600,27 @@ interface DealTier { medianPpm: number; count: number; bars: number[]; + /** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */ + asOf: string | null; +} + +/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */ +function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] { + let best: AnalogLot | null = null; + for (const l of lots) { + if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l; + } + return [best?.listing_date ?? null, best?.date_precision ?? null]; } /** * Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with * real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's * own actual_deals. null when no deal data at all. + * + * #2846: каждая ветка тянет СВОЙ as-of — три ветки это три РАЗНЫЕ выборки + * (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до + * города), и подпись обязана описывать ту, чьи числа сейчас на экране. */ function resolveDealTier( e: AggregatedEstimate, @@ -621,6 +637,9 @@ function resolveDealTier( // same way the ads card does — otherwise a single mis-scraped lot bins // over [min,max] and crushes the real deals into the left buckets. bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean), + // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам, + // так что максимум по ним = максимум по всей выборке, не по показанным. + asOf: dealsAsOfLabel(...newestLot(sd.deals)), }; } const area = e.area_m2; @@ -636,6 +655,9 @@ function resolveDealTier( bars: bins8( guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean, ), + // Не из actual_deals: гистограмма заимствует их только как форму, а + // числа карточки (медиана/границы) — из коридора, у которого своя выборка. + asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), }; } if (e.actual_deals.length > 0) { @@ -652,6 +674,7 @@ function resolveDealTier( count: e.actual_deals.length, // Fix #1/#8: same high-outlier guard as the ads card (see above). bars: bins8(guardPriceOutliers(ppms).clean), + asOf: dealsAsOfLabel(...newestLot(e.actual_deals)), }; } return null; @@ -1135,11 +1158,26 @@ export function mapResultPanel( range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных", + // #2846: хвост «· по I кв. 2026» — возраст ОТОБРАННЫХ сделок. Слово + // «ФАКТИЧЕСКИЕ» в заголовке читается как «недавние», а свежайшая пачка + // Росреестра на проде — I кв. 2026; молчание тут обещало свежесть, + // которой нет. Отдельной строки не заводим: ppm-строка по контракту + // ResultCard уже носит хвост «· N сделок», третий факт про тот же набор + // встаёт в тот же ряд и читается одним взглядом. + // dealTier == null (сделок нет) → «—», подписи нет: без чисел датировать + // нечего, а «по I кв. 2026» под прочерком выглядело бы как скрытые данные. ppm: dealTier - ? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu( - dealTier.count, - ["сделка", "сделки", "сделок"], - )}` + ? [ + fmtPpm(dealTier.medianPpm), + `${dealTier.count} ${pluralRu(dealTier.count, [ + "сделка", + "сделки", + "сделок", + ])}`, + dealTier.asOf, + ] + .filter(Boolean) + .join(" · ") : "—", bars: dealTier ? dealTier.bars : [], nav: 1, diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts index af89b100..6c2aa52e 100644 --- a/tradein-mvp/frontend/src/lib/rosreestr.ts +++ b/tradein-mvp/frontend/src/lib/rosreestr.ts @@ -1,6 +1,41 @@ export const ROSREESTR_REQUEST_URL = "https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; +const ROMAN_QUARTER = ["I", "II", "III", "IV"] as const; + +/** + * #2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ ДКП-сделки. + * + * Гранулярность — квартал, и это не округление ради красоты: Росреестр + * публикует ДКП пачками, deal_date = первый день квартала (бэкенд помечает это + * `date_precision: "quarter"`, #1995). Прод-замер 2026-08-12: 96 974 сделки, + * 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно {01,04,07,10}. + * Поэтому «223 дня назад» было бы ложной точностью в сторону состаривания: + * сделка из пачки «2026-01-01» могла случиться и 31 марта. «по I кв. 2026» — + * ровно то, что известно, и ни бита сверх. + * + * «по» (а не «за») намеренно: выборка обычно накрывает несколько кварталов, + * и подпись называет ВЕРХНЮЮ границу свежести, а не период целиком. + * + * @param iso ISO-дата свежайшей сделки ВЫБОРКИ (не всей таблицы). + * @param precision 'day' у источника с настоящей датой; иначе квартал. + * @returns null, если датировать нечего — подпись тогда не рисуется вовсе. + */ +export function dealsAsOfLabel( + iso: string | null | undefined, + precision?: "day" | "quarter" | null, +): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + // ISO date-only ("2026-01-01") парсится как UTC-полночь — читаем UTC-поля, + // иначе в отрицательных зонах квартал уезжает на предыдущий. + if (precision === "day") { + return `по ${String(d.getUTCMonth() + 1).padStart(2, "0")}.${d.getUTCFullYear()}`; + } + return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`; +} + export async function openRosreestrWithAddress(address: string): Promise { try { await navigator.clipboard.writeText(address); diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 38ecd503..c136e129 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -110,6 +110,10 @@ export interface AnalogLot { // 'T0_per_house' — kadastr_num exact match (currently not available in open dataset) // 'T1_per_street' — street-level only (default for all rosreestr deals) tier?: string | null; + // #1995 (бэкенд уже слал, фронт игнорировал; поднято #2846): честная точность + // listing_date. 'quarter' — rosreestr: дата = первый день КВАРТАЛА, а не день + // сделки. 'day' — объявления. undefined у старых оценок и когда source неизвестен. + date_precision?: "day" | "quarter" | null; // ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional + // nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем // пины с null lat/lon. @@ -153,7 +157,12 @@ export interface DkpCorridor { low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) median_ppm2: number; // медиана ₽/м² high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) - period_months: number; // окно поиска сделок + period_months: number; // окно ПОИСКА сделок — НЕ возраст данных + // #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность + // — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому + // «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки, + // посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется. + latest_deal_date?: string | null; } export interface AggregatedEstimate { diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 034de30f..e5421aec 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -61,7 +61,22 @@ _PROBE_URLS: dict[str, str] = { "avito": "https://www.avito.ru/robots.txt", "cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url "yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL - "domclick": "https://bff-search-web.domclick.ru/robots.txt", + # #2855: боевой ЗАЩИЩЁННЫЙ путь, а не robots.txt того же хоста. QRATOR закрывает + # /api/offers/*, а robots.txt отдаётся свободно — проба по нему возвращала 200 ровно + # тогда, когда свип с того же узла получал блок-страницу. Замер 13.08: за сутки + # 64 проверки пар и 5 банов на ВСЕ площадки, при этом свип Домклика блокировался + # каждые сутки; 04:30 проба «pair_banned=0» → 05:02 свип «QRATOR block». + # Взят count-эндпоинт: та же семья путей, что у работы (providers/domclick/serp.py + # ::_build_count_url), но ответ — одно число, без пагинации и без выдачи, то есть + # проба остаётся дешёвой для площадки (тот же довод, по которому здесь /fetch, а + # не /fetch-json). Параметры продублированы намеренно: импорт провайдера сюда дал + # бы цикл (providers/* уже импортируют browser_fetcher). + "domclick": ( + "https://bff-search-web.domclick.ru/api/offers/count/v1" + "?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561" + "&deal_type=sale&category=living&offer_type=flat&rooms=st" + "&sort=qi&sort_dir=desc" + ), } _PROXY_PROBE_URL: str = _PROBE_URLS["avito"] # Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool @@ -75,6 +90,11 @@ PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS) # странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали # отказ с кодом 200». _PROBE_CONTENT_MARKER: str = "User-agent" +# #2855: источник, чья проба идёт НЕ по robots.txt, требует своего признака «ресурс +# отдан». У count-эндпоинта Домклика это ключ ответа `snippetsCount` — он есть в живом +# JSON и отсутствует в блок-странице QRATOR. Держим отдельной картой, а не условием в +# теле: добавить площадку с боевым путём должно быть одной строкой, а не веткой. +_PROBE_CONTENT_MARKERS: dict[str, str] = {"domclick": "snippetsCount"} # source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес: # сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с # source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠ @@ -220,14 +240,16 @@ async def probe_proxy_via_browser( html = "" if not html: return False, classify_browser_probe(resp.status_code, detail), "empty html" - if _PROBE_CONTENT_MARKER not in html: - # 200 и непустое тело, но это не robots.txt — площадка отдала заглушку - # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»). + marker = _PROBE_CONTENT_MARKERS.get(source, _PROBE_CONTENT_MARKER) + if marker not in html: + # 200 и непустое тело, но это не запрошенный ресурс — площадка отдала заглушку + # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»; + # 13.08: Домклик отдаёт блок-страницу QRATOR на /api/offers/*, #2855). # Тракт узла исправен, негодна пара — отсюда "page", а не "proxy". return ( False, "page", - f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}", + f"no marker {marker!r} (html_len={len(html)}): {' '.join(html.split())[:120]}", ) return True, None, f"html_len={len(html)}" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 5c090d75..b3d462d5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -2953,6 +2953,11 @@ class CianFullLoadCounters: detail_enriched: int = 0 detail_failed: int = 0 errors_count: int = 0 + # Бакеты, отданные SERP-слоем как НЕполные (страница выпала / исключение + # проглочено / hard-cap): лоты сохранены, но в done_buckets бакет не попал. + # Без этого счётчика «сколько бакетов чекпоинт не покрывает» видно только грепом + # логов, которые теряются при редеплое. + partial_buckets: int = 0 def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} @@ -3007,8 +3012,32 @@ async def run_cian_full_load( done: set[str] = set(skip_set) # накапливаем завершённые бакеты этого прогона - def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] - """Инкрементальный save после каждого leaf-бакета. Дописывает bucket_key в done.""" + def _mark_bucket(bucket_key: str, complete: bool) -> None: + """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем. + + Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета, + поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск + частичного бакета на следующем прогоне означал бы, что его непрочитанные + страницы не перечитает уже никто, а счётчики покажут успех. + """ + if complete: + done.add(bucket_key) + return + counters.partial_buckets += 1 + logger.warning( + "cian-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, " + "следующий прогон перечитает его целиком (partial_buckets=%d)", + run_id, + bucket_key, + counters.partial_buckets, + ) + + def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + """Инкрементальный save после каждого leaf-бакета. + + complete=False → лоты сохраняем, бакет в чекпоинт не пишем + (см. _mark_bucket). Дефолт True — для вызывающих без пагинации. + """ nonlocal done if runs.is_cancelled(db, run_id): logger.info("cian-full-load run_id=%d: cancel detected in on_bucket", run_id) @@ -3023,7 +3052,7 @@ async def run_cian_full_load( ) raise RuntimeError("shutdown") if not lots: - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat( db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} ) @@ -3042,7 +3071,7 @@ async def run_cian_full_load( counters.saved_inserted += inserted counters.saved_updated += updated counters.unique_fetched += len(lots) - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) logger.info( "cian-full-load run_id=%d: bucket %s saved ins=%d upd=%d total_unique=%d", @@ -3225,7 +3254,8 @@ async def run_cian_full_load( except NoProxyAvailableError as exc: # #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан - # сохранять done_buckets, а generic-RuntimeError → mark_failed его теряет. + # сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который + # его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930). logger.error("cian-full-load run_id=%d: no proxy available — %s", run_id, exc) counters.errors_count += 1 runs.mark_banned( @@ -3467,7 +3497,8 @@ async def run_yandex_full_load( except NoProxyAvailableError as exc: # #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан - # сохранять done_buckets, а generic-RuntimeError → mark_failed его теряет. + # сохранять done_buckets, а generic-RuntimeError уходил в mark_failed, который + # его терял (теперь counters мержатся — runs.update_heartbeat/mark_*, #930). logger.error("yandex-full-load run_id=%d: no proxy available — %s", run_id, exc) counters.errors_count += 1 runs.mark_banned( @@ -3527,6 +3558,8 @@ class AvitoFullLoadCounters: saved_inserted: int = 0 saved_updated: int = 0 errors_count: int = 0 + # Бакеты, отданные SERP-слоем как НЕполные — см. CianFullLoadCounters. + partial_buckets: int = 0 def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} @@ -3599,8 +3632,32 @@ async def run_avito_full_load( ) done: set[str] = set(skip_set) - def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] - """Инкрементальный save после каждого leaf-бакета.""" + def _mark_bucket(bucket_key: str, complete: bool) -> None: + """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем. + + Ключ done_buckets ("room:lo:hi") одинаков у целого и у недособранного бакета, + поэтому признак полноты приезжает отдельным аргументом из SERP-слоя. Пропуск + частичного бакета на следующем прогоне означал бы, что его непрочитанные + страницы не перечитает уже никто, а счётчики покажут успех. + """ + if complete: + done.add(bucket_key) + return + counters.partial_buckets += 1 + logger.warning( + "avito-full-load run_id=%d: bucket=%s собран ЧАСТИЧНО — в чекпоинт НЕ пишем, " + "следующий прогон перечитает его целиком (partial_buckets=%d)", + run_id, + bucket_key, + counters.partial_buckets, + ) + + def _on_bucket(bucket_key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + """Инкрементальный save после каждого leaf-бакета. + + complete=False → лоты сохраняем, бакет в чекпоинт не пишем + (см. _mark_bucket). Дефолт True — для вызывающих без пагинации. + """ nonlocal done if runs.is_cancelled(db, run_id): logger.info("avito-full-load run_id=%d: cancel detected in on_bucket", run_id) @@ -3613,7 +3670,7 @@ async def run_avito_full_load( ) raise RuntimeError("shutdown") if not lots: - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat( db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} ) @@ -3632,7 +3689,7 @@ async def run_avito_full_load( counters.saved_inserted += inserted counters.saved_updated += updated counters.unique_fetched += len(lots) - done.add(bucket_key) + _mark_bucket(bucket_key, complete) runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) logger.info( "avito-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d", @@ -3699,9 +3756,10 @@ async def run_avito_full_load( except NoProxyAvailableError as exc: # #2687: пул опустел mid-run. Ветка стоит ДО generic-RuntimeError намеренно — # NoProxyAvailableError его подкласс, и без неё отказ уходил в mark_failed, - # который (в отличие от mark_banned) НЕ пишет done_buckets. То есть чекпоинт + # который (в отличие от mark_banned) НЕ передавал done_buckets. То есть чекпоинт # терялся ровно на НАШЕМ отказе — том исходе, для которого #2686 требовал его - # сохранять наравне с блокировкой площадкой. + # сохранять наравне с блокировкой площадкой. Диагноз ban_kind='infra' ветка + # даёт по-прежнему; сам чекпоинт с #930-мержем counters переживает и mark_failed. logger.error("avito-full-load run_id=%d: no proxy available — %s", run_id, exc) counters.errors_count += 1 runs.mark_banned( @@ -3942,6 +4000,13 @@ async def run_domclick_city_sweep( f"QRATOR block aborted sweep — {counters.lots_fetched} listings " "collected before abort (#2657)", counters.to_dict(), + # #2687: эта ветка входится ТОЛЬКО при распознанном QRATOR-блоке + # (counters.blocked == 1, DomClickBlockedError — площадка показала + # block-страницу). Это ровно определение BAN_KIND_PLATFORM + # («площадка нас заблокировала»), а не 'unknown'. Раньше домкликовый + # свип не передавал ban_kind и все его баны падали в 'unknown', хотя + # диагноз здесь установлен, а не «не установлен» (#2764). + ban_kind=BAN_KIND_PLATFORM, ) elif 0 < counters.buckets_completed < counters.buckets_total: # #2670: прогон оборван на середине — прошёл часть комнатных бакетов и diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index c0996e1b..86729701 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -100,8 +100,8 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int: # РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый # статус» и «явное поле причины»: # 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed -# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив -# статус, пришлось бы дублировать её в каждом потребителе. +# его тогда терял) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; +# расщепив статус, пришлось бы дублировать её в каждом потребителе. # 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых # молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы, # IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results), @@ -570,12 +570,21 @@ def mark_skipped(db: Session, *, source: str, reason: str, details: str | None = return int(row.id) -def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None: - """UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки. +def update_heartbeat(db: Session, run_id: int, counters: dict[str, Any]) -> None: + """UPDATE heartbeat_at + counters (МЕРЖ, не замена) + total_seen/new_count колонки. total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926). COALESCE: если ключа нет в counters — старое значение колонки сохраняется. + + `counters || :counters` вместо замены (#930 добивка): чекпоинт `done_buckets` + писали ТОЛЬКО сайты, знающие о нём (`_on_bucket`), а heartbeat'ы, которые о нём не + знают, целиком перезаписывали объект и СТИРАЛИ точку. На проде это давало + немонотонную точку: `_on_progress` (после каждой комнатности) и фоновый heartbeat + cian'а (каждые 60 с) отправляли `counters.to_dict()` без ключа — то есть у cian + точка в БД жила лишь от сохранения бакета до ближайшего тика. Прод-след: 15 + оборванных прогонов с доказанной работой (35 706 + 9 222 fetched) и БЕЗ ключа + вообще. Мерж делает точку монотонной для любого писателя, а не только для знающих. """ total_seen, new_count = _column_counts(counters) db.execute( @@ -583,7 +592,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None """ UPDATE scrape_runs SET heartbeat_at = clock_timestamp(), - counters = CAST(:counters AS jsonb), + counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) WHERE id = :run_id @@ -641,7 +650,7 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: UPDATE scrape_runs SET status = 'done', finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), - counters = CAST(:counters AS jsonb), + counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) WHERE id = :run_id AND status = 'running' @@ -681,7 +690,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) UPDATE scrape_runs SET status = 'failed', finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), - error = :error, counters = CAST(:counters AS jsonb), + error = :error, + counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb), total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) WHERE id = :run_id AND status = 'running' @@ -715,8 +725,9 @@ def mark_banned( Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'. Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим - багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его - теряет). Cooldown 2-4 часа. + багом. Чекпоинт done_buckets раньше сохранял только этот финализатор — теперь + counters мержатся во всех (см. update_heartbeat), и точка переживает любой из них. + Cooldown 2-4 часа. `ban_kind` разводит два исхода, которые раньше схлопывались в один статус: - BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha); @@ -742,7 +753,8 @@ def mark_banned( UPDATE scrape_runs SET status = 'banned', finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), - error = :error, counters = CAST(:counters AS jsonb), + error = :error, + counters = COALESCE(counters, CAST('{}' AS jsonb)) || CAST(:counters AS jsonb), ban_kind = :ban_kind, total_seen = COALESCE(CAST(:total_seen AS int), total_seen), new_count = COALESCE(CAST(:new_count AS int), new_count) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 06d778a8..8b0b5009 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -497,6 +497,132 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) return run_id +# ── подхват чекпоинта оборванного прогона (#930, вторая половина) ──────────── +# #930 сделал чекпоинт (`counters.done_buckets`) и приёмную сторону +# (`run_*_full_load(resume_run_id=...)`), но единственным входом оставил админку. У +# avito full-load её нет вовсе, поэтому 299 из 433 «впустую перебранных» корзин за 90 +# суток не имели НИКАКОГО пути возобновления, даже ручного. Планировщик передавал +# resume_run_id=None литералом. +# +# Точка берётся только когда выполнены ВСЕ условия ниже; иначе прогон честно начинает с +# нуля, а ПРИЧИНА пишется в его counters (молчаливый отказ неотличим от отсутствия +# правки — см. _resume_decision). + +# 'zombie' НЕ в списке НАМЕРЕННО. reap_zombies снимает пометку 'running', но НЕ убивает +# процесс (прямо задокументировано в app/tasks/listing_source_snapshot.py) — а +# has_running_run гейтит claim именно по статусу. То есть после reap'а старый сборщик +# может продолжать писать в ту же строку: подхват читал бы ДВИЖУЩУЮСЯ точку и запускал +# второй сборщик на ту же площадку. 147 корзин в 10 zombie-прогонах за 90 суток +# остаются несобранными сознательно — это цена, а не недосмотр. +# 'failed' в списке: его чекпоинт больше не стирается финализатором (runs.py, мерж +# counters), а причина отказа («наш баг») ничего не говорит о полноте УЖЕ записанных +# корзин — они записаны тем же heartbeat'ом, что и у banned. +_RESUME_STATUSES = frozenset({"banned", "cancelled", "failed"}) + +# Длина цепочки возобновлений. Не круглое число: STALE_DIGEST_INTERVAL_FACTOR (=3) — +# уже существующий в этом файле порог «источник не собирал дольше 3× своего такта = +# сломан». Цепочка не имеет права отодвинуть полный обход дальше этой же черты, +# поэтому подряд идущих подхватов допускается на один меньше: прогоны 1 и 2 могут +# продолжать предшественника, третий обязан пойти с нуля. При такте avito 7 суток это +# гарантирует попытку полного обхода не реже, чем раз в 21 сутки — ровно в тот момент, +# когда сводка объявляет источник просроченным. +_MAX_RESUME_CHAIN = STALE_DIGEST_INTERVAL_FACTOR - 1 + +# Кандидат — ПОСЛЕДНИЙ прогон источника, а не последний подходящий: если после обрыва +# уже прошёл полный ('done') прогон, дерево обойдено и возобновлять нечего. Строки +# 'skipped' — бухгалтерия планировщика, а не прогоны, поэтому не в счёт. +_RESUME_CANDIDATE_SQL = text(""" + WITH cur AS ( + SELECT source, params FROM scrape_runs WHERE id = CAST(:rid AS bigint) + ) + SELECT r.id AS prev_id, + r.status AS prev_status, + r.counters AS prev_counters, + (r.params IS NOT DISTINCT FROM cur.params) AS same_params, + EXTRACT(EPOCH FROM (clock_timestamp() - r.heartbeat_at)) / 3600.0 AS age_h, + cur.params ->> 'interval_days' AS interval_days + FROM scrape_runs r, cur + WHERE r.source = cur.source + AND r.id <> CAST(:rid AS bigint) + AND r.status <> 'skipped' + ORDER BY r.started_at DESC + LIMIT 1 +""") + + +def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]: + """Решение «подхватывать ли точку» + счётчики-объяснение. Чистая функция. + + Возвращает (resume_run_id | None, counters-заготовка нового прогона). Причина + отказа — машиночитаемый слаг в `resume_reason`, по нему «предыдущего прогона не + было» отличается от «параметры разъехались» ЗАПРОСОМ, а не грепом логов. + + Срок годности точки — такт источника ПЛЮС сутки (`interval_days` из его же params). + Ни одно из слагаемых не выбрано произвольно. Такт — объявленный самим расписанием + срок, в течение которого собранное считается свежим; точка старше него пережила + цикл, в котором источник обязан был обойти дерево целиком. Сутки — сетка запуска: + `compute_next_run_at` выбирает ДЕНЬ (сегодня+interval_days) и случайное время внутри + окна, поэтому два соседних запуска отстоят друг от друга на interval_days ± меньше + суток. Без этого слагаемого точку отвергал бы jitter расписания, а не устаревание: + прогон 3547 убит деплоем 09.08 16:53, расписание 139 подхватит его 16.08 13:37 — + 164.6 ч при такте 168 ч, запас 3.4 ч при ширине окна 2 ч. Пропущенный цикл в окно + всё равно не влезает: для avito это 13 суток против порога 8. + """ + if row is None: + return None, {"resume_from": None, "resume_reason": "no_prev_run", "resume_chain": 0} + + prev_counters = row.prev_counters if isinstance(row.prev_counters, dict) else {} + done_buckets = prev_counters.get("done_buckets") + done_n = len(done_buckets) if isinstance(done_buckets, list) else 0 + chain_raw = prev_counters.get("resume_chain") + prev_chain = chain_raw if isinstance(chain_raw, int) else 0 + verdict: dict[str, Any] = { + "resume_from": None, + "resume_candidate": int(row.prev_id), + "resume_buckets": done_n, + "resume_chain": 0, + } + + if row.prev_status not in _RESUME_STATUSES: + verdict["resume_reason"] = f"status_{row.prev_status}" + elif not row.same_params: + verdict["resume_reason"] = "params_changed" + elif done_n == 0: + verdict["resume_reason"] = "no_checkpoint" + elif row.age_h is None or float(row.age_h) > 24.0 * ( + _schedule_interval_days(row.interval_days) + 1 + ): + verdict["resume_reason"] = "checkpoint_stale" + elif prev_chain >= _MAX_RESUME_CHAIN: + verdict["resume_reason"] = "chain_limit" + else: + verdict["resume_from"] = int(row.prev_id) + verdict["resume_reason"] = "ok" + verdict["resume_chain"] = prev_chain + 1 + return int(row.prev_id), verdict + return None, verdict + + +def _pick_resume(db: Session, run_id: int) -> int | None: + """Чекпоинт какого прогона наследует `run_id` (или None) + запись вердикта. + + Тождество задания сверяется РОВНО по тем полям, которыми задание задаётся: source + (кандидат ищется в пределах одного source) и params целиком, побайтово. Кандидат + сравнивается с ТЕКУЩИМ прогоном, а не с расписанием, потому что именно params + прогона поехали в pipeline. На проде за 90 суток 89 корзин из 433 (21%) лежат в + прогонах, чьи params отличаются от следующего — по ним пропуск был бы неверным: + `incremental_days` меняет СМЫСЛ ключа (дочитано до watermark ≠ бакет перебран), а + `price_cap_per_bucket` меняет само дерево бисекции, то есть какие ключи существуют. + """ + row = db.execute(_RESUME_CANDIDATE_SQL, {"rid": run_id}).fetchone() + resume_run_id, verdict = _resume_decision(row) + # Вердикт кладём в counters НОВОГО прогона: `update_heartbeat` мержит jsonb, поэтому + # последующие heartbeat'ы пайплайна его не затрут и он доживёт до финализатора. + _kit_runs.update_heartbeat(db, run_id, verdict) + logger.info("scheduler: resume run_id=%d — %s", run_id, verdict) + return resume_run_id + + def _defer_next_run_at(db: Session, schedule_row: dict[str, Any]) -> None: """Сдвинуть next_run_at на следующее окно БЕЗ создания run (#1522). @@ -705,7 +831,7 @@ async def _job_avito_full_load( concurrency=int(params.get("concurrency", 5)), request_delay_sec=float(params.get("request_delay_sec", 7.0)), secondary_only=bool(params.get("secondary_only", True)), - resume_run_id=None, + resume_run_id=_pick_resume(db, run_id), incremental_days=incremental_days, ) @@ -725,7 +851,7 @@ async def _job_avito_full_load_exhaustive( concurrency=int(params.get("concurrency", 5)), request_delay_sec=float(params.get("request_delay_sec", 7.0)), secondary_only=bool(params.get("secondary_only", True)), - resume_run_id=None, + resume_run_id=_pick_resume(db, run_id), incremental_days=None, ) @@ -796,7 +922,7 @@ async def _job_cian_full_load( request_delay_sec=float(params.get("request_delay_sec", 4.0)), enrich_detail=bool(params.get("enrich_detail", False)), detail_top_n=int(params.get("detail_top_n", 0)), - resume_run_id=None, + resume_run_id=_pick_resume(db, run_id), ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 3c3d717a..95e2618a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -1095,7 +1095,9 @@ class AvitoScraper(BaseScraper): leaf-бакета. Может быть async или sync. Исключение прерывает прогон. on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket). skip_buckets: множество ключей «room_label:lo:hi» уже завершённых бакетов — - пагинация и on_bucket для них пропускаются. Probe-запросы выполняются. + пагинация и on_bucket для них пропускаются. В exhaustive-режиме probe-запросы + всё равно выполняются (skip проверяется уже в листе, после probe), в + инкрементальном probe'а нет — там пропускается весь бакет целиком. since: если None (default) — EXHAUSTIVE bisection-обход (поведение без изменений). Если задана date — INCREMENTAL: на каждый (комнатность × seed-брекет) последовательная пагинация newest-first с ранней остановкой, @@ -1141,6 +1143,7 @@ class AvitoScraper(BaseScraper): max_pages_per_bucket=max_pages_per_bucket, secondary_only=secondary_only, on_bucket=on_bucket, + skip_buckets=skip_buckets, ) else: await self._walk_price_range( @@ -1390,14 +1393,17 @@ class AvitoScraper(BaseScraper): # иначе фетчим её как обычную страницу (открытый брекет без probe-html). sem = asyncio.Semaphore(concurrency) first_url = self._build_rooms_url(room_slug, 1, _lo_param, _hi_param) + dropped_pages = 0 # страницы, не отдавшие карточки по отказу (не по пустоте) async def _one_page(p: int) -> list[ScrapedLot]: + nonlocal dropped_pages if p == 1 and html is not None: return self._parse_html(html, source_url_base=first_url) async with sem: page_html = await self._fetch_rooms_page_html(room_slug, p, _lo_param, _hi_param) await asyncio.sleep(self.request_delay_sec) if page_html is None: + dropped_pages += 1 logger.warning( "avito: page_html=None %s [%d, %s] page=%d — skipping page", room_label, @@ -1420,6 +1426,7 @@ class AvitoScraper(BaseScraper): # Блокировки пробрасываем наверх (mark_banned в pipeline-обёртке). if isinstance(res, AvitoBlockedError | AvitoRateLimitedError): raise res + dropped_pages += 1 logger.warning( "avito: page exception %s [%d, %s] page=%d — %r", room_label, @@ -1464,8 +1471,25 @@ class AvitoScraper(BaseScraper): if key: seen[key] = lot + # ── Полнота бакета: ключ у целиком и частично собранного ОДИНАКОВ ───── + # `bucket_key` — это "room:lo:hi" и больше ничего, поэтому «сделано» едет + # отдельным аргументом. Три пути частичности сходятся здесь: + # 1. страница молча выпала (page_html=None выше); + # 2. исключение страницы проглочено gather'ом (return_exceptions=True); + # 3. признанный tail-loss — probe провалился (expected_total=None, открытый + # брекет) или страниц нужно больше, чем max_pages. + # Ни один из них не оставлял следа в чекпоинте: следующий прогон видел ключ и + # пропускал бакет. Пока признак не доехал до done_buckets, включать подхват + # нельзя — видимая потеря (перескрап) стала бы невидимой (пропуск страниц). + pages_needed = ( + math.ceil(expected_total / _AVITO_OFFERS_PER_PAGE) + if expected_total is not None + else None + ) + complete = dropped_pages == 0 and pages_needed is not None and pages_needed <= max_pages logger.info( - "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d", + "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d " + "complete=%s dropped_pages=%d", room_label, lo, _hi_repr, @@ -1473,11 +1497,13 @@ class AvitoScraper(BaseScraper): collected_this_bucket, dropped_nb, len(seen), + complete, + dropped_pages, ) # ── on_bucket callback: инкрементальный save ────────────────────────── if on_bucket is not None and bucket_lots: - res_cb = on_bucket(bucket_key, bucket_lots) + res_cb = on_bucket(bucket_key, bucket_lots, complete) if inspect.isawaitable(res_cb): await res_cb @@ -1493,6 +1519,7 @@ class AvitoScraper(BaseScraper): max_pages_per_bucket: int, secondary_only: bool, on_bucket: Callable[..., Any] | None, + skip_buckets: set[str] | None = None, ) -> None: """INCREMENTAL пагинация одного (комнатность × seed-брекет) с ранней остановкой. @@ -1514,16 +1541,37 @@ class AvitoScraper(BaseScraper): bucket_key, secondary_only-фильтр и дедуп в seen — идентичны _paginate_leaf_bucket. on_bucket вызывается один раз для собранного бакета (async/sync-aware). AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх. + + skip_buckets: ключи, дочитанные ПРЕДЫДУЩИМ прогоном с ТЕМИ ЖЕ params (тождество + задания проверяет планировщик, `_pick_resume`). В инкрементальном режиме + «сделано» значит «дочитал до watermark `since`», а не «перебрал бакет целиком», + поэтому смешивать такой ключ с exhaustive-ключом нельзя — они дословно совпадают + (6 из 11 seed-ключей), но означают разное. Пропуск здесь безопасен по покрытию + ровно потому, что окно ретроспективы не уже такта (#2674, гарантируется + _job_avito_full_load): бакет, дочитанный до watermark N суток назад, следующий + плановый прогон перечитает со своим since = сегодня−N и увидит всё, что успело + появиться. Раньше аргумент сюда не передавался вовсе — resume в боевом + (инкрементальном) режиме avito_full_load был чистым no-op: 134 из 242 корзин. + + complete=False (см. `_paginate_leaf_bucket`) отдаётся, когда бакет НЕ дочитан до + watermark: страница выпала, или страниц не хватило (max_pages), или остановка + произошла по grace-эвристике «2 подряд недатированные страницы» — там watermark + не доказан, а не достигнут. """ _lo_param = lo if lo > 0 else None _hi_param = hi # None → _build_rooms_url не ставит pmax _hi_repr = "open" if hi is None else str(hi) bucket_key = f"{room_label}:{lo}:{_hi_repr}" + if skip_buckets and bucket_key in skip_buckets: + logger.info("avito: skip bucket %s — already read to watermark (resume)", bucket_key) + return + bucket_lots: list[ScrapedLot] = [] pages_fetched = 0 not_fresh_streak = 0 # подряд идущие не-свежие (all None-или-старые) страницы stop_reason = "max-pages" # перетирается ниже на реальную причину + complete = False # дочитан ли бакет до watermark; True только на честных стопах for p in range(1, max_pages_per_bucket + 1): # Последовательный фетч (НЕ asyncio.gather): early-stop требует читать @@ -1540,6 +1588,7 @@ class AvitoScraper(BaseScraper): page_lots = self._parse_html(page_html, source_url_base=page_url) if not page_lots: stop_reason = "end-of-pages (empty parse)" + complete = True # выдача кончилась — читать в этом брекете больше нечего break bucket_lots.extend(page_lots) @@ -1556,6 +1605,7 @@ class AvitoScraper(BaseScraper): # Есть даты, но все < since → newest-first гарантирует, что дальше # только старее → стоп немедленно. stop_reason = "early-stop (page all older than since)" + complete = True # watermark достигнут — ровно то, что значит «сделано» break # Все карточки undated (None) → не стопим сразу (могут быть свежие без # даты), но копим streak; 2 подряд недатированные страницы → grace-стоп. @@ -1581,7 +1631,7 @@ class AvitoScraper(BaseScraper): logger.info( "avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d " - "unique_total=%d incremental since=%s stop=%s", + "unique_total=%d incremental since=%s stop=%s complete=%s", room_label, lo, _hi_repr, @@ -1591,11 +1641,12 @@ class AvitoScraper(BaseScraper): len(seen), since.isoformat(), stop_reason, + complete, ) # ── on_bucket callback: инкрементальный save ────────────────────────── if on_bucket is not None and bucket_lots: - res_cb = on_bucket(bucket_key, bucket_lots) + res_cb = on_bucket(bucket_key, bucket_lots, complete) if inspect.isawaitable(res_cb): await res_cb diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py index e046ef67..67b5c302 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py @@ -621,8 +621,10 @@ class CianScraper(BaseScraper): # Страница 1 уже есть (html из probe выше); остальные — параллельно. sem = asyncio.Semaphore(concurrency) + dropped_pages = 0 if html else 1 # пустой probe-html = страница 1 не собрана async def _one_page(p: int) -> list[ScrapedLot]: + nonlocal dropped_pages if p == 1: # Используем уже полученный HTML от probe return self._parse_serp_html(html) if html else [] @@ -630,6 +632,7 @@ class CianScraper(BaseScraper): page_html = await self._fetch_page_html(rooms, p, _lo_param, hi) await asyncio.sleep(self.request_delay_sec) if page_html is None: + dropped_pages += 1 logger.warning( "cian: page_html=None %s [%d, %s] page=%d — skipping page", room_label, @@ -648,6 +651,7 @@ class CianScraper(BaseScraper): bucket_lots: list[ScrapedLot] = [] for p_idx, res in enumerate(page_results, start=1): if isinstance(res, BaseException): + dropped_pages += 1 logger.warning( "cian: page exception %s [%d, %s] page=%d — %r", room_label, @@ -674,8 +678,16 @@ class CianScraper(BaseScraper): if key: seen[key] = lot + # ── Полнота бакета: ключ у целиком и частично собранного ОДИНАКОВ ───── + # См. avito/serp.py — те же два пути частичности (выпавшая страница, + # проглоченное gather'ом исключение) плюс признанный hard-cap выше. У cian это + # тяжелее: в SERP-слое НЕТ класса блок-исключения вообще (ban-детект #2625 + # агрегатный, на выходе из скрапера), поэтому капча посреди бакета приходит сюда + # как page_html=None и раньше давала «сделанный» бакет из уцелевших страниц. + complete = dropped_pages == 0 and pages_needed <= max_pages logger.info( - "cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d", + "cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d " + "complete=%s dropped_pages=%d", room_label, lo, _hi_repr, @@ -683,11 +695,13 @@ class CianScraper(BaseScraper): collected_this_bucket, dropped_nb, len(seen), + complete, + dropped_pages, ) # ── on_bucket callback: инкрементальный save ────────────────────────── if on_bucket is not None and bucket_lots: - res_cb = on_bucket(bucket_key, bucket_lots) + res_cb = on_bucket(bucket_key, bucket_lots, complete) if inspect.isawaitable(res_cb): await res_cb diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py index 0c8c883c..589be38e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py @@ -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]: