Merge remote-tracking branch 'origin/main' into HEAD
Some checks failed
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m21s

This commit is contained in:
bot-backend 2026-08-13 12:36:56 +05:00
commit d7017ebfee
57 changed files with 4857 additions and 664 deletions

View file

@ -2230,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel):
) )
only_status: str = Field( only_status: str = Field(
default="pending", default="pending",
description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.", description=(
"Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор "
"'transient_error' на половине пакета; явное значение = только этот статус."
),
) )
house_id: int | None = Field( house_id: int | None = Field(
default=None, default=None,
@ -2267,7 +2270,12 @@ async def scrape_house_imv_backfill(
batch_size: сколько домов обработать за запуск (default 50). batch_size: сколько домов обработать за запуск (default 50).
request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV
реагирует на частые запросы с datacenter-IP. Не снижать < 3s. реагирует на частые запросы с 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). house_id: обработать один дом (debug).
Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию. Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию.

View file

@ -613,9 +613,13 @@ class Settings(BaseSettings):
cian_valuation_max_rub: float = 500_000_000 cian_valuation_max_rub: float = 500_000_000
# ── #audit-5: data-age guards ───────────────────────────────────────────── # ── #audit-5: data-age guards ─────────────────────────────────────────────
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца # #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning. # (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток),
sber_index_max_age_days: int = 35 # 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-оценка # avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning. # на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
avito_imv_thin_market_threshold: int = 10 avito_imv_thin_market_threshold: int = 10
@ -643,6 +647,23 @@ class Settings(BaseSettings):
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал. # индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6 estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8 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) ────────────── # ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561, # Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,

View file

@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор) low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: int # медиана ₽/м² median_ppm2: int # медиана ₽/м²
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор) 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): class PriceTrendPoint(BaseModel):

View file

@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import (
estimate_via_cian_valuation, estimate_via_cian_valuation,
) )
from scraper_kit.providers.yandex.valuation import ( from scraper_kit.providers.yandex.valuation import (
ValuationHouseMeta,
YandexValuationResult, YandexValuationResult,
YandexValuationScraper, YandexValuationScraper,
) )
@ -490,6 +489,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0) 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→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент # Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.720.93). Estimator # ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.720.93). Estimator
@ -963,13 +988,13 @@ def _save_yandex_history_items(
Batch semantics: single try/except; on any failure the batch rolls back. Batch semantics: single try/except; on any failure the batch rolls back.
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по #2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
без единого объявления в истории, до записи панорамы не доходила на проде это в истории, дома не заводила на проде это 1519 оценок против 1360 домов с
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
переноса: match_or_create_house теперь вызывается и для таких страниц (может «панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
остальных 90% новых сущностей класс не появляется, появляется недостающая доля. тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
""" """
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16] # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
# — stable across re-runs, distinguishes pages for different addresses. # — stable across re-runs, distinguishes pages for different addresses.
@ -1006,9 +1031,6 @@ def _save_yandex_history_items(
result.address, result.address,
) )
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
_save_yandex_house_panorama(db, house_id, result.house)
if not result.history_items: if not result.history_items:
return 0 return 0
@ -1093,58 +1115,13 @@ def _save_yandex_history_items(
return 0 return 0
# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в # #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но # (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь — # ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету. # 24.0512.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
# # полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text # ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн, # не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
def _save_yandex_house_panorama(
db: Session,
house_id: int | None,
meta: ValuationHouseMeta,
) -> None:
"""Пишет houses.has_panorama по разобранной мете yandex_valuation.
No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
выше). Best-effort: ошибка логируется и глотается оценка не должна падать из-за
справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
"""
if house_id is None:
return
if meta.year_built is None and meta.total_floors is None:
logger.debug(
"yandex_valuation: has_panorama не пишем для house_id=%s"
"страница не подтверждена (нет ни года, ни этажности)",
house_id,
)
return
try:
with db.begin_nested():
db.execute(
text(
"""
UPDATE houses
SET has_panorama = CAST(:panorama AS boolean)
WHERE id = CAST(:hid AS bigint)
AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
"""
),
{"hid": house_id, "panorama": meta.has_panorama},
)
db.commit()
except Exception as e:
logger.warning(
"yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
house_id,
e,
)
db.rollback()
# ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── # ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
@ -1160,14 +1137,39 @@ def _fetch_house_imv_anchor(
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`. """Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
В отличие от `avito_imv_evaluations` (keyed estimate_id пустая, on-demand В отличие от `avito_imv_evaluations` (keyed estimate_id пустая, on-demand
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе None при любой ошибке / отсутствии house_id / пустой таблице estimator
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии продолжает на гео-tier'ах (no regress).
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, 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: if target_house_id is None:
return None return None
@ -1177,7 +1179,7 @@ def _fetch_house_imv_anchor(
text( text(
""" """
SELECT recommended_price, lower_price, higher_price, SELECT recommended_price, lower_price, higher_price,
market_count, rooms, area_m2 market_count, rooms, area_m2, renovation_type
FROM house_imv_evaluations FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint) WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0 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} 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( def _apply_quarter_index(
*, *,
base_median_ppm2: float, 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. """#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. 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: for dash in SBER_COEFF_DASHBOARDS:
try: 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} series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
if not series: if not series:
continue 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 series
return {} return {}
@ -1700,6 +1770,10 @@ def _fetch_dkp_corridor(
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION) series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
adjusted: list[float] = [] adjusted: list[float] = []
factors_applied: list[float] = [] factors_applied: list[float] = []
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
# заявленную свежесть коридора, не участвуя в его границах.
latest_deal: date | None = None
for r in rows: for r in rows:
ppm2 = r["price_per_m2"] ppm2 = r["price_per_m2"]
if not ppm2: if not ppm2:
@ -1711,6 +1785,8 @@ def _fetch_dkp_corridor(
factor = _sber_time_factor(series, deal_month) factor = _sber_time_factor(series, deal_month)
adjusted.append(float(ppm2) * factor) adjusted.append(float(ppm2) * factor)
factors_applied.append(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) ppm2_values = sorted(adjusted)
# #oblast-D widen: a single street in a small non-EKB town can easily have # #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_rows = []
city_adjusted: list[float] = [] city_adjusted: list[float] = []
city_latest: date | None = None
for r in city_rows: for r in city_rows:
ppm2 = r["price_per_m2"] ppm2 = r["price_per_m2"]
if not ppm2: if not ppm2:
@ -1773,6 +1850,8 @@ def _fetch_dkp_corridor(
deal_month = date(dd.year, dd.month, 1) deal_month = date(dd.year, dd.month, 1)
factor = _sber_time_factor(series, deal_month) factor = _sber_time_factor(series, deal_month)
city_adjusted.append(float(ppm2) * factor) 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): if len(city_adjusted) > len(ppm2_values):
logger.info( logger.info(
@ -1783,6 +1862,10 @@ def _fetch_dkp_corridor(
city, city,
) )
ppm2_values = sorted(city_adjusted) ppm2_values = sorted(city_adjusted)
# #2846: коридор теперь описывает city-выборку — и возраст обязан
# переехать вместе с числами, иначе подпись осталась бы от street-
# выборки, которую на витрине уже никто не видит.
latest_deal = city_latest
if not ppm2_values: if not ppm2_values:
return None return None
@ -1805,6 +1888,7 @@ def _fetch_dkp_corridor(
"median_ppm2": int(_percentile(ppm2_values, 0.5)), "median_ppm2": int(_percentile(ppm2_values, 0.5)),
"high_ppm2": int(_percentile(ppm2_values, 0.90)), "high_ppm2": int(_percentile(ppm2_values, 0.90)),
"period_months": period_months, "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]], ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
quarter_index_lookup: Callable[[str], tuple[float, int] | None], quarter_index_lookup: Callable[[str], tuple[float, int] | None],
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]], quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
target_house_cadnum: str | None, target_house_cadnum: str | None,
dadata_coarse: bool, dadata_coarse: bool,
geo: GeocodeResult, geo: GeocodeResult,
@ -3058,6 +3145,7 @@ def _price_from_inputs(
anchor_total: int | None = None anchor_total: int | None = None
anchor_higher: int | None = None anchor_higher: int | None = None
anchor_label: str | None = None anchor_label: str | None = None
anchor_repair_note: str = ""
if imv_anchor is not None and imv_anchor.get("recommended_price"): if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"]) anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = ( anchor_higher = (
@ -3076,6 +3164,22 @@ def _price_from_inputs(
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold _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: elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(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 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_price = new_median
median_ppm2 = new_ppm2 median_ppm2 = new_ppm2
explanation = (explanation or "") + ( explanation = (explanation or "") + (
f" Оценка скорректирована по {anchor_label} " f" Оценка скорректирована по {anchor_label}"
f"({anchor_used / 1_000_000:.1f} млн ₽)." f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
) )
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"}) sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали. # Диапазон расширяем даже если медиану не двигали.
@ -3164,13 +3268,16 @@ def _price_from_inputs(
and median_price > 0 and median_price > 0
and area_m2 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) 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: if target_quarter is not None:
qindex_result = quarter_index_lookup(target_quarter) qindex_result = quarter_index_lookup(target_quarter)
@ -4240,6 +4347,22 @@ async def estimate_quality(
# ── Coarse-geo signals ──────────────────────────────────────────────────── # ── Coarse-geo signals ────────────────────────────────────────────────────
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2 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 ─────────────────────── # ── DB-callable wrappers injected into pure pricing ───────────────────────
def _ratio_resolver( def _ratio_resolver(
appm2: float | None, appm2: float | None,
@ -4284,7 +4407,7 @@ async def estimate_quality(
ratio_resolver=_ratio_resolver, ratio_resolver=_ratio_resolver,
quarter_index_lookup=_qi_lookup, quarter_index_lookup=_qi_lookup,
quarter_indexes_lookup=_qis_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, dadata_coarse=dadata_coarse,
geo=geo, geo=geo,
dadata_qc_geo=dadata.qc_geo if dadata else None, dadata_qc_geo=dadata.qc_geo if dadata else None,

View file

@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
# ── Page 3: Deals ──────────────────────────────────────────────────────────── # ── 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] def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_deals = len(estimate.actual_deals) n_deals = len(estimate.actual_deals)
today = dt.date.today() # #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
period_start = today - dt.timedelta(days=estimate.period_months * 30) # Реальная граница — as-of по показанным сделкам; окна поиска на странице
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
deals_as_of = deals_as_of_label(estimate)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа # Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%», # (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%»,
@ -1597,9 +1627,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<table style="width:100%;border-collapse:collapse;"> <table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td> <tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr> <td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
<tr><td style="padding:4pt 0;">Период сделок</td> {f'''<tr><td style="padding:4pt 0;">Сделки</td>
<td class="bold" style="text-align:right;"> <td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
{_mono(f"{period_start.strftime('%m.%Y')} {today.strftime('%m.%Y')}")}</td></tr> if deals_as_of else ""}
</table> </table>
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};"> <div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div> <span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>

View file

@ -372,6 +372,134 @@ def _names_unrecognized_locality(address: str) -> bool:
return bool(_LOCALITY_MARKER_RE.search(normalized)) 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: def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582). """Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
@ -1287,6 +1415,42 @@ async def suggest(
# ── Public API ─────────────────────────────────────────────────────────────── # ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None: 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. """Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args: Args:

View file

@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
UPDATE houses UPDATE houses
SET imv_status = 'ok', SET imv_status = 'ok',
last_imv_attempt_at = NOW(), last_imv_attempt_at = NOW(),
imv_error_reason = NULL imv_error_reason = NULL,
imv_transient_attempts = 0
WHERE id = :hid WHERE id = :hid
"""), """),
{"hid": house_id}, {"hid": house_id},
@ -424,12 +425,19 @@ def _mark_status(
status: str, status: str,
reason: str | None = None, reason: str | None = None,
) -> None: ) -> None:
# #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом
# падал по временной причине», а не «сколько раз его трогали». no_params /
# no_address / not_found счётчик не двигают: они не занимают retry-слот.
db.execute( db.execute(
text(""" text("""
UPDATE houses UPDATE houses
SET imv_status = :s, SET imv_status = :s,
last_imv_attempt_at = NOW(), 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 WHERE id = :hid
"""), """),
{"hid": house_id, "s": status, "r": reason}, {"hid": house_id, "s": status, "r": reason},
@ -443,6 +451,104 @@ _IMVStatus = Literal[
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error" "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 @dataclass
class HouseIMVBackfillResult: class HouseIMVBackfillResult:
@ -452,6 +558,23 @@ class HouseIMVBackfillResult:
errors: int = 0 errors: int = 0
duration_sec: float = field(default=0.0) duration_sec: float = field(default=0.0)
status_counts: dict[str, int] = field(default_factory=dict) 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: 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). batch_size: max houses to process (ignored when house_id given).
request_delay_sec: sleep between Avito API calls (default 5s anti-bot). request_delay_sec: sleep between Avito API calls (default 5s anti-bot).
only_status: process houses with this imv_status (default 'pending'). 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). house_id: process a single specific house (debug).
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
домов caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies домов caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
@ -509,23 +636,45 @@ async def backfill_house_imv(
.all() .all()
) )
else: 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( db.execute(
text(""" _QUEUE_SQL,
SELECT id, address, full_address, lat, lon {"status": only_status, "batch": max(batch_size - result.retried, 0)},
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},
) )
.mappings() .mappings()
.all() .all()
) )
rows = list(fresh_rows) + list(retry_rows)
result.checked = len(rows) result.checked = len(rows)
if not rows: if not rows:
@ -534,9 +683,11 @@ async def backfill_house_imv(
return result return result
logger.info( 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, result.checked,
only_status, only_status,
result.retried,
result.premarked,
request_delay_sec, request_delay_sec,
) )
@ -610,11 +761,14 @@ async def backfill_house_imv(
result.duration_sec = time.time() - t0 result.duration_sec = time.time() - t0
logger.info( 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.checked,
result.saved, result.saved,
result.skipped, result.skipped,
result.errors, result.errors,
result.retried,
result.premarked,
result.duration_sec, result.duration_sec,
result.status_counts, result.status_counts,
) )

View file

@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"commission_year": ["cian_serp", "yandex_realty_nb"], "commission_year": ["cian_serp", "yandex_realty_nb"],
"commission_month": ["yandex_realty_nb"], # raw RU month name "commission_month": ["yandex_realty_nb"], # raw RU month name
"developer_name": ["cian", "yandex_realty_nb"], "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_total_listings": ["yandex_valuation"], # "N объектов" в истории
# Yandex Valuation enrichment (existing house attrs) # Yandex Valuation enrichment (existing house attrs)
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"], "has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],

View file

@ -447,6 +447,12 @@ async def _job_house_imv_backfill(
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал. # алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
"total_seen": result.checked, "total_seen": result.checked,
"new_count": result.saved, "new_count": result.saved,
# #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error)
# и сколько домов ушло в no_params до пакета одним запросом. Без этих
# двух счётчиков в scrape_runs.counters проверить, что застрявшие
# действительно возвращаются в очередь, можно только по houses.
"retried": result.retried,
"premarked": result.premarked,
} }
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это # Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
# «сделали то, что собирались», а не «не поймали известное исключение». # «сделали то, что собирались», а не «не поймали известное исключение».

View file

@ -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 #1348: blocking psycopg work — must run via asyncio.to_thread, never directly
on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard). 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: for city_label, period_month, segment, dash, value in rows_to_upsert:
db.execute( 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) ON CONFLICT (city, period_month, dashboard)
DO UPDATE SET DO UPDATE SET
index_value_rub_m2 = EXCLUDED.index_value_rub_m2, index_value_rub_m2 = EXCLUDED.index_value_rub_m2,
segment = EXCLUDED.segment, segment = EXCLUDED.segment
fetched_at = now() -- fetched_at НЕ трогаем (#2846): она = «впервые увидели период».
""" """
), ),
{ {

View file

@ -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). wiring into the production scheduled orchestrator (previously only reachable manually).
Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher
per run (async context manager, source="domclick" -- dedicated residential proxy pool, per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула;
see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800),
на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via
domclick_session.load_session(db) and threaded into every fetch_detail() call. 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): 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): Статус такого прогона 'banned' (#2674, см. runs.mark_backfill_finished):
блок это external constraint, не наш баг, но и НЕ успех раньше здесь стоял блок это external constraint, не наш баг, но и НЕ успех раньше здесь стоял
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными. mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
No IP-rotation/cooldown recovery step exists here No IP-rotation/cooldown recovery step exists here -- an aborted run simply
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an retries the remaining backlog next window.
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 этот прогон НЕ передаёт и ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch

View file

@ -1,56 +1,68 @@
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1. """Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода).
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846).
СберИндекс 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'е, тонущем в логах оценок.
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток
поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага (sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по
публикации так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. построению: `period_month` метка ПЕРВОГО числа месяца, поэтому на закрытии месяца
возрасту уже 30; плюс собственный лаг публикации источника. За 31 сутки прямых
наблюдений монитора (07-13 08-12, scrape_runs.counters) возраст лежал в 46..76 и
НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени ноль бит.
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING мерил не источник, а нашу же пилу. Миграция 212 (такт 28 7) обещала потолок
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его возраста 46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне
застой сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor) загрузки шестидневной давности (08-06, errors=0, upserted=639) источник просто не
писал ERROR с самого начала расходилась только эта джоба. опубликовал июль. Двенадцать суток подряд (08-01 08-12) монитор писал ERROR при
исправной загрузке. Потолок 53 держится, только если источник публикует строго
помесячно; он не публикует.
ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду
монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка это была ПИЛА нашего нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН
собственного такта загрузки: максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» это вопрос
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, тревога снова означает
«источник/загрузка встали», а не «мы давно не ходили».
Порог алерта (документирование выбора): последний полный прогон свежий наш max == max источника источник не публиковал,
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.
Задача синхронная (DB-only, один SELECT max(period_month)) запускается ЛОВУШКА: `status='done'` НЕ означает успех прогон id=37 (2026-05-31) имеет
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в {errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ 3 табло × 3 региона прошли: errors счётчик по всему прогону).
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) ERROR-записи ПОРОГ не круглое число, а такт самой загрузки: `scrape_schedules.default_params
достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего .interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик
оценивать). запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с
тактом порог не может: поменяли такт порог поехал следом. Тревога после
MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети)
поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14
суток; на прод-истории такое состояние ДОСТИЖИМО разрывы между полными прогонами
были 14.8 и 20 суток (05-3106-15 и 07-1708-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 from __future__ import annotations
@ -62,153 +74,239 @@ from datetime import UTC, date, datetime
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
from app.services import scrape_runs as runs_mod 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__) logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"DEFAULT_LAG_ALLOWANCE_DAYS", "DEFAULT_PULL_INTERVAL_DAYS",
"MISSED_PULL_CYCLES",
"SBER_FRESHNESS_PULL_SOURCE",
"SberFreshnessVerdict", "SberFreshnessVerdict",
"check_sber_freshness", "check_sber_freshness",
"evaluate_sber_freshness", "evaluate_sber_freshness",
] ]
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate # Джоба-загрузчик, чей такт и успешность мы и мониторим.
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days. SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull"
DEFAULT_LAG_ALLOWANCE_DAYS = 25
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями # Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг
# sber_price_index для областного уровня. # окна (поглощаем), 2 = загрузка встала (алерт).
SBER_MONITOR_CITY = "Свердловская область" 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 SELECT max(period_month) AS latest
FROM sber_price_index FROM sber_price_index
WHERE city = CAST(:city AS text) WHERE city = CAST(:city AS text)
AND dashboard = CAST(:dash AS text)
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка -- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр -- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
-- estimator._load_sber_index_series. -- estimator._load_sber_index_series.
AND (segment IS NULL OR segment ILIKE '%вторичн%') 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) @dataclass(frozen=True)
class SberFreshnessVerdict: class SberFreshnessVerdict:
"""Вердикт свежести СберИндекса по max(period_month).""" """Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта."""
latest_period: date latest_period: date
age_days: int age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги
pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было
max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки
stale: bool stale: bool
def evaluate_sber_freshness( def evaluate_sber_freshness(
latest_period: date, latest_period: date,
now: datetime, now: datetime,
max_age_days: int, *,
last_complete_pull_at: datetime | None,
pull_interval_days: int,
) -> SberFreshnessVerdict: ) -> SberFreshnessVerdict:
"""Чистая логика: устарел ли latest период СберИндекса. """Чистая логика: отстала ли загрузка от собственного такта.
stale = age_days > max_age_days, где age_days = now.date() - latest_period. stale = полных прогонов не было ВООБЩЕ, либо последний старше
`max_age_days` ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance), MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД. вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш
max(period_month) равен максимуму источника по построению, и его возраст это
лаг ПУБЛИКАЦИИ, на который мы повлиять не можем.
""" """
age_days = (now.date() - latest_period).days 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( return SberFreshnessVerdict(
latest_period=latest_period, latest_period=latest_period,
age_days=age_days, 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( def check_sber_freshness(
db: Session, db: Session,
run_id: int, run_id: int,
params: dict | None = None, # type: ignore[type-arg] params: dict | None = None, # type: ignore[type-arg]
now: datetime | None = None, now: datetime | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness. """Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании.
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
Читает один SELECT max(period_month) вторичного сегмента по региону, считает Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops) sber_index_pull, такт загрузки из scrape_schedules. Вердикт чистой функцией.
и финализирует run.
Params (default_params jsonb): `params` больше ничего не настраивает: порог берётся из такта самой загрузки
lag_allowance_days: int запас сверх sber_index_max_age_days (default 25). (унаследованный default_params.lag_allowance_days=25 монитора игнорируется
`now` инъектируется в тестах (frozen); в проде None datetime.now(UTC). он кодировал мёртвый календарный порог). `now` инъектируется в тестах.
Returns counters {latest_year, latest_month, age_days, alert}. Returns counters {latest_year, latest_month, age_days, pull_lag_days,
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать); max_pull_lag_days, alert}.
mark_failed только если у оценщика нет серии вообще (нечего оценивать);
при алерте прогон помечается done (это монитор, не сбой джобы). при алерте прогон помечается done (это монитор, не сбой джобы).
""" """
params = params or {}
now = now or datetime.now(UTC) now = now or datetime.now(UTC)
counters: dict[str, int] = { counters: dict[str, int] = {
"latest_year": 0, "latest_year": 0,
"latest_month": 0, "latest_month": 0,
"age_days": 0, "age_days": 0,
"pull_lag_days": -1,
"max_pull_lag_days": 0,
"alert": 0, "alert": 0,
} }
try: try:
runs_mod.update_heartbeat(db, run_id, counters) runs_mod.update_heartbeat(db, run_id, counters)
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() found = _load_estimator_dashboard(db)
latest: date | None = row.latest if row is not None else None if found is None:
if latest is None:
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
logger.error( logger.error(
"sber freshness: sber_price_index пуст/недоступен для region=%s " "sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
"(вторичка) — оценить свежесть нельзя", "для region=%s (вторичка); оценить нечего",
SBER_MONITOR_CITY, list(SBER_COEFF_DASHBOARDS),
SBER_TIME_ADJUST_REGION,
) )
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters) runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
return counters return counters
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS)) dashboard, latest = found
max_age_days = settings.sber_index_max_age_days + lag_days last_pull_row = db.execute(
verdict = evaluate_sber_freshness(latest, now, max_age_days) _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 = { counters = {
"latest_year": latest.year, "latest_year": latest.year,
"latest_month": latest.month, "latest_month": latest.month,
"age_days": verdict.age_days, "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), "alert": int(verdict.stale),
} }
if verdict.stale: if verdict.stale:
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) — # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
logger.error( logger.error(
"sber freshness: max(period_month)=%s устарел на %d дней " "sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон "
"(> порога %d = sber_index_max_age_days %d + lag %d); " "%s (%s суток назад, порог %d = %d такта × %d суток; "
"СберИндекс time-adjustment ДКП-сделок мог отстать — " "status='done' с errors>0 за успех НЕ считается). "
"проверь sber_index_pull и доступность новых периодов источника", "Наш 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, latest,
verdict.age_days, dashboard,
max_age_days,
settings.sber_index_max_age_days,
lag_days,
) )
else: else:
logger.info( logger.info(
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) " "sber freshness: загрузка в такте — последний полный прогон %d суток назад "
"region=%s — алерта нет", "(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен "
"максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, "
"не наше отставание — алерта нет",
verdict.pull_lag_days,
verdict.max_pull_lag_days,
latest, latest,
dashboard,
verdict.age_days, verdict.age_days,
max_age_days,
SBER_MONITOR_CITY,
) )
runs_mod.mark_done(db, run_id, counters) runs_mod.mark_done(db, run_id, counters)
logger.info( 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, run_id,
latest, latest,
dashboard,
counters["alert"], counters["alert"],
counters["pull_lag_days"],
counters["age_days"], counters["age_days"],
) )
return counters return counters

View file

@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
такие URL регуляркой ДО сети это не капча, а предрешённый parseNone. Идут они такие URL регуляркой ДО сети это не капча, а предрешённый parseNone. Идут они
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
Но «не по тому 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: Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS 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__) logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"CANONICAL_URL_SQL",
"OFFER_ID_PATTERN",
"OFFER_URL_PATTERN", "OFFER_URL_PATTERN",
"YandexDetailBackfillResult", "YandexDetailBackfillResult",
"run_yandex_detail_backfill", "run_yandex_detail_backfill",
@ -63,8 +71,7 @@ __all__ = [
# #
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют # Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru, # source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из # strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
# #
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один # Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон: # застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
@ -72,6 +79,39 @@ __all__ = [
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим. # Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
OFFER_URL_PATTERN = "/offer/[0-9]+" 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 @dataclass
class YandexDetailBackfillResult: class YandexDetailBackfillResult:
@ -80,6 +120,11 @@ class YandexDetailBackfillResult:
attempted: int = 0 attempted: int = 0
enriched: int = 0 enriched: int = 0
failed: int = 0 failed: int = 0
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
# прогону; замер на месте = очередь снова читается не тем признаком.
url_from_offer_id: int = 0
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
unenrichable_pending: int = 0 unenrichable_pending: int = 0
duration_sec: float = field(default=0.0) duration_sec: float = field(default=0.0)
@ -88,6 +133,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted, "attempted": self.attempted,
"enriched": self.enriched, "enriched": self.enriched,
"failed": self.failed, "failed": self.failed,
"url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending, "unenrichable_pending": self.unenrichable_pending,
"duration_sec": int(self.duration_sec), "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. # SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first). # 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 = ( snapshot = (
db.execute( db.execute(
text( text(
""" f"""
SELECT id, source_url 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 FROM listings
WHERE source = 'yandex' WHERE source = 'yandex'
AND detail_enriched_at IS NULL AND detail_enriched_at IS NULL
AND source_url IS NOT NULL AND (
AND source_url ~ CAST(:offer_url_pattern AS text) (
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 ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int) 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() .mappings()
.all() .all()
) )
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено # Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
counters.unenrichable_pending = int( # разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
db.execute( # «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
text( pending = db.execute(
""" text(
SELECT count(*) """
FROM listings SELECT
WHERE source = 'yandex' count(*) FILTER (
AND detail_enriched_at IS NULL WHERE source_id ~ CAST(:offer_id_pattern AS text)
AND source_url IS NOT NULL ) AS url_from_offer_id,
AND source_url !~ CAST(:offer_url_pattern AS text) count(*) FILTER (
""" WHERE source_id IS NULL
), OR source_id !~ CAST(:offer_id_pattern AS text)
{"offer_url_pattern": OFFER_URL_PATTERN}, ) AS unenrichable_pending
).scalar_one() FROM listings
) WHERE source = 'yandex'
if counters.unenrichable_pending: 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( logger.info(
"yandex_detail_backfill: run_id=%d%d объявлений вне очереди: " "yandex_detail_backfill: run_id=%dу %d объявлений сохранённый "
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает " "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, run_id,
counters.unenrichable_pending, counters.unenrichable_pending,
OFFER_URL_PATTERN, OFFER_URL_PATTERN,

View file

@ -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/<source_id>/)
-- Живая проба прод-трактом 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;

View file

@ -0,0 +1,41 @@
-- 258_houses_imv_transient_attempts.sql
-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
--
-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
-- 'pending'), и за всю историю (41 прогон, 26.0611.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;

View file

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

View file

@ -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.0512.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;

View file

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

View file

@ -245,3 +245,8 @@
250_drop_duplicate_expires_at_index.sql 250_drop_duplicate_expires_at_index.sql
251_listings_drop_ceiling_height.sql 251_listings_drop_ceiling_height.sql
254_listings_backfill_avito_rating_glued_address.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

View file

@ -8,7 +8,7 @@
}, },
"low": { "low": {
"coverage_pct": 82.09, "coverage_pct": 82.09,
"mape_pct": 13.2, "mape_pct": 12.67,
"n": 276, "n": 276,
"n_covered": 220 "n_covered": 220
}, },
@ -26,22 +26,22 @@
], ],
"expected_sold": { "expected_sold": {
"overall": { "overall": {
"mape_pct": 13.18, "mape_pct": 12.63,
"median_bias_pct": -3.71, "median_bias_pct": -3.74,
"n": 269, "n": 269,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -16.11, "p25_pct": -15.17,
"p75_pct": 8.92 "p75_pct": 8.67
}, },
"per_rooms": { "per_rooms": {
"0": { "0": {
"label": "студия", "label": "студия",
"mape_pct": 19.38, "mape_pct": 16.96,
"median_bias_pct": 18.1, "median_bias_pct": 16.96,
"n": 35, "n": 35,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": 1.5, "p25_pct": 1.5,
"p75_pct": 34.32 "p75_pct": 38.82
}, },
"1": { "1": {
"label": "1к", "label": "1к",
@ -50,50 +50,50 @@
"n": 93, "n": 93,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -14.43, "p25_pct": -14.43,
"p75_pct": 6.98 "p75_pct": 8.15
}, },
"2": { "2": {
"label": "2к", "label": "2к",
"mape_pct": 18.26, "mape_pct": 17.39,
"median_bias_pct": -12.2, "median_bias_pct": -11.71,
"n": 74, "n": 74,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -24.35, "p25_pct": -23.07,
"p75_pct": -0.36 "p75_pct": -0.36
}, },
"3": { "3": {
"label": "3к", "label": "3к",
"mape_pct": 9.34, "mape_pct": 7.79,
"median_bias_pct": -3.08, "median_bias_pct": -4.05,
"n": 43, "n": 43,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -10.26, "p25_pct": -10.26,
"p75_pct": 4.68 "p75_pct": 3.82
}, },
"4": { "4": {
"label": "4+", "label": "4+",
"mape_pct": 16.3, "mape_pct": 16.38,
"median_bias_pct": 3.34, "median_bias_pct": -1.32,
"n": 24, "n": 24,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -14.91, "p25_pct": -14.91,
"p75_pct": 15.11 "p75_pct": 15.41
} }
}, },
"per_segment": { "per_segment": {
"бизнес": { "бизнес": {
"mape_pct": 14.65, "mape_pct": 13.65,
"median_bias_pct": -10.54, "median_bias_pct": -10.54,
"n": 46, "n": 46,
"p25_pct": -22.93, "p25_pct": -27.15,
"p75_pct": -1.31 "p75_pct": -1.31
}, },
"комфорт": { "комфорт": {
"mape_pct": 11.8, "mape_pct": 10.14,
"median_bias_pct": -4.61, "median_bias_pct": -5.01,
"n": 101, "n": 101,
"p25_pct": -17.07, "p25_pct": -15.49,
"p75_pct": 6.04 "p75_pct": 4.37
}, },
"премиум": { "премиум": {
"mape_pct": 68.92, "mape_pct": 68.92,
@ -103,11 +103,11 @@
"p75_pct": -68.92 "p75_pct": -68.92
}, },
"эконом": { "эконом": {
"mape_pct": 13.71, "mape_pct": 14.2,
"median_bias_pct": 2.54, "median_bias_pct": 3.33,
"n": 115, "n": 115,
"p25_pct": -9.8, "p25_pct": -8.69,
"p75_pct": 25.5 "p75_pct": 27.3
}, },
"элит": { "элит": {
"mape_pct": 33.2, "mape_pct": 33.2,

View file

@ -113,9 +113,17 @@ class TestYandexHousePriority:
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
def test_house_has_panorama_yandex_valuation_only(self) -> None: def test_has_panorama_removed_from_house_priority(self) -> None:
out = resolve_house_field("has_panorama", {"yandex_valuation": True}) """#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259).
assert out is True
В отличие от 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: def test_house_yandex_total_listings_yandex_valuation_only(self) -> None:
out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42}) out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42})
@ -217,7 +225,6 @@ class TestYandexListingPriority:
"corpus_count", "corpus_count",
"total_area_ha", "total_area_ha",
"commission_month", "commission_month",
"has_panorama",
"yandex_total_listings", "yandex_total_listings",
] ]
for key in yandex_keys: for key in yandex_keys:

View file

@ -59,3 +59,13 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234: # дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе. # на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint 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

View file

@ -15,6 +15,7 @@ import json
import os import os
import re import re
import sys import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") 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 import pytest # noqa: E402
from app.tasks.yandex_detail_backfill import ( # noqa: E402 from app.tasks.yandex_detail_backfill import ( # noqa: E402
CANONICAL_URL_SQL,
OFFER_ID_PATTERN,
OFFER_URL_PATTERN,
YandexDetailBackfillResult, YandexDetailBackfillResult,
run_yandex_detail_backfill, 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: def _mock_db(
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а snapshot: list[dict],
.scalar_one() размер отброшенной (непарсимой) части очереди.""" unenrichable: int = 0,
url_from_offer_id: int = 0,
) -> MagicMock:
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() —
остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из
source_id / адресовать нечем)."""
db = MagicMock() db = MagicMock()
sel = MagicMock() sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot 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 db.execute.return_value = sel
return db return db
@ -510,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
assert result.unenrichable_pending == 3535 assert result.unenrichable_pending == 3535
assert runs.mark_done.call_args.args[2]["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

View file

@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None:
assert "DROP COLUMN IF EXISTS is_outlier" in sql 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: def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
"""Действующее определение v_data_quality (последняя миграция, которая его """Действующее определение v_data_quality не упоминает is_outlier.
создаёт) не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged` `(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") _, body = _latest_v_data_quality()
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]
assert "outliers_flagged" not in body assert "outliers_flagged" not in body
assert "is_outlier" 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

View file

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

View file

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

View file

@ -153,28 +153,38 @@ async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.Monke
@pytest.mark.parametrize( @pytest.mark.parametrize(
("source", "must_contain"), ("source", "must_contain", "path_suffix"),
[ [
("avito", "www.avito.ru"), ("avito", "www.avito.ru", "/robots.txt"),
("cian", "ekb.cian.ru"), ("cian", "ekb.cian.ru", "/robots.txt"),
("yandex", "realty.yandex.ru"), ("yandex", "realty.yandex.ru", "/robots.txt"),
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. # а рабочий 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( 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: ) -> None:
seen: dict[str, Any] = {} seen: dict[str, Any] = {}
# Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» —
# 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа.
_body = (
'{"result":{"snippetsCount":678}}' if source == "domclick" else "<pre>User-agent: *</pre>"
)
class _Resp: class _Resp:
status_code = 200 status_code = 200
text = '{"html": "<pre>User-agent: *</pre>"}' text = "{}"
@staticmethod @staticmethod
def json() -> dict[str, str]: def json() -> dict[str, str]:
return {"html": "<pre>User-agent: *</pre>"} return {"html": _body}
class _Client: class _Client:
def __init__(self, **_kw: Any) -> None: ... 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 ok is True
assert must_contain in seen["payload"]["url"] 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' — проба не отбирает лок у боевой сессии. # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
assert seen["payload"]["source"] == "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 assert ok is False
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
assert fail_kind == "page" 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: 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] lease = acquire(db, "domclick") # type: ignore[arg-type]
assert lease is not None assert lease is not None
release(db, lease.id) # type: ignore[arg-type] 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.
("<html><title>Ошибка</title>qrator captcha</html>", 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("<pre>User-agent: *</pre>")
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"]

View file

@ -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 без него невозможен."
)

View file

@ -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), ("<page2/>", 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"<page{page}/>"
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="<page1/>",
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, "лоты частичного бакета обязаны быть сохранены"

View file

@ -102,14 +102,32 @@ def test_harness_itself_drops_warnings() -> None:
class _FakeMonitorDB: 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._latest = latest
self._last_pull = last_pull
self._interval_days = interval_days
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
result = MagicMock() 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 return result
def rollback(self) -> None: 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) monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None)
def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие.""" """Загрузка встала (полный прогон 20 суток назад при такте 7) → событие.
20 суток реальный разрыв прод-истории между полными прогонами 07-17 и 08-06.
"""
_patch_runs(monkeypatch, sber_mon) _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: with glitchtip_events() as events:
out = sber_mon.check_sber_freshness( out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type] db, # type: ignore[arg-type]
@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
assert out["alert"] == 1 assert out["alert"] == 1
assert any( assert any(
"sber freshness" in t for t in event_texts(events) "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) _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: with glitchtip_events() as events:
out = sber_mon.check_sber_freshness( out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type] db, # type: ignore[arg-type]
run_id=2, run_id=2,
params={}, params={},
now=datetime(2026, 6, 20, tzinfo=UTC), now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC),
) )
assert out["alert"] == 0 assert out["alert"] == 0
assert event_texts(events) == [] assert event_texts(events) == []
@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None
params={}, params={},
now=datetime(2026, 8, 6, tzinfo=UTC), 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: def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path():
patch("app.services.house_imv_backfill.save_imv_result") as mock_save, patch("app.services.house_imv_backfill.save_imv_result") as mock_save,
): ):
mock_mappings = MagicMock() 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 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) 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, patch("app.services.house_imv_backfill._mark_status") as mock_mark,
): ):
mock_mappings = MagicMock() 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 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) 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, patch("app.services.house_imv_backfill._mark_status") as mock_mark,
): ):
mock_mappings = MagicMock() 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 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) result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)

View file

@ -3,8 +3,6 @@
Каждая правка эпика тест, который краснеет без неё: Каждая правка эпика тест, который краснеет без неё:
подключено: подключено:
- houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница
не подтверждена иначе false «не смотрели» выдаётся за false «посмотрели»);
- domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules —
именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным;
- filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash.
@ -15,7 +13,11 @@
читателя и писателя; читателя и писателя;
- listings.merged_into, house_sources.raw_payload колонки без писателя; - listings.merged_into, house_sources.raw_payload колонки без писателя;
- v_data_quality.price_disagreements_count показатель, который не мог быть - v_data_quality.price_disagreements_count показатель, который не мог быть
ненулевым. ненулевым;
- houses.has_panorama (хвост, мигр. 259) признак, которого нет на площадке.
Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя;
подключённый писатель за 7 дней записал 12 значений, все false. Разница между
«проводка оборвана» и «мерить нечего» видна не по коду, а по площадке.
задокументировано: задокументировано:
- BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт —
@ -29,19 +31,13 @@ from __future__ import annotations
import os import os
import re import re
from pathlib import Path 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") 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.cian.valuation import _parse_valuation_state
from scraper_kit.providers.yandex.valuation import (
ValuationHistoryItem,
ValuationHouseMeta,
YandexValuationResult,
)
from app.services import estimator from app.services import estimator
from app.services.estimator import _save_yandex_history_items
REPO_ROOT = Path(__file__).resolve().parents[3] REPO_ROOT = Path(__file__).resolve().parents[3]
TRADEIN = REPO_ROOT / "tradein-mvp" TRADEIN = REPO_ROOT / "tradein-mvp"
@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Подключено 1/3: houses.has_panorama # Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка
# ─────────────────────────────────────────────────────────────────────────────
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: загрузчик ДОМ.РФ — оборванная проводка
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -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_ratios_tiered",
"asking_to_sold_tier_bounds", "asking_to_sold_tier_bounds",
"price_disagreements_count", "price_disagreements_count",
# Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета
# источников. Гейт краснеет на любом из трёх, а не только на одном (первая
# редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет).
"has_panorama",
] ]
offenders: list[str] = [] offenders: list[str] = []
for path in _live_python_sources(): 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 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: def test_price_divergence_is_documented_as_structurally_empty() -> None:
"""Оставленный задел обязан говорить, чем он НЕ является сегодня.""" """Оставленный задел обязан говорить, чем он НЕ является сегодня."""
sql = MIGRATION.read_text(encoding="utf-8") sql = MIGRATION.read_text(encoding="utf-8")

View file

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

View file

@ -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: def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None:
"""_load_sber_index_series логирует warning при stale серии.""" """Серия отдаётся как есть; календарного warning'а в горячем пути больше нет."""
import logging import logging
from app.services.estimator import _load_sber_index_series from app.services.estimator import _load_sber_index_series
# Серия с единственным месяцем 2 года назад stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал
stale_month = date(2024, 1, 1)
mock_db = MagicMock() mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [ mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"period_month": stale_month, "index_value_rub_m2": 100_000.0} {"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"): with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
series = _load_sber_index_series(mock_db, region="Свердловская область") series = _load_sber_index_series(mock_db, region="Свердловская область")
assert len(series) == 1 assert series == {stale_month: 100_000.0}
assert stale_month in series
# Warning о staleness должен быть залогирован
stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}" assert not stale_msgs, f"per-estimate guard вернулся, 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}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

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

View file

@ -178,21 +178,22 @@ def test_save_history_items_inserts_each():
): ):
saved = _save_yandex_history_items(db, result) saved = _save_yandex_history_items(db, result)
assert saved == 2 assert saved == 2
# 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE # 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова:
# houses.has_panorama — считаем именно вставки истории, а не все execute. # #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой.
rows = _history_rows(db) rows = _history_rows(db)
assert isinstance(rows, list) and len(rows) == 2 assert isinstance(rows, list) and len(rows) == 2
# Два коммита: панорама (до истории) + батч истории. Раньше был один. # Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе
assert db.commit.call_count == 2 # с колонкой, хвост #2674, мигр. 259.
assert db.commit.call_count == 1
def test_save_history_items_empty_no_commit(): def test_save_history_items_empty_no_commit():
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего. """Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно #2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%). отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь
Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается match_or_create_house вызывается до возврата а записей по-прежнему ноль:
а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает. истории нет, вставлять нечего.
""" """
db = MagicMock() db = MagicMock()
result = YandexValuationResult( 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(): def test_save_history_items_db_error_rolls_back_batch():
"""Any item failing rolls back the whole batch — batch semantics (finding #5). """Any item failing rolls back the whole batch — batch semantics (finding #5).
#2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение #2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе
доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает), и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы
а батч истории проходил бы успешно, и тест молча проверял бы не тот путь. не тот путь, стоит появиться любому новому execute перед вставкой истории.
""" """
db = MagicMock() db = MagicMock()
@ -266,5 +267,4 @@ def test_save_history_items_db_error_rolls_back_batch():
saved = _save_yandex_history_items(db, result) saved = _save_yandex_history_items(db, result)
assert saved == 0 # whole batch rolled back assert saved == 0 # whole batch rolled back
db.rollback.assert_called_once() db.rollback.assert_called_once()
# Панорама коммитится отдельно и раньше — её успех не отменяет отката истории. assert db.commit.call_count == 0
assert db.commit.call_count == 1

View file

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

View file

@ -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, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval,
patch.object(hib, "_mark_status") as mock_mark, 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) result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0)
mock_eval.assert_not_called() mock_eval.assert_not_called()

View file

@ -0,0 +1,267 @@
"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную.
Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон
(`only_status`, дефолт 'pending'). Возможность повтора существовала и была
задокументирована в admin-API, но за всю историю (41 прогон, 26.0611.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

View file

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

View file

@ -1,13 +1,20 @@
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1. """Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846).
Покрывает: Покрывает:
1. Чистую логику evaluate_sber_freshness (frozen now, без БД): 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте /
- fresh: age <= max_age_days (алерта нет); загрузка встала / полных прогонов не было вовсе / граница порога.
- stale: age > max_age_days (алерт); 2. check_sber_freshness с FakeDB прод-реплей 2026-08-12 и двусторонность:
- граница порога (== max_age_days нет алерта; +1 день алерт). при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой.
2. check_sber_freshness с FakeDB (fresh / stale / emptymark_failed / кастомный lag). 3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor). отставшее табло).
4. Регистрацию в kit product_handlers (registry остаётся зелёным). 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 from __future__ import annotations
@ -22,7 +29,6 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") 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.services.product_handlers import build_product_handlers
from app.tasks import sber_freshness_monitor as mon 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_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql" _MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql"
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12). # Прод-состояние 2026-08-12.
_MAY_2026 = date(2026, 5, 1) _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
# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── _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: def _now(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, tzinfo=UTC) return datetime(y, m, d, tzinfo=UTC)
def test_fresh_within_max_age() -> None: # ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
"""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
def test_stale_beyond_max_age() -> None: def test_loader_in_cadence_no_alert_even_at_age_72() -> None:
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт.""" """Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим.
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
assert v.stale is True После полного прогона наш 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.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: def test_threshold_boundary_exact_no_alert() -> None:
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >).""" """Ровно на пороге (2 такта) алерта ещё нет — строгое >."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60) v = mon.evaluate_sber_freshness(
assert v.age_days == 60 _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 assert v.stale is False
def test_threshold_boundary_next_day_alert() -> None: def test_threshold_boundary_next_day_alerts() -> None:
"""Порог + 1 день (age=61) — первый алерт.""" """Порог + 1 сутки — первый алерт (два такта подряд пропущены)."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60) v = mon.evaluate_sber_freshness(
assert v.age_days == 61 _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 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) ───────────────────────────────────────────── # ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
class _Row: class _Row:
def __init__(self, latest: date | None) -> None: def __init__(self, **kw: Any) -> None:
self.latest = latest self.__dict__.update(kw)
class _FakeResult: class _FakeResult:
def __init__(self, latest: date | None) -> None: def __init__(self, row: _Row | None) -> None:
self._latest = latest self._row = row
def first(self) -> _Row: def first(self) -> _Row | None:
return _Row(self._latest) return self._row
class _FakeDB: 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.rolled_back = False
self.asked_dashboards: list[str] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: 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: def rollback(self) -> None:
self.rolled_back = True self.rolled_back = True
@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
return calls return calls
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None: def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB:
calls = _patch_runs(monkeypatch) """Прод-состояние 2026-08-12 (оба табло вторички)."""
db = _FakeDB(_MAY_2026) return _FakeDB(
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта. {"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026},
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type] last_pull=last_pull,
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0} )
assert calls["done"] == out
assert calls["failed"] is None
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) calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026) db = _prod_db()
# @2026-07-12 (прод): age=72 > 60 → алерт. out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type]
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 1
assert out["age_days"] == 72 assert out["age_days"] == 72
assert out["latest_month"] == 5 assert out["alert"] == 0
# Монитор НЕ падает при алерте — прогон done, а не failed.
assert calls["done"] == out assert calls["done"] == out
assert calls["failed"] is None 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) calls = _patch_runs(monkeypatch)
db = _FakeDB(None) out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type]
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 0 assert out["alert"] == 0
assert calls["done"] is None assert calls["done"] is None
assert calls["failed"] is not 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) _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026) base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type]
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено. tweaked = mon.check_sber_freshness(
out = mon.check_sber_freshness( _prod_db(), # type: ignore[arg-type]
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12) run_id=9,
) # type: ignore[arg-type] params={"lag_allowance_days": 0},
assert out["alert"] == 1 now=_PROD_NOW,
)
assert base["alert"] == tweaked["alert"] == 0
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None: def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS.""" """Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет."""
_patch_runs(monkeypatch) _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026) db = _FakeDB(
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS {"real_estate_deals": _JUN_2026},
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт. last_pull=_now(2026, 7, 23), # 20 суток
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold) interval_days="28",
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type] )
assert out_exact["age_days"] == threshold out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out_exact["alert"] == 0 assert out["max_pull_lag_days"] == 56
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type] assert out["alert"] == 0
assert out_over["alert"] == 1
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 ────────────────────────────────────────────────────────────── # ── Миграция 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" 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: def test_migration_180_no_psycopg_trap() -> None:
sql = _MIGRATION_180.read_text("utf-8") sql = _MIGRATION_180.read_text("utf-8")
assert not re.search(r":\w+::", sql) 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: 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) assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type)
def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None: def test_default_pull_interval_matches_migration_212() -> 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 тревога каждый цикл, что и наблюдали.
"""
interval_days = int( interval_days = int(
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr]
)
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}д — "
"монитор снова будет мерить наш такт, а не застой источника"
) )
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 ───────────────────────────────────────────────── # ── Регистрация в kit registry ─────────────────────────────────────────────────

View file

@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]:
"""Строки батча house_placement_history из мока сессии. """Строки батча house_placement_history из мока сессии.
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama сломалась, как только у функции появился второй execute перед вставкой истории.
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. Фильтруем по SQL: тест переживёт любой новый вызов.
""" """
for call in db.execute.call_args_list: for call in db.execute.call_args_list:
if "INSERT INTO house_placement_history" in str(call.args[0]): 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 assert saved == 0
# db.execute не должен вызываться для пустого rows (нет INSERT) # db.execute не должен вызываться для пустого rows (нет INSERT)
assert _history_rows(db) == [] assert _history_rows(db) == []
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись # Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась # (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259.
# ли история по площади. assert db.commit.call_count == 1
assert db.commit.call_count == 2
db.rollback.assert_not_called() db.rollback.assert_not_called()

View file

@ -1,7 +1,7 @@
"""Unit tests for YandexValuationScraper — anonymous house-history scraper. """Unit tests for YandexValuationScraper — anonymous house-history scraper.
Fixture HTML simulates the Yandex valuation page body text containing: 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 - 2-3 historical offer entries with full structure
Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг
@ -78,7 +78,6 @@ def test_parse_house_meta_full():
assert meta.ceiling_height == 2.50 assert meta.ceiling_height == 2.50
assert meta.has_lift is True assert meta.has_lift is True
assert meta.total_objects == 12 assert meta.total_objects == 12
assert meta.has_panorama is False
def test_parse_house_meta_no_lift(): def test_parse_house_meta_no_lift():
@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift():
assert meta.ceiling_height == 3.0 assert meta.ceiling_height == 3.0
def test_parse_house_meta_with_panorama(): def test_parse_house_meta_brick_with_lift():
text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание" """#2674 (хвост): фикстура больше не содержит «Панорама».
Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором;
зелёный он был по построению. На настоящей странице оценки этого слова нет
проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов.
Остаток теста осмысленный: кирпич + лифт на короткой мета-строке.
"""
text = "7 объектов Дом 2010 года Лифт Кирпичное здание"
meta = YandexValuationScraper._parse_house_meta(text) meta = YandexValuationScraper._parse_house_meta(text)
assert meta.has_panorama is True
assert meta.has_lift is True assert meta.has_lift is True
assert meta.house_type == "brick" 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. Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item.
""" """
text = ( text = (
"Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт " "Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт "
"1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже " "1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже "
"2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже" "2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже"
) )

View file

@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]:
"""Строки батча house_placement_history из мока сессии. """Строки батча house_placement_history из мока сессии.
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция #2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama сломалась, как только у функции появился второй execute перед вставкой истории.
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов. Фильтруем по SQL: тест переживёт любой новый вызов.
""" """
for call in db.execute.call_args_list: for call in db.execute.call_args_list:
if "INSERT INTO house_placement_history" in str(call.args[0]): if "INSERT INTO house_placement_history" in str(call.args[0]):

View file

@ -175,6 +175,10 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
median_ppm2: 162_000, median_ppm2: 162_000,
high_ppm2: 178_000, high_ppm2: 178_000,
period_months: 24, period_months: 24,
// #2846: свежайшая сделка ВЫБОРКИ. Совпадает с прод-максимумом на 2026-08-12
// (deals.max(deal_date) = 2026-01-01) — оффлайн-превью должно показывать
// ту же подпись «по I кв. 2026», что и живая страница.
latest_deal_date: "2026-01-01",
}, },
price_trend: [ price_trend: [
{ month: "2025-11", ppm2: 168_000 }, { month: "2025-11", ppm2: 168_000 },

View file

@ -11,6 +11,7 @@ import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState,
import { asHouseType, asRepairState } from "@/types/trade-in"; import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand"; import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { sourceDot, sourceLabel } from "@/lib/source-registry"; import { sourceDot, sourceLabel } from "@/lib/source-registry";
import { dealsAsOfLabel } from "@/lib/rosreestr";
import { HeroTransparency } from "./HeroTransparency"; import { HeroTransparency } from "./HeroTransparency";
interface Props { interface Props {
@ -175,6 +176,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0; const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
const showDkp = const showDkp =
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0; !!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 // Progressive enrichment state
const needsHouseType = estimate.house_type === null; const needsHouseType = estimate.house_type === null;
@ -547,6 +553,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</span> </span>
<span className="bench-chip__sub mono"> <span className="bench-chip__sub mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} сд. {dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} сд.
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
</span> </span>
</div> </div>
)} )}
@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{formatMln(dkp.low_ppm2 * dkpArea)} {formatMln(dkp.high_ppm2 * dkpArea)} {formatMln(dkp.low_ppm2 * dkpArea)} {formatMln(dkp.high_ppm2 * dkpArea)}
</div> </div>
<div className="price-figure-perm2 mono"> <div className="price-figure-perm2 mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> за{" "} {dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr>
{dkp.period_months} мес {dkpAsOf ? ` · ${dkpAsOf}` : ""}
</div> </div>
</div> </div>
)} )}

View file

@ -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>): 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();
});
});

View file

@ -76,6 +76,7 @@ import type {
} from "./types"; } from "./types";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry"; 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 // Total number of source slots — meta "N / M". Single source of truth is
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES). // SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
@ -599,12 +600,27 @@ interface DealTier {
medianPpm: number; medianPpm: number;
count: number; count: number;
bars: 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 * Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
* real totals, then (2) the dkp_corridor /м² × area, then (3) the estimate's * real totals, then (2) the dkp_corridor /м² × area, then (3) the estimate's
* own actual_deals. null when no deal data at all. * own actual_deals. null when no deal data at all.
*
* #2846: каждая ветка тянет СВОЙ as-of три ветки это три РАЗНЫЕ выборки
* (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до
* города), и подпись обязана описывать ту, чьи числа сейчас на экране.
*/ */
function resolveDealTier( function resolveDealTier(
e: AggregatedEstimate, e: AggregatedEstimate,
@ -621,6 +637,9 @@ function resolveDealTier(
// same way the ads card does — otherwise a single mis-scraped lot bins // 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. // over [min,max] and crushes the real deals into the left buckets.
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean), 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; const area = e.area_m2;
@ -636,6 +655,9 @@ function resolveDealTier(
bars: bins8( bars: bins8(
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean, 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) { if (e.actual_deals.length > 0) {
@ -652,6 +674,7 @@ function resolveDealTier(
count: e.actual_deals.length, count: e.actual_deals.length,
// Fix #1/#8: same high-outlier guard as the ads card (see above). // Fix #1/#8: same high-outlier guard as the ads card (see above).
bars: bins8(guardPriceOutliers(ppms).clean), bars: bins8(guardPriceOutliers(ppms).clean),
asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
}; };
} }
return null; return null;
@ -1135,11 +1158,26 @@ export function mapResultPanel(
range: dealTier range: dealTier
? rangeLine(dealTier.loRub, dealTier.hiRub) ? rangeLine(dealTier.loRub, dealTier.hiRub)
: "нет данных", : "нет данных",
// #2846: хвост «· по I кв. 2026» — возраст ОТОБРАННЫХ сделок. Слово
// «ФАКТИЧЕСКИЕ» в заголовке читается как «недавние», а свежайшая пачка
// Росреестра на проде — I кв. 2026; молчание тут обещало свежесть,
// которой нет. Отдельной строки не заводим: ppm-строка по контракту
// ResultCard уже носит хвост «· N сделок», третий факт про тот же набор
// встаёт в тот же ряд и читается одним взглядом.
// dealTier == null (сделок нет) → «—», подписи нет: без чисел датировать
// нечего, а «по I кв. 2026» под прочерком выглядело бы как скрытые данные.
ppm: dealTier 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 : [], bars: dealTier ? dealTier.bars : [],
nav: 1, nav: 1,

View file

@ -1,6 +1,41 @@
export const ROSREESTR_REQUEST_URL = export const ROSREESTR_REQUEST_URL =
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; "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<void> { export async function openRosreestrWithAddress(address: string): Promise<void> {
try { try {
await navigator.clipboard.writeText(address); await navigator.clipboard.writeText(address);

View file

@ -110,6 +110,10 @@ export interface AnalogLot {
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset) // 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
// 'T1_per_street' — street-level only (default for all rosreestr deals) // 'T1_per_street' — street-level only (default for all rosreestr deals)
tier?: string | null; tier?: string | null;
// #1995 (бэкенд уже слал, фронт игнорировал; поднято #2846): честная точность
// listing_date. 'quarter' — rosreestr: дата = первый день КВАРТАЛА, а не день
// сделки. 'day' — объявления. undefined у старых оценок и когда source неизвестен.
date_precision?: "day" | "quarter" | null;
// ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional + // ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional +
// nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем // nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем
// пины с null lat/lon. // пины с null lat/lon.
@ -153,7 +157,12 @@ export interface DkpCorridor {
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор) low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: number; // медиана ₽/м² median_ppm2: number; // медиана ₽/м²
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор) 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 { export interface AggregatedEstimate {

View file

@ -61,7 +61,22 @@ _PROBE_URLS: dict[str, str] = {
"avito": "https://www.avito.ru/robots.txt", "avito": "https://www.avito.ru/robots.txt",
"cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url "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 "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_PROBE_URL: str = _PROBE_URLS["avito"]
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool # Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
@ -75,6 +90,11 @@ PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали # странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
# отказ с кодом 200». # отказ с кодом 200».
_PROBE_CONTENT_MARKER: str = "User-agent" _PROBE_CONTENT_MARKER: str = "User-agent"
# #2855: источник, чья проба идёт НЕ по robots.txt, требует своего признака «ресурс
# отдан». У count-эндпоинта Домклика это ключ ответа `snippetsCount` — он есть в живом
# JSON и отсутствует в блок-странице QRATOR. Держим отдельной картой, а не условием в
# теле: добавить площадку с боевым путём должно быть одной строкой, а не веткой.
_PROBE_CONTENT_MARKERS: dict[str, str] = {"domclick": "snippetsCount"}
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес: # source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с # сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠ # source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
@ -220,14 +240,16 @@ async def probe_proxy_via_browser(
html = "" html = ""
if not html: if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html" return False, classify_browser_probe(resp.status_code, detail), "empty html"
if _PROBE_CONTENT_MARKER not in html: marker = _PROBE_CONTENT_MARKERS.get(source, _PROBE_CONTENT_MARKER)
# 200 и непустое тело, но это не robots.txt — площадка отдала заглушку if marker not in html:
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»). # 200 и непустое тело, но это не запрошенный ресурс — площадка отдала заглушку
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»;
# 13.08: Домклик отдаёт блок-страницу QRATOR на /api/offers/*, #2855).
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy". # Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
return ( return (
False, False,
"page", "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)}" return True, None, f"html_len={len(html)}"

View file

@ -2953,6 +2953,11 @@ class CianFullLoadCounters:
detail_enriched: int = 0 detail_enriched: int = 0
detail_failed: int = 0 detail_failed: int = 0
errors_count: int = 0 errors_count: int = 0
# Бакеты, отданные SERP-слоем как НЕполные (страница выпала / исключение
# проглочено / hard-cap): лоты сохранены, но в done_buckets бакет не попал.
# Без этого счётчика «сколько бакетов чекпоинт не покрывает» видно только грепом
# логов, которые теряются при редеплое.
partial_buckets: int = 0
def to_dict(self) -> dict[str, int]: def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)} 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) # накапливаем завершённые бакеты этого прогона done: set[str] = set(skip_set) # накапливаем завершённые бакеты этого прогона
def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] def _mark_bucket(bucket_key: str, complete: bool) -> None:
"""Инкрементальный save после каждого leaf-бакета. Дописывает bucket_key в done.""" """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем.
Ключ 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 nonlocal done
if runs.is_cancelled(db, run_id): if runs.is_cancelled(db, run_id):
logger.info("cian-full-load run_id=%d: cancel detected in on_bucket", 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") raise RuntimeError("shutdown")
if not lots: if not lots:
done.add(bucket_key) _mark_bucket(bucket_key, complete)
runs.update_heartbeat( runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} 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_inserted += inserted
counters.saved_updated += updated counters.saved_updated += updated
counters.unique_fetched += len(lots) 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)}) runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info( logger.info(
"cian-full-load run_id=%d: bucket %s saved ins=%d upd=%d total_unique=%d", "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: except NoProxyAvailableError as exc:
# #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан # #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) logger.error("cian-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1 counters.errors_count += 1
runs.mark_banned( runs.mark_banned(
@ -3467,7 +3497,8 @@ async def run_yandex_full_load(
except NoProxyAvailableError as exc: except NoProxyAvailableError as exc:
# #2687, см. ту же ветку в run_avito_full_load: наш отказ (пул пуст) обязан # #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) logger.error("yandex-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1 counters.errors_count += 1
runs.mark_banned( runs.mark_banned(
@ -3527,6 +3558,8 @@ class AvitoFullLoadCounters:
saved_inserted: int = 0 saved_inserted: int = 0
saved_updated: int = 0 saved_updated: int = 0
errors_count: int = 0 errors_count: int = 0
# Бакеты, отданные SERP-слоем как НЕполные — см. CianFullLoadCounters.
partial_buckets: int = 0
def to_dict(self) -> dict[str, int]: def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)} 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) done: set[str] = set(skip_set)
def _on_bucket(bucket_key: str, lots: list) -> None: # type: ignore[type-arg] def _mark_bucket(bucket_key: str, complete: bool) -> None:
"""Инкрементальный save после каждого leaf-бакета.""" """В чекпоинт — только ПОЛНОСТЬЮ собранный бакет; частичный лишь считаем.
Ключ 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 nonlocal done
if runs.is_cancelled(db, run_id): if runs.is_cancelled(db, run_id):
logger.info("avito-full-load run_id=%d: cancel detected in on_bucket", 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") raise RuntimeError("shutdown")
if not lots: if not lots:
done.add(bucket_key) _mark_bucket(bucket_key, complete)
runs.update_heartbeat( runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)} 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_inserted += inserted
counters.saved_updated += updated counters.saved_updated += updated
counters.unique_fetched += len(lots) 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)}) runs.update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)})
logger.info( logger.info(
"avito-full-load run_id=%d: bucket=%s saved ins=%d upd=%d total_unique=%d", "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: except NoProxyAvailableError as exc:
# #2687: пул опустел mid-run. Ветка стоит ДО generic-RuntimeError намеренно — # #2687: пул опустел mid-run. Ветка стоит ДО generic-RuntimeError намеренно —
# NoProxyAvailableError его подкласс, и без неё отказ уходил в mark_failed, # NoProxyAvailableError его подкласс, и без неё отказ уходил в mark_failed,
# который (в отличие от mark_banned) НЕ пишет done_buckets. То есть чекпоинт # который (в отличие от mark_banned) НЕ передавал done_buckets. То есть чекпоинт
# терялся ровно на НАШЕМ отказе — том исходе, для которого #2686 требовал его # терялся ровно на НАШЕМ отказе — том исходе, для которого #2686 требовал его
# сохранять наравне с блокировкой площадкой. # сохранять наравне с блокировкой площадкой. Диагноз ban_kind='infra' ветка
# даёт по-прежнему; сам чекпоинт с #930-мержем counters переживает и mark_failed.
logger.error("avito-full-load run_id=%d: no proxy available — %s", run_id, exc) logger.error("avito-full-load run_id=%d: no proxy available — %s", run_id, exc)
counters.errors_count += 1 counters.errors_count += 1
runs.mark_banned( runs.mark_banned(
@ -3942,6 +4000,13 @@ async def run_domclick_city_sweep(
f"QRATOR block aborted sweep — {counters.lots_fetched} listings " f"QRATOR block aborted sweep — {counters.lots_fetched} listings "
"collected before abort (#2657)", "collected before abort (#2657)",
counters.to_dict(), 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: elif 0 < counters.buckets_completed < counters.buckets_total:
# #2670: прогон оборван на середине — прошёл часть комнатных бакетов и # #2670: прогон оборван на середине — прошёл часть комнатных бакетов и

View file

@ -100,8 +100,8 @@ def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый # РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый
# статус» и «явное поле причины»: # статус» и «явное поле причины»:
# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed # 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed
# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив # его тогда терял) — нужна ОБОИМ исходам. Оставив статус, получаем её даром;
# статус, пришлось бы дублировать её в каждом потребителе. # расщепив статус, пришлось бы дублировать её в каждом потребителе.
# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых # 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы, # молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы,
# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results), # 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) return int(row.id)
def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None: def update_heartbeat(db: Session, run_id: int, counters: dict[str, Any]) -> None:
"""UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки. """UPDATE heartbeat_at + counters (МЕРЖ, не замена) + total_seen/new_count колонки.
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926). пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
COALESCE: если ключа нет в counters старое значение колонки сохраняется. 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) total_seen, new_count = _column_counts(counters)
db.execute( db.execute(
@ -583,7 +592,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
""" """
UPDATE scrape_runs UPDATE scrape_runs
SET heartbeat_at = clock_timestamp(), 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), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id WHERE id = :run_id
@ -641,7 +650,7 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
UPDATE scrape_runs UPDATE scrape_runs
SET status = 'done', SET status = 'done',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), 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), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running' 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 UPDATE scrape_runs
SET status = 'failed', SET status = 'failed',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), 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), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running' WHERE id = :run_id AND status = 'running'
@ -715,8 +725,9 @@ def mark_banned(
Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'. Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'.
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
багом, и важно СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его багом. Чекпоинт done_buckets раньше сохранял только этот финализатор теперь
теряет). Cooldown 2-4 часа. counters мержатся во всех (см. update_heartbeat), и точка переживает любой из них.
Cooldown 2-4 часа.
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус: `ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
- BAN_KIND_PLATFORM площадка нас заблокировала (firewall/403/captcha); - BAN_KIND_PLATFORM площадка нас заблокировала (firewall/403/captcha);
@ -742,7 +753,8 @@ def mark_banned(
UPDATE scrape_runs UPDATE scrape_runs
SET status = 'banned', SET status = 'banned',
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(), 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, ban_kind = :ban_kind,
total_seen = COALESCE(CAST(:total_seen AS int), total_seen), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)

View file

@ -497,6 +497,132 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext)
return run_id 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: def _defer_next_run_at(db: Session, schedule_row: dict[str, Any]) -> None:
"""Сдвинуть next_run_at на следующее окно БЕЗ создания run (#1522). """Сдвинуть next_run_at на следующее окно БЕЗ создания run (#1522).
@ -705,7 +831,7 @@ async def _job_avito_full_load(
concurrency=int(params.get("concurrency", 5)), concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)), request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)), secondary_only=bool(params.get("secondary_only", True)),
resume_run_id=None, resume_run_id=_pick_resume(db, run_id),
incremental_days=incremental_days, incremental_days=incremental_days,
) )
@ -725,7 +851,7 @@ async def _job_avito_full_load_exhaustive(
concurrency=int(params.get("concurrency", 5)), concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)), request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)), secondary_only=bool(params.get("secondary_only", True)),
resume_run_id=None, resume_run_id=_pick_resume(db, run_id),
incremental_days=None, incremental_days=None,
) )
@ -796,7 +922,7 @@ async def _job_cian_full_load(
request_delay_sec=float(params.get("request_delay_sec", 4.0)), request_delay_sec=float(params.get("request_delay_sec", 4.0)),
enrich_detail=bool(params.get("enrich_detail", False)), enrich_detail=bool(params.get("enrich_detail", False)),
detail_top_n=int(params.get("detail_top_n", 0)), detail_top_n=int(params.get("detail_top_n", 0)),
resume_run_id=None, resume_run_id=_pick_resume(db, run_id),
) )

View file

@ -1095,7 +1095,9 @@ class AvitoScraper(BaseScraper):
leaf-бакета. Может быть async или sync. Исключение прерывает прогон. leaf-бакета. Может быть async или sync. Исключение прерывает прогон.
on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket). on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket).
skip_buckets: множество ключей «room_label:lo:hi» уже завершённых бакетов skip_buckets: множество ключей «room_label:lo:hi» уже завершённых бакетов
пагинация и on_bucket для них пропускаются. Probe-запросы выполняются. пагинация и on_bucket для них пропускаются. В exhaustive-режиме probe-запросы
всё равно выполняются (skip проверяется уже в листе, после probe), в
инкрементальном probe'а нет — там пропускается весь бакет целиком.
since: если None (default) EXHAUSTIVE bisection-обход (поведение без since: если None (default) EXHAUSTIVE bisection-обход (поведение без
изменений). Если задана date INCREMENTAL: на каждый (комнатность × изменений). Если задана date INCREMENTAL: на каждый (комнатность ×
seed-брекет) последовательная пагинация newest-first с ранней остановкой, seed-брекет) последовательная пагинация newest-first с ранней остановкой,
@ -1141,6 +1143,7 @@ class AvitoScraper(BaseScraper):
max_pages_per_bucket=max_pages_per_bucket, max_pages_per_bucket=max_pages_per_bucket,
secondary_only=secondary_only, secondary_only=secondary_only,
on_bucket=on_bucket, on_bucket=on_bucket,
skip_buckets=skip_buckets,
) )
else: else:
await self._walk_price_range( await self._walk_price_range(
@ -1390,14 +1393,17 @@ class AvitoScraper(BaseScraper):
# иначе фетчим её как обычную страницу (открытый брекет без probe-html). # иначе фетчим её как обычную страницу (открытый брекет без probe-html).
sem = asyncio.Semaphore(concurrency) sem = asyncio.Semaphore(concurrency)
first_url = self._build_rooms_url(room_slug, 1, _lo_param, _hi_param) first_url = self._build_rooms_url(room_slug, 1, _lo_param, _hi_param)
dropped_pages = 0 # страницы, не отдавшие карточки по отказу (не по пустоте)
async def _one_page(p: int) -> list[ScrapedLot]: async def _one_page(p: int) -> list[ScrapedLot]:
nonlocal dropped_pages
if p == 1 and html is not None: if p == 1 and html is not None:
return self._parse_html(html, source_url_base=first_url) return self._parse_html(html, source_url_base=first_url)
async with sem: async with sem:
page_html = await self._fetch_rooms_page_html(room_slug, p, _lo_param, _hi_param) page_html = await self._fetch_rooms_page_html(room_slug, p, _lo_param, _hi_param)
await asyncio.sleep(self.request_delay_sec) await asyncio.sleep(self.request_delay_sec)
if page_html is None: if page_html is None:
dropped_pages += 1
logger.warning( logger.warning(
"avito: page_html=None %s [%d, %s] page=%d — skipping page", "avito: page_html=None %s [%d, %s] page=%d — skipping page",
room_label, room_label,
@ -1420,6 +1426,7 @@ class AvitoScraper(BaseScraper):
# Блокировки пробрасываем наверх (mark_banned в pipeline-обёртке). # Блокировки пробрасываем наверх (mark_banned в pipeline-обёртке).
if isinstance(res, AvitoBlockedError | AvitoRateLimitedError): if isinstance(res, AvitoBlockedError | AvitoRateLimitedError):
raise res raise res
dropped_pages += 1
logger.warning( logger.warning(
"avito: page exception %s [%d, %s] page=%d%r", "avito: page exception %s [%d, %s] page=%d%r",
room_label, room_label,
@ -1464,8 +1471,25 @@ class AvitoScraper(BaseScraper):
if key: if key:
seen[key] = lot 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( 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, room_label,
lo, lo,
_hi_repr, _hi_repr,
@ -1473,11 +1497,13 @@ class AvitoScraper(BaseScraper):
collected_this_bucket, collected_this_bucket,
dropped_nb, dropped_nb,
len(seen), len(seen),
complete,
dropped_pages,
) )
# ── on_bucket callback: инкрементальный save ────────────────────────── # ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots: 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): if inspect.isawaitable(res_cb):
await res_cb await res_cb
@ -1493,6 +1519,7 @@ class AvitoScraper(BaseScraper):
max_pages_per_bucket: int, max_pages_per_bucket: int,
secondary_only: bool, secondary_only: bool,
on_bucket: Callable[..., Any] | None, on_bucket: Callable[..., Any] | None,
skip_buckets: set[str] | None = None,
) -> None: ) -> None:
"""INCREMENTAL пагинация одного (комнатность × seed-брекет) с ранней остановкой. """INCREMENTAL пагинация одного (комнатность × seed-брекет) с ранней остановкой.
@ -1514,16 +1541,37 @@ class AvitoScraper(BaseScraper):
bucket_key, secondary_only-фильтр и дедуп в seen идентичны _paginate_leaf_bucket. bucket_key, secondary_only-фильтр и дедуп в seen идентичны _paginate_leaf_bucket.
on_bucket вызывается один раз для собранного бакета (async/sync-aware). on_bucket вызывается один раз для собранного бакета (async/sync-aware).
AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх. 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 _lo_param = lo if lo > 0 else None
_hi_param = hi # None → _build_rooms_url не ставит pmax _hi_param = hi # None → _build_rooms_url не ставит pmax
_hi_repr = "open" if hi is None else str(hi) _hi_repr = "open" if hi is None else str(hi)
bucket_key = f"{room_label}:{lo}:{_hi_repr}" 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] = [] bucket_lots: list[ScrapedLot] = []
pages_fetched = 0 pages_fetched = 0
not_fresh_streak = 0 # подряд идущие не-свежие (all None-или-старые) страницы not_fresh_streak = 0 # подряд идущие не-свежие (all None-или-старые) страницы
stop_reason = "max-pages" # перетирается ниже на реальную причину stop_reason = "max-pages" # перетирается ниже на реальную причину
complete = False # дочитан ли бакет до watermark; True только на честных стопах
for p in range(1, max_pages_per_bucket + 1): for p in range(1, max_pages_per_bucket + 1):
# Последовательный фетч (НЕ asyncio.gather): early-stop требует читать # Последовательный фетч (НЕ asyncio.gather): early-stop требует читать
@ -1540,6 +1588,7 @@ class AvitoScraper(BaseScraper):
page_lots = self._parse_html(page_html, source_url_base=page_url) page_lots = self._parse_html(page_html, source_url_base=page_url)
if not page_lots: if not page_lots:
stop_reason = "end-of-pages (empty parse)" stop_reason = "end-of-pages (empty parse)"
complete = True # выдача кончилась — читать в этом брекете больше нечего
break break
bucket_lots.extend(page_lots) bucket_lots.extend(page_lots)
@ -1556,6 +1605,7 @@ class AvitoScraper(BaseScraper):
# Есть даты, но все < since → newest-first гарантирует, что дальше # Есть даты, но все < since → newest-first гарантирует, что дальше
# только старее → стоп немедленно. # только старее → стоп немедленно.
stop_reason = "early-stop (page all older than since)" stop_reason = "early-stop (page all older than since)"
complete = True # watermark достигнут — ровно то, что значит «сделано»
break break
# Все карточки undated (None) → не стопим сразу (могут быть свежие без # Все карточки undated (None) → не стопим сразу (могут быть свежие без
# даты), но копим streak; 2 подряд недатированные страницы → grace-стоп. # даты), но копим streak; 2 подряд недатированные страницы → grace-стоп.
@ -1581,7 +1631,7 @@ class AvitoScraper(BaseScraper):
logger.info( logger.info(
"avito: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d " "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, room_label,
lo, lo,
_hi_repr, _hi_repr,
@ -1591,11 +1641,12 @@ class AvitoScraper(BaseScraper):
len(seen), len(seen),
since.isoformat(), since.isoformat(),
stop_reason, stop_reason,
complete,
) )
# ── on_bucket callback: инкрементальный save ────────────────────────── # ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots: 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): if inspect.isawaitable(res_cb):
await res_cb await res_cb

View file

@ -621,8 +621,10 @@ class CianScraper(BaseScraper):
# Страница 1 уже есть (html из probe выше); остальные — параллельно. # Страница 1 уже есть (html из probe выше); остальные — параллельно.
sem = asyncio.Semaphore(concurrency) sem = asyncio.Semaphore(concurrency)
dropped_pages = 0 if html else 1 # пустой probe-html = страница 1 не собрана
async def _one_page(p: int) -> list[ScrapedLot]: async def _one_page(p: int) -> list[ScrapedLot]:
nonlocal dropped_pages
if p == 1: if p == 1:
# Используем уже полученный HTML от probe # Используем уже полученный HTML от probe
return self._parse_serp_html(html) if html else [] 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) page_html = await self._fetch_page_html(rooms, p, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec) await asyncio.sleep(self.request_delay_sec)
if page_html is None: if page_html is None:
dropped_pages += 1
logger.warning( logger.warning(
"cian: page_html=None %s [%d, %s] page=%d — skipping page", "cian: page_html=None %s [%d, %s] page=%d — skipping page",
room_label, room_label,
@ -648,6 +651,7 @@ class CianScraper(BaseScraper):
bucket_lots: list[ScrapedLot] = [] bucket_lots: list[ScrapedLot] = []
for p_idx, res in enumerate(page_results, start=1): for p_idx, res in enumerate(page_results, start=1):
if isinstance(res, BaseException): if isinstance(res, BaseException):
dropped_pages += 1
logger.warning( logger.warning(
"cian: page exception %s [%d, %s] page=%d%r", "cian: page exception %s [%d, %s] page=%d%r",
room_label, room_label,
@ -674,8 +678,16 @@ class CianScraper(BaseScraper):
if key: if key:
seen[key] = lot 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( 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, room_label,
lo, lo,
_hi_repr, _hi_repr,
@ -683,11 +695,13 @@ class CianScraper(BaseScraper):
collected_this_bucket, collected_this_bucket,
dropped_nb, dropped_nb,
len(seen), len(seen),
complete,
dropped_pages,
) )
# ── on_bucket callback: инкрементальный save ────────────────────────── # ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots: 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): if inspect.isawaitable(res_cb):
await res_cb await res_cb

View file

@ -62,7 +62,11 @@ class ValuationHouseMeta(BaseModel):
ceiling_height: float | None = None # in meters, e.g. 2.5 ceiling_height: float | None = None # in meters, e.g. 2.5
has_lift: bool | None = None has_lift: bool | None = None
total_objects: int | None = None # 'N объектов' (full archive count) 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( def validate_match(
self, self,
@ -331,7 +335,6 @@ class YandexValuationScraper(BaseScraper):
ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None), ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None),
has_lift="Лифт" in body_text, has_lift="Лифт" in body_text,
total_objects=int(objects_m.group(1)) if objects_m else None, 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]: def _parse_history_items(self, tree: HTMLParser, body_text: str) -> list[ValuationHistoryItem]: