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

View file

@ -613,9 +613,13 @@ class Settings(BaseSettings):
cian_valuation_max_rub: float = 500_000_000
# ── #audit-5: data-age guards ─────────────────────────────────────────────
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
sber_index_max_age_days: int = 35
# #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению
# (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток),
# guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно
# место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог
# берёт из такта самой загрузки (scrape_schedules.default_params.interval_days),
# так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться.
# extra="ignore" в model_config защищает от startup-краха на leftover env var.
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
avito_imv_thin_market_threshold: int = 10
@ -643,6 +647,23 @@ class Settings(BaseSettings):
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
#
# Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
# известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
# 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
# цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
# координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
# денежный — поэтому включение отдельным решением, после замера.
#
# Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
# заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
# сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
# 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
# а не оставлять «на вырост».
estimate_quarter_from_coords_enabled: bool = False
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,

View file

@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: int # медиана ₽/м²
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
period_months: int # окно поиска сделок
period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date)
# #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/
# median/high — включая city-wide widen, если сработал), НЕ по всей таблице.
# period_months отвечает на «где искали», а не «насколько свежи сделки»: прод
# 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок
# даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы
# враньём в пользу свежести именно для них.
# Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день
# квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974
# сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно
# {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи
# на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад».
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
class PriceTrendPoint(BaseModel):

View file

@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import (
estimate_via_cian_valuation,
)
from scraper_kit.providers.yandex.valuation import (
ValuationHouseMeta,
YandexValuationResult,
YandexValuationScraper,
)
@ -490,6 +489,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0)
# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
"""#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
(`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
и смешивать их напрямую значит класть два разных базиса на одну шкалу: у клиента
с «требует ремонта» медиана 6%, якорь нет, порог `median×1.15` фактически падает до
×1.081, а сработавший blend возвращает половину этих 6% обратно вверх.
Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` рыночная
эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
применил к медиане, чинится расхождение базисов, а не калибровка.
Неизвестный/незнакомый ремонт с любой стороны 1.0 (no-op, прежнее поведение).
"""
base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
if base <= 0:
return 1.0
return _repair_coefficient(target_repair) / base
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.720.93). Estimator
@ -963,13 +988,13 @@ def _save_yandex_history_items(
Batch semantics: single try/except; on any failure the batch rolls back.
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
без единого объявления в истории, до записи панорамы не доходила на проде это
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
переноса: match_or_create_house теперь вызывается и для таких страниц (может
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
остальных 90% новых сущностей класс не появляется, появляется недостающая доля.
#2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
в истории, дома не заводила на проде это 1519 оценок против 1360 домов с
историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
«панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
"""
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
# — stable across re-runs, distinguishes pages for different addresses.
@ -1006,9 +1031,6 @@ def _save_yandex_history_items(
result.address,
)
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
_save_yandex_house_panorama(db, house_id, result.house)
if not result.history_items:
return 0
@ -1093,58 +1115,13 @@ def _save_yandex_history_items(
return 0
# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в
# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но
# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь —
# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету.
#
# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text
# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн,
# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
def _save_yandex_house_panorama(
db: Session,
house_id: int | None,
meta: ValuationHouseMeta,
) -> None:
"""Пишет houses.has_panorama по разобранной мете yandex_valuation.
No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
выше). Best-effort: ошибка логируется и глотается оценка не должна падать из-за
справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
"""
if house_id is None:
return
if meta.year_built is None and meta.total_floors is None:
logger.debug(
"yandex_valuation: has_panorama не пишем для house_id=%s"
"страница не подтверждена (нет ни года, ни этажности)",
house_id,
)
return
try:
with db.begin_nested():
db.execute(
text(
"""
UPDATE houses
SET has_panorama = CAST(:panorama AS boolean)
WHERE id = CAST(:hid AS bigint)
AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
"""
),
{"hid": house_id, "panorama": meta.has_panorama},
)
db.commit()
except Exception as e:
logger.warning(
"yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
house_id,
e,
)
db.rollback()
# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
# 24.0512.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
@ -1160,14 +1137,39 @@ def _fetch_house_imv_anchor(
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
В отличие от `avito_imv_evaluations` (keyed estimate_id пустая, on-demand
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
house_id / пустой таблице estimator продолжает на гео-tier'ах (no regress).
скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
None при любой ошибке / отсутствии house_id / пустой таблице estimator
продолжает на гео-tier'ах (no regress).
СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
~2951 домов, fresh» неправда по обоим пунктам, #2674):
2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id)
поэтому ORDER BY ниже выбирает из 1 кандидата, а не ранжирует). 2633 из
них (98.2%) старше 40 суток, 2366 один прогон 2026-05-24; свежих, после
фикса параметров 2026-08-06, всего 47.
Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
(46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
«панель» вместо «монолита»).
ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
· Гейт «не брать дореформенные строки» ОТКЛОНЁН: band-совместимая строка
находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
224 из 224, то есть просто выключил бы механизм.
· Массовая переоценка не чинит корень и упирается в очередь: 1628 из
2633 домов уже в imv_status='ok', а батч берёт только pending/
transient_error, где сейчас 7144 дома при темпе ~25/прогон.
· Корень не свежесть, а разные базисы ремонта; починено в #2677
(`_anchor_repair_factor`), см. ниже.
Returns dict {recommended_price, lower_price, higher_price, market_count,
rooms, area_m2} или None.
rooms, area_m2, renovation_type} или None.
#2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
(см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
"""
if target_house_id is None:
return None
@ -1177,7 +1179,7 @@ def _fetch_house_imv_anchor(
text(
"""
SELECT recommended_price, lower_price, higher_price,
market_count, rooms, area_m2
market_count, rooms, area_m2, renovation_type
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
@ -1359,6 +1361,80 @@ def _lookup_quarter_indexes(
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
# порог ответ выдан чужой квартал точность покрытие
# 15 м 475 26 94.5% 19.8%
# 25 м 748 59 92.1% 31.2%
# 50 м 1341 271 79.8% 55.9%
# 100 м 2090 644 69.2% 87.2%
# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
# точность 92%, и это ровно тот компромисс, который видно в замере, а не
# заимствованная у соседнего модуля константа.
_TARGET_QUARTER_RADIUS_M = 25.0
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
_TARGET_QUARTER_DEG_GATE = 0.0015
def _lookup_target_quarter_by_coords(
db: Session,
lat: float | None,
lon: float | None,
) -> str | None:
"""Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м).
Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на
проде (замер 2026-08-12) то есть свой кадастр цели известен почти никогда.
Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number`
(tasks/cadastral_geo_match) здесь он применён к координатам САМОЙ цели, а не
к координатам аналога.
Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен
только 3-й компонент кадастрового номера (квартал), а он у соседних зданий
одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не
полный кадастр, чтобы вызывающий код не принял его за кадастр дома.
None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет
или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M.
"""
if lat is None or lon is None:
return None
try:
row = db.execute(
text(
"""
WITH p AS (
SELECT ST_SetSRID(
ST_MakePoint(CAST(:lon AS double precision),
CAST(:lat AS double precision)), 4326) AS g
)
SELECT cb.cad_num
FROM cad_buildings_local cb, p
WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision))
AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision)
ORDER BY cb.geom <-> p.g
LIMIT 1
"""
),
{
"lat": lat,
"lon": lon,
"deg": _TARGET_QUARTER_DEG_GATE,
"radius": _TARGET_QUARTER_RADIUS_M,
},
).first()
except Exception as exc:
logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc)
return None
if row is None:
return None
return _quarter_from_cadastre(str(row[0]))
def _apply_quarter_index(
*,
base_median_ppm2: float,
@ -1493,7 +1569,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
"""#794: monthly {period_month: index_value} for region from sber_price_index.
Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error.
#audit-5a: если latest месяц серии старее sber_index_max_age_days → warning.
#2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest
периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по
построению: period_month метка ПЕРВОГО числа месяца (30 суток уже на
закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых
наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени
нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip
(event_level=ERROR). Свежесть теперь мерит ОДНО место tasks/sber_freshness_monitor,
и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт),
а не календарь.
"""
for dash in SBER_COEFF_DASHBOARDS:
try:
@ -1521,21 +1606,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
if not series:
continue
# #audit-5a: data-age guard — предупреждаем о stale СберИндексе.
latest = max(series)
today = datetime.now(tz=UTC).date()
age_days = (today - latest).days
if age_days > settings.sber_index_max_age_days:
logger.warning(
"sber_index stale #audit-5a: latest=%s age=%d days"
" (> sber_index_max_age_days=%d) region=%s dash=%s"
" — time-adjustment may be outdated",
latest.isoformat(),
age_days,
settings.sber_index_max_age_days,
region,
dash,
)
return series
return {}
@ -1700,6 +1770,10 @@ def _fetch_dkp_corridor(
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
adjusted: list[float] = []
factors_applied: list[float] = []
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
# заявленную свежесть коридора, не участвуя в его границах.
latest_deal: date | None = None
for r in rows:
ppm2 = r["price_per_m2"]
if not ppm2:
@ -1711,6 +1785,8 @@ def _fetch_dkp_corridor(
factor = _sber_time_factor(series, deal_month)
adjusted.append(float(ppm2) * factor)
factors_applied.append(factor)
if dd is not None and (latest_deal is None or dd > latest_deal):
latest_deal = dd
ppm2_values = sorted(adjusted)
# #oblast-D widen: a single street in a small non-EKB town can easily have
@ -1763,6 +1839,7 @@ def _fetch_dkp_corridor(
city_rows = []
city_adjusted: list[float] = []
city_latest: date | None = None
for r in city_rows:
ppm2 = r["price_per_m2"]
if not ppm2:
@ -1773,6 +1850,8 @@ def _fetch_dkp_corridor(
deal_month = date(dd.year, dd.month, 1)
factor = _sber_time_factor(series, deal_month)
city_adjusted.append(float(ppm2) * factor)
if dd is not None and (city_latest is None or dd > city_latest):
city_latest = dd
if len(city_adjusted) > len(ppm2_values):
logger.info(
@ -1783,6 +1862,10 @@ def _fetch_dkp_corridor(
city,
)
ppm2_values = sorted(city_adjusted)
# #2846: коридор теперь описывает city-выборку — и возраст обязан
# переехать вместе с числами, иначе подпись осталась бы от street-
# выборки, которую на витрине уже никто не видит.
latest_deal = city_latest
if not ppm2_values:
return None
@ -1805,6 +1888,7 @@ def _fetch_dkp_corridor(
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
"period_months": period_months,
"latest_deal_date": latest_deal,
}
@ -2695,6 +2779,9 @@ def _price_from_inputs(
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
target_house_cadnum: str | None,
dadata_coarse: bool,
geo: GeocodeResult,
@ -3058,6 +3145,7 @@ def _price_from_inputs(
anchor_total: int | None = None
anchor_higher: int | None = None
anchor_label: str | None = None
anchor_repair_note: str = ""
if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = (
@ -3076,6 +3164,22 @@ def _price_from_inputs(
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
),
)
# #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
# не правим), а в blend уходит якорь, приведённый к базису ремонта target'а
# тому же, в котором median_price уже посчитан выше (repair_coef).
_rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
if _rep_k != 1.0:
anchor_total = round(anchor_total * _rep_k)
anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
logger.info(
"imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
" → anchor %d",
imv_anchor.get("renovation_type"),
repair_state,
_rep_k,
anchor_total,
)
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
@ -3125,8 +3229,8 @@ def _price_from_inputs(
median_price = new_median
median_ppm2 = new_ppm2
explanation = (explanation or "") + (
f" Оценка скорректирована по {anchor_label} "
f"({anchor_used / 1_000_000:.1f} млн ₽)."
f" Оценка скорректирована по {anchor_label}"
f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
)
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали.
@ -3164,13 +3268,16 @@ def _price_from_inputs(
and median_price > 0
and area_m2
):
# target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по
# координатам цели (_lookup_target_quarter_by_coords, вызывающий код).
# Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» —
# снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал
# цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м
# от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза
# по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой).
# Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя
# заменить чужим кварталом, он и есть предмет поправки.
target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum)
if target_quarter is None:
for lot in listings_clean:
cq = _quarter_from_cadastre(lot.get("building_cadastral_number"))
if cq is not None:
target_quarter = cq
break
if target_quarter is not None:
qindex_result = quarter_index_lookup(target_quarter)
@ -4240,6 +4347,22 @@ async def estimate_quality(
# ── Coarse-geo signals ────────────────────────────────────────────────────
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
# квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
# сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
if (
settings.estimate_quarter_from_coords_enabled
and not target_quarter_cadnum
and geo is not None
):
target_quarter_cadnum = await asyncio.to_thread(
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
)
# ── DB-callable wrappers injected into pure pricing ───────────────────────
def _ratio_resolver(
appm2: float | None,
@ -4284,7 +4407,7 @@ async def estimate_quality(
ratio_resolver=_ratio_resolver,
quarter_index_lookup=_qi_lookup,
quarter_indexes_lookup=_qis_lookup,
target_house_cadnum=dadata.house_cadnum if dadata else None,
target_house_cadnum=target_quarter_cadnum,
dadata_coarse=dadata_coarse,
geo=geo,
dadata_qc_geo=dadata.qc_geo if dadata else None,

View file

@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
# ── Page 3: Deals ────────────────────────────────────────────────────────────
_ROMAN_QUARTER = ("I", "II", "III", "IV")
def deals_as_of_label(estimate: AggregatedEstimate) -> str | None:
"""#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки.
Раньше страница печатала «Период сделок: 08.2025 08.2026» окно ПОИСКА,
посчитанное как `today - period_months*30 today`. Правым концом оно обещало
сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде
(замер 2026-08-12) I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно
по тем сделкам, из которых страница строит диапазон и таблицу примеров.
Гранулярность квартал: rosreestr пишет deal_date = первый день квартала
(#1995, ровно то, что помечает AnalogLot.date_precision == "quarter").
Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания
сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока
такого нет) подписывается месяцем.
None сделок нет либо ни у одной нет даты: подписывать нечего.
"""
dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date]
if not dated:
return None
newest, precision = max(dated, key=lambda p: p[0])
if precision == "day":
return f"по {newest.strftime('%m.%Y')}"
return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}"
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_deals = len(estimate.actual_deals)
today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30)
# #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
# Реальная граница — as-of по показанным сделкам; окна поиска на странице
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
deals_as_of = deals_as_of_label(estimate)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%»,
@ -1597,9 +1627,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
<tr><td style="padding:4pt 0;">Период сделок</td>
<td class="bold" style="text-align:right;">
{_mono(f"{period_start.strftime('%m.%Y')} {today.strftime('%m.%Y')}")}</td></tr>
{f'''<tr><td style="padding:4pt 0;">Сделки</td>
<td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
if deals_as_of else ""}
</table>
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
<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))
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
# использовал, и если названный топоним туда не дожил — топоним подменён.
#
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
# сам целевой город.
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
# списка городов области, ради ухода от которого всё и делается).
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
_STREET_LEVEL_RE = re.compile(
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
r"|корпус|лит|литера|снт|сад|гск)\b"
)
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
_LOCALITY_TYPE_WORDS = frozenset(
{
"поселок",
"пос",
"село",
"деревня",
"дер",
"город",
"гор",
"округ",
"муниципальный",
"городской",
"сельское",
"поселение",
"тер",
"территория",
"станция",
"пгт",
"рп",
}
)
_WORD_RE = re.compile(r"[а-я][а-я-]*")
def _fold(value: str) -> str:
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
return " ".join(value.lower().replace("ё", "е").split())
def _claimed_locality(address: str) -> str | None:
"""Имя населённого пункта, названное в тексте адреса, или None.
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
(страна регион район НП улица дом), пропускаем уровни выше/ниже
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент имя НП.
None означает «НП не назван» это основной трафик формы («Малышева 30»), и
для него инвариант не применяется вовсе.
"""
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
segment = segment.strip()
if not segment:
continue
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
return None # улица/дом: будь НП назван, он шёл бы раньше
if (
_COUNTRY_RE.search(segment)
or _REGION_LEVEL_RE.search(segment)
or _DISTRICT_LEVEL_RE.search(segment)
):
continue
name = " ".join(
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
)
if name:
return name
return None
def _city_substituted(address: str, result: GeocodeResult) -> bool:
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
Три условия вместе:
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
2. этого имени НЕТ в адресе, который вернул провайдер то есть топоним не
пережил геокодинг;
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
собственному ответу провайдера он называет Екатеринбург либо не
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
тогда «внутри ЕКБ» подтверждают координаты).
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
Советская ул., 5» ответ «Екатеринбург, Советская улица, 5» имя не дожило,
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
корректный ответ по посёлку («Кедровка, Екатеринбург, » Nominatim и Yandex
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит»
«М-12 Ачит-Екатеринбург», «Лесной» «Лесной переулок»), считается дожившим
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
приставки («пионерский, советская»), понижается до `locality` честная
деградация, координаты не теряются.
"""
claimed = _claimed_locality(address)
if claimed is None or _EKATERINBURG_RE.search(claimed):
return False
answer = _fold(result.full_address or "")
if any(word in answer for word in claimed.split()):
return False # топоним дожил до ответа — провайдер искал там, где просили
if not is_within_ekb_bbox(result.lat, result.lon):
return False
answer_locality = _claimed_locality(answer)
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
@ -1287,6 +1415,42 @@ async def suggest(
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
применяется ОДНОЙ точкой на выходе поэтому покрывает разом все источники,
включая попадание в кэш: отравленная запись, записанная до этого фикса,
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
Сработал инвариант `confidence="locality"` + `city_ambiguous=True`.
`locality` не косметика: `estimator._geocode_is_coarse` уже трактует его
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
оценки, (б) через `tasks.geocode_missing` проставляет листингу
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
за собой чужие оценки. Координаты НЕ выбрасываются деградация честная и
видимая, а не отказ.
"""
result = await _geocode_resolve(address, db, city_hint)
if result is None or not _city_substituted(address, result):
return result
logger.warning(
"geocode city substitution (#2590): %r%r (%.5f, %.5f) provider=%s"
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
address[:80],
(result.full_address or "")[:80],
result.lat,
result.lon,
result.provider,
)
return replace(result, confidence="locality", city_ambiguous=True)
async def _geocode_resolve(
address: str, db: Session, city_hint: str | None = None
) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args:

View file

@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
UPDATE houses
SET imv_status = 'ok',
last_imv_attempt_at = NOW(),
imv_error_reason = NULL
imv_error_reason = NULL,
imv_transient_attempts = 0
WHERE id = :hid
"""),
{"hid": house_id},
@ -424,12 +425,19 @@ def _mark_status(
status: str,
reason: str | None = None,
) -> None:
# #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом
# падал по временной причине», а не «сколько раз его трогали». no_params /
# no_address / not_found счётчик не двигают: они не занимают retry-слот.
db.execute(
text("""
UPDATE houses
SET imv_status = :s,
last_imv_attempt_at = NOW(),
imv_error_reason = :r
imv_error_reason = :r,
imv_transient_attempts = CASE
WHEN :s = 'transient_error' THEN imv_transient_attempts + 1
ELSE imv_transient_attempts
END
WHERE id = :hid
"""),
{"hid": house_id, "s": status, "r": reason},
@ -443,6 +451,104 @@ _IMVStatus = Literal[
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error"
]
# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем
# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08)
# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039
# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на
# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует,
# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно.
# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status=
# 'transient_error' и считаются как
# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3.
_MAX_TRANSIENT_ATTEMPTS = 3
# Доля пакета под повтор transient_error. Половина — потому что остальные слоты
# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке
# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер
# головы очереди на 12.08), так что pending на половине пакета всё равно идёт
# быстрее, чем на целом до правки.
_RETRY_SLOTS_SHARE = 0.5
# Дом без пригодных параметров backfill всё равно пометит no_params — но только
# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним
# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ
# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»).
# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по
# imv_error_reason продолжают работать.
# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё
# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом.
# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы
# поштучный путь.
_PREMARK_UNUSABLE_SQL = text("""
WITH unusable AS (
SELECT h.id,
CASE WHEN NOT EXISTS (
SELECT 1 FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)
THEN 'no listings with rooms+area'
ELSE 'unknown house_type'
END AS reason
FROM houses h
WHERE h.imv_status = ANY(CAST(:statuses AS text[]))
AND h.lat IS NOT NULL
AND h.lon IS NOT NULL
AND h.address IS NOT NULL
AND (
NOT EXISTS (
SELECT 1 FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)
OR COALESCE(
NULLIF(TRIM((
SELECT mode() WITHIN GROUP (ORDER BY l.house_type)
FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)), ''),
NULLIF(TRIM(h.house_type), '')
) IS NULL
)
)
UPDATE houses
SET imv_status = 'no_params',
last_imv_attempt_at = NOW(),
imv_error_reason = unusable.reason
FROM unusable
WHERE houses.id = unusable.id
""")
# Основная очередь: один статус, как и было (only_status — публичный параметр
# admin-API, семантику не трогаем).
_QUEUE_SQL = text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = :status
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
""")
# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending
# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST
# transient_error не попал бы в пакет, пока не кончится pending (по замеру
# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход.
_RETRY_QUEUE_SQL = text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = 'transient_error'
AND imv_transient_attempts < :max_attempts
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
""")
@dataclass
class HouseIMVBackfillResult:
@ -452,6 +558,23 @@ class HouseIMVBackfillResult:
errors: int = 0
duration_sec: float = field(default=0.0)
status_counts: dict[str, int] = field(default_factory=dict)
# #2674: сколько домов пакета пришло из retry-очереди transient_error и
# сколько помечено no_params до пакета (без запроса к площадке).
retried: int = 0
premarked: int = 0
def _premark_unusable(db: Session, statuses: list[str]) -> int:
"""Пометить no_params дома, по которым запрос к площадке невозможен. → сколько.
Не новое поведение, а тот же вердикт _process_one_house одним запросом: на
12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area,
1812 без house_type) каждый занимал слот пакета и паузу, чтобы получить
ответ, который виден в SQL.
"""
res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses})
db.commit()
return int(res.rowcount or 0)
def _beat(heartbeat: Callable[[], None] | None) -> None:
@ -480,7 +603,11 @@ async def backfill_house_imv(
batch_size: max houses to process (ignored when house_id given).
request_delay_sec: sleep between Avito API calls (default 5s anti-bot).
only_status: process houses with this imv_status (default 'pending').
Use 'transient_error' to retry failures.
Use 'transient_error' to retry failures. При значении по умолчанию
часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор
transient_error с непотраченным лимитом попыток (#2674) — явно
переданный only_status этот проход отключает, оператор получает
ровно то, что попросил, включая исчерпавшие лимит дома.
house_id: process a single specific house (debug).
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
домов caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
@ -509,23 +636,45 @@ async def backfill_house_imv(
.all()
)
else:
rows = (
# Повторный проход только на расписании (only_status по умолчанию): явный
# only_status от оператора — это ручной запрос ровно одного статуса.
retry_lane = only_status == "pending"
statuses = [only_status] + (["transient_error"] if retry_lane else [])
result.premarked = _premark_unusable(db, statuses)
if result.premarked:
logger.info(
"house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area "
"или house_type) — слоты пакета не потрачены",
result.premarked,
)
retry_rows: list = []
if retry_lane:
retry_rows = (
db.execute(
_RETRY_QUEUE_SQL,
{
"max_attempts": _MAX_TRANSIENT_ATTEMPTS,
"batch": int(batch_size * _RETRY_SLOTS_SHARE),
},
)
.mappings()
.all()
)
result.retried = len(retry_rows)
# Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на
# 12.08) возвращается pending — пакет не простаивает.
fresh_rows = (
db.execute(
text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = :status
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
"""),
{"status": only_status, "batch": batch_size},
_QUEUE_SQL,
{"status": only_status, "batch": max(batch_size - result.retried, 0)},
)
.mappings()
.all()
)
rows = list(fresh_rows) + list(retry_rows)
result.checked = len(rows)
if not rows:
@ -534,9 +683,11 @@ async def backfill_house_imv(
return result
logger.info(
"house_imv_backfill: %d houses (status=%r delay=%.1fs)",
"house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)",
result.checked,
only_status,
result.retried,
result.premarked,
request_delay_sec,
)
@ -610,11 +761,14 @@ async def backfill_house_imv(
result.duration_sec = time.time() - t0
logger.info(
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s",
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d "
"retried=%d premarked=%d %.1fs %s",
result.checked,
result.saved,
result.skipped,
result.errors,
result.retried,
result.premarked,
result.duration_sec,
result.status_counts,
)

View file

@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"commission_year": ["cian_serp", "yandex_realty_nb"],
"commission_month": ["yandex_realty_nb"], # raw RU month name
"developer_name": ["cian", "yandex_realty_nb"],
"has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag
# #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259).
# В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала,
# источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда,
# потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на
# проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML).
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
# Yandex Valuation enrichment (existing house attrs)
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],

View file

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

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
on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard).
#2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией
(limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в
DO UPDATE ставил одну и ту же метку всем строкам ряда на проде все 639 строк
несли время последнего прогона, включая период 2017-01. Как признак свежести
колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период»,
то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым
периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка
2026-08-06 и она останется такт публикации до этого PR невосстановим.
Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно
scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted),
и именно оттуда его берёт tasks/sber_freshness_monitor.
"""
for city_label, period_month, segment, dash, value in rows_to_upsert:
db.execute(
@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st
ON CONFLICT (city, period_month, dashboard)
DO UPDATE SET
index_value_rub_m2 = EXCLUDED.index_value_rub_m2,
segment = EXCLUDED.segment,
fetched_at = now()
segment = EXCLUDED.segment
-- fetched_at НЕ трогаем (#2846): она = «впервые увидели период».
"""
),
{

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).
Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher
per run (async context manager, source="domclick" -- dedicated residential proxy pool,
see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via
per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула;
выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800),
на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via
domclick_session.load_session(db) and threaded into every fetch_detail() call.
NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere):
@ -40,9 +41,15 @@ Exception triad differs from Avito:
Статус такого прогона 'banned' (#2674, см. runs.mark_backfill_finished):
блок это external constraint, не наш баг, но и НЕ успех раньше здесь стоял
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
No IP-rotation/cooldown recovery step exists here
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
aborted run simply retries the remaining backlog next window.
No IP-rotation/cooldown recovery step exists here -- an aborted run simply
retries the remaining backlog next window.
УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated
residential proxy, not a rotating pool». Это перестало быть правдой на миграции
253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть
отсутствие ротации больше НЕ следует из «ротировать нечего» это просто
непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой
комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по
длительности до блока 111-332 с, получает свой бюджет).
ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch

View file

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

View file

@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
такие URL регуляркой ДО сети это не капча, а предрешённый parseNone. Идут они
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
Но «не по тому URL» «нечего обогащать» (разобрано 2026-08-12, см. комментарий
у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по
собранному из него каноническому URL страница отдаётся и парсится. Поэтому в
очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены:
url_from_offer_id сколько ждёт починки адреса, unenrichable_pending сколько
не адресуемо вообще (ни offer-URL, ни числового source_id).
Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
@ -51,6 +57,8 @@ from app.services.proxy_egress import resolve_proxy_url
logger = logging.getLogger(__name__)
__all__ = [
"CANONICAL_URL_SQL",
"OFFER_ID_PATTERN",
"OFFER_URL_PATTERN",
"YandexDetailBackfillResult",
"run_yandex_detail_backfill",
@ -63,8 +71,7 @@ __all__ = [
#
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
#
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
@ -72,6 +79,39 @@ __all__ = [
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
OFFER_URL_PATTERN = "/offer/[0-9]+"
# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ────────────────
# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на
# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде:
#
# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная;
# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк,
# вставленных после самой свежей его строки, id 2583989), и выйти из него
# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое
# множество и обязано быть константой — вопрос был не «почему не растёт», а
# «правда ли они непригодны».
#
# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId
# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из
# него собирается — это инвариант #2235 (`_canonical_source_url` в
# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
#
# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ
# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной
# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес,
# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки.
#
# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой
# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией:
# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят
# на сайты застройщиков вместо Яндекса.
OFFER_ID_PATTERN = "^[0-9]+$"
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
@dataclass
class YandexDetailBackfillResult:
@ -80,6 +120,11 @@ class YandexDetailBackfillResult:
attempted: int = 0
enriched: int = 0
failed: int = 0
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
# прогону; замер на месте = очередь снова читается не тем признаком.
url_from_offer_id: int = 0
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
unenrichable_pending: int = 0
duration_sec: float = field(default=0.0)
@ -88,6 +133,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"failed": self.failed,
"url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
"duration_sec": int(self.duration_sec),
}
@ -132,51 +178,86 @@ async def run_yandex_detail_backfill(
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
# (см. комментарий у константы): в очередь не берём то, что заведомо
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
# В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо
# сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес
# собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире
# этого условия — гарантированный parse→None пачкой и обрыв по брейкеру.
snapshot = (
db.execute(
text(
"""
SELECT id, source_url
f"""
SELECT id,
CASE
WHEN source_url ~ CAST(:offer_url_pattern AS text)
THEN source_url
ELSE {CANONICAL_URL_SQL}
END AS source_url
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url ~ CAST(:offer_url_pattern AS text)
AND (
(
source_url IS NOT NULL
AND source_url ~ CAST(:offer_url_pattern AS text)
)
OR source_id ~ CAST(:offer_id_pattern AS text)
)
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
# f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля,
# не пользовательский ввод. Всё изменяемое — bind-параметры.
),
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
{
"batch_size": batch_size,
"offer_url_pattern": OFFER_URL_PATTERN,
"offer_id_pattern": OFFER_ID_PATTERN,
},
)
.mappings()
.all()
)
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
counters.unenrichable_pending = int(
db.execute(
text(
"""
SELECT count(*)
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url !~ CAST(:offer_url_pattern AS text)
"""
),
{"offer_url_pattern": OFFER_URL_PATTERN},
).scalar_one()
)
if counters.unenrichable_pending:
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
# разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
# «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
pending = db.execute(
text(
"""
SELECT
count(*) FILTER (
WHERE source_id ~ CAST(:offer_id_pattern AS text)
) AS url_from_offer_id,
count(*) FILTER (
WHERE source_id IS NULL
OR source_id !~ CAST(:offer_id_pattern AS text)
) AS unenrichable_pending
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND (
source_url IS NULL
OR source_url !~ CAST(:offer_url_pattern AS text)
)
"""
),
{"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN},
).one()
counters.url_from_offer_id = int(pending.url_from_offer_id)
counters.unenrichable_pending = int(pending.unenrichable_pending)
if counters.url_from_offer_id:
logger.info(
"yandex_detail_backfill: run_id=%d%d объявлений вне очереди: "
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
"до сети",
"yandex_detail_backfill: run_id=%dу %d объявлений сохранённый "
"source_url не ведёт на карточку Яндекса; адресуем их по offerId из "
"source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)",
run_id,
counters.url_from_offer_id,
)
if counters.unenrichable_pending:
logger.warning(
"yandex_detail_backfill: run_id=%d%d объявлений вне очереди: нет ни "
"offer-URL (%s), ни числового source_id — адресовать их нечем",
run_id,
counters.unenrichable_pending,
OFFER_URL_PATTERN,

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
251_listings_drop_ceiling_height.sql
254_listings_backfill_avito_rating_glued_address.sql
257_listings_backfill_yandex_source_url.sql
258_houses_imv_transient_attempts.sql
259_data_quality_drop_pct_cadastr.sql
260_houses_drop_has_panorama.sql
261_listings_search_mv_drop_placeholder_columns.sql

View file

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

View file

@ -113,9 +113,17 @@ class TestYandexHousePriority:
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
def test_house_has_panorama_yandex_valuation_only(self) -> None:
out = resolve_house_field("has_panorama", {"yandex_valuation": True})
assert out is True
def test_has_panorama_removed_from_house_priority(self) -> None:
"""#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259).
В отличие от ceiling_height выше, это правило было ИСПОЛНИМО колонка
существовала, единственный источник её писал. Разрешать было нечего:
yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде),
потому что слова «панорам» на странице оценки нет вовсе.
"""
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
assert "has_panorama" not in HOUSE_FIELD_PRIORITY
def test_house_yandex_total_listings_yandex_valuation_only(self) -> None:
out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42})
@ -217,7 +225,6 @@ class TestYandexListingPriority:
"corpus_count",
"total_area_ha",
"commission_month",
"has_panorama",
"yandex_total_listings",
]
for key in yandex_keys:

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:
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
# по-настоящему (Postgres + схема из data/sql) — там прогон и был красным на
# origin/main и зелёным на ветке. Статическая половина файла
# (test_retry_queue_is_not_bound_to_only_status) БД не требует и идёт в обоих лэйнах.
tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient

View file

@ -15,6 +15,7 @@ import json
import os
import re
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -25,6 +26,9 @@ sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.tasks.yandex_detail_backfill import ( # noqa: E402
CANONICAL_URL_SQL,
OFFER_ID_PATTERN,
OFFER_URL_PATTERN,
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
@ -55,13 +59,21 @@ def _make_snapshot(n: int) -> list[dict]:
]
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
.scalar_one() размер отброшенной (непарсимой) части очереди."""
def _mock_db(
snapshot: list[dict],
unenrichable: int = 0,
url_from_offer_id: int = 0,
) -> MagicMock:
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() —
остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из
source_id / адресовать нечем)."""
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.scalar_one.return_value = unenrichable
sel.one.return_value = SimpleNamespace(
url_from_offer_id=url_from_offer_id,
unenrichable_pending=unenrichable,
)
db.execute.return_value = sel
return db
@ -510,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
assert result.unenrichable_pending == 3535
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535
# ---------------------------------------------------------------------------
# «Непригодно» — ярлык, а не диагноз (2026-08-12)
# ---------------------------------------------------------------------------
def _render_canonical_sql(offer_id: str) -> str:
"""Считает CANONICAL_URL_SQL как строку: '||' — конкатенация, source_id — значение."""
parts = [p.strip() for p in CANONICAL_URL_SQL.split("||")]
return "".join(offer_id if p == "source_id" else p.strip("'") for p in parts)
def test_recovered_url_equals_producer_canonical_form() -> None:
"""Адрес, вычисленный из source_id, — тот же, что пишет продюсер, и парсер его примет.
Шесть прогонов подряд unenrichable_pending равнялся ровно 3535 не потому, что
счётчик застыл (SELECT живой), а потому что множество замкнуто: продюсер после
#2235 таких строк больше не создаёт, а выйти оттуда нельзя — source_url пишется
только при вставке. Ярлык «непригодны» был неверен: у всех есть offerId, и по
собранному из него URL страница парсится (прод-проба 2026-08-12, 6/6).
Сторож держит ровно это: формула восстановления в SQL не должна разъехаться с
`_canonical_source_url` продюсера, а результат пройти гейт парсера.
"""
from scraper_kit.providers.yandex.serp import _canonical_source_url
offer_id = "7416316697684470413" # реальный source_id прод-строки с macroserver.ru
recovered = _render_canonical_sql(offer_id)
assert re.match(OFFER_ID_PATTERN, offer_id)
assert re.search(OFFER_URL_PATTERN, recovered), recovered
for stored_url, is_offer_url in _PROD_QUEUE_HEAD:
if is_offer_url:
continue # у этих сохранённый адрес уже канонический, чинить нечего
assert _canonical_source_url(stored_url, offer_id) == recovered, stored_url
@pytest.mark.asyncio
async def test_pending_counter_split_by_reason() -> None:
"""Остаток очереди делится по ПРИЧИНЕ, и восстановимое не зовётся непригодным.
Одно число на две разные судьбы («адрес чиним» и «адресовать нечем») читается
как «тут делать нечего» так 3535 квартир простояли неделю вне обогащения.
"""
db = _mock_db([], unenrichable=0, url_from_offer_id=3535)
runs = MagicMock()
session_cls, _session = _make_session_ctx([])
with (
patch(_ASYNC_SESSION, session_cls),
patch(_RUNS, runs),
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db, run_id=43, params={"batch_size": 10, "budget_sec": 60}
)
assert result.url_from_offer_id == 3535
assert result.unenrichable_pending == 0
counters = runs.mark_done.call_args.args[2]
assert counters["url_from_offer_id"] == 3535
assert counters["unenrichable_pending"] == 0
# Снапшот-SELECT берёт такие строки в работу по вычисленному адресу, а не
# выбрасывает: без этой ветки они не попадут в очередь никогда.
snapshot_sql = str(db.execute.call_args_list[0].args[0])
assert "OR source_id ~ CAST(:offer_id_pattern AS text)" in snapshot_sql
assert CANONICAL_URL_SQL in snapshot_sql

View file

@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None:
assert "DROP COLUMN IF EXISTS is_outlier" in sql
_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
def _latest_v_data_quality() -> tuple[str, str]:
"""(текст последней миграции, создающей v_data_quality; тело её SELECT).
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
деплой применяет файлы отсортированными, последний по имени последний в проде.
"""
creators = sorted(
p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8"))
)
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
sql = creators[-1].read_text(encoding="utf-8")
hit = _VIEW_MARKER.search(sql)
assert hit is not None
return sql, sql[hit.end() :].split(";")[0]
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
"""Действующее определение v_data_quality (последняя миграция, которая его
создаёт) не упоминает is_outlier.
"""Действующее определение v_data_quality не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
показатель, который не мог быть ненулевым, потому что колонку не писал никто.
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
деплой применяет файлы отсортированными, последний по имени последний в проде.
"""
marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8")))
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
latest = creators[-1].read_text(encoding="utf-8")
hit = marker.search(latest)
assert hit is not None
body = latest[hit.end() :].split(";")[0]
_, body = _latest_v_data_quality()
assert "outliers_flagged" not in body
assert "is_outlier" not in body
def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None:
"""Тот же класс, третий случай: pct_cadastr (мигр. 259).
Считался по listings.cadastral_number кадастру КВАРТИРЫ, которого не отдаёт ни
одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому
показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61%
читался как измеренное качество данных.
Red на origin/main: последний DDL там 222_db_audit_cleanup.sql, в нём строка
`... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте.
Замена источника на listings.building_cadastral_number НЕ починка: та колонка
про ЗДАНИЕ и целиком производная нашего ночного KNN 50 м, который #2674
замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление
в этой витрине.
"""
sql, body = _latest_v_data_quality()
assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality"
assert "cadastral_number" not in body, (
"в витрину подставили другой кадастр — под подписью «доля объявлений с "
"кадастром» это новая ложь вместо старой (см. шапку 259)"
)
# DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана
# выставить его заново, иначе объяснение «почему показателя нет» молча теряется.
if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql):
assert "COMMENT ON VIEW v_data_quality" in sql

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(
("source", "must_contain"),
("source", "must_contain", "path_suffix"),
[
("avito", "www.avito.ru"),
("cian", "ekb.cian.ru"),
("yandex", "realty.yandex.ru"),
("avito", "www.avito.ru", "/robots.txt"),
("cian", "ekb.cian.ru", "/robots.txt"),
("yandex", "realty.yandex.ru", "/robots.txt"),
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
("domclick", "bff-search-web.domclick.ru"),
#
# #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*,
# robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4
# в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по
# нему зелёная ровно тогда, когда свип получает блок.
("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"),
],
)
async def test_probe_asks_the_working_host_of_each_source(
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str
) -> None:
seen: dict[str, Any] = {}
# Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» —
# 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа.
_body = (
'{"result":{"snippetsCount":678}}' if source == "domclick" else "<pre>User-agent: *</pre>"
)
class _Resp:
status_code = 200
text = '{"html": "<pre>User-agent: *</pre>"}'
text = "{}"
@staticmethod
def json() -> dict[str, str]:
return {"html": "<pre>User-agent: *</pre>"}
return {"html": _body}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
@ -196,7 +206,10 @@ async def test_probe_asks_the_working_host_of_each_source(
assert ok is True
assert must_contain in seen["payload"]["url"]
assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
# #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это
# боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть
# нагрузка на площадку остаётся минимальной, а защита — той же, что у работы.
assert path_suffix in seen["payload"]["url"]
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
assert seen["payload"]["source"] == "generic"
@ -233,7 +246,10 @@ async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.Monkey
assert ok is False
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
assert fail_kind == "page"
assert "not robots.txt" in detail
# #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от
# площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика).
# Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили.
assert "no marker 'User-agent'" in detail
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
@ -454,3 +470,72 @@ async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPat
lease = acquire(db, "domclick") # type: ignore[arg-type]
assert lease is not None
release(db, lease.id) # type: ignore[arg-type]
# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ──
@pytest.mark.parametrize(
("api_body", "expect_ok"),
[
# Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом.
# На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True,
# то есть «пара здорова» ровно там, где свип получает блок. Красный на main.
("<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:
"""Session-мок мониторов свежести: один SELECT max(...)."""
"""Session-мок мониторов свежести.
def __init__(self, latest: date | None) -> None:
Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает
ещё время последнего ПОЛНОГО прогона загрузки и её такт именно они, а не
календарный возраст периода, решают, быть ли тревоге.
"""
def __init__(
self,
latest: date | None,
last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC),
interval_days: str = "7",
) -> None:
self._latest = latest
self._last_pull = last_pull
self._interval_days = interval_days
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
result = MagicMock()
result.first.return_value = MagicMock(latest=self._latest)
if "scrape_runs" in sql:
result.first.return_value = MagicMock(last_pull=self._last_pull)
elif "scrape_schedules" in sql:
result.first.return_value = MagicMock(interval_days=self._interval_days)
else:
result.first.return_value = MagicMock(latest=self._latest)
return result
def rollback(self) -> None:
@ -122,10 +140,13 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None:
monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None)
def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие."""
def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
"""Загрузка встала (полный прогон 20 суток назад при такте 7) → событие.
20 суток реальный разрыв прод-истории между полными прогонами 07-17 и 08-06.
"""
_patch_runs(monkeypatch, sber_mon)
db = _FakeMonitorDB(date(2026, 6, 1))
db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC))
with glitchtip_events() as events:
out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type]
@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
assert out["alert"] == 1
assert any(
"sber freshness" in t for t in event_texts(events)
), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает"
), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает"
def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
"""Свежие данные — ни одного события (иначе алерт-усталость)."""
def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет.
Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR:
возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость
от таких событий и делает настоящий отказ незаметным.
"""
_patch_runs(monkeypatch, sber_mon)
db = _FakeMonitorDB(date(2026, 6, 1))
db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон)
with glitchtip_events() as events:
out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type]
run_id=2,
params={},
now=datetime(2026, 6, 20, tzinfo=UTC),
now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC),
)
assert out["alert"] == 0
assert event_texts(events) == []
@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None
params={},
now=datetime(2026, 8, 6, tzinfo=UTC),
)
assert any("sber_price_index пуст" in t for t in event_texts(events))
assert any("у оценщика нет серии" in t for t in event_texts(events))
def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:

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,
):
mock_mappings = MagicMock()
mock_mappings.all.return_value = houses
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
mock_mappings.all.return_value = houses
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
mock_mappings.all.return_value = houses
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)

View file

@ -3,8 +3,6 @@
Каждая правка эпика тест, который краснеет без неё:
подключено:
- houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница
не подтверждена иначе false «не смотрели» выдаётся за false «посмотрели»);
- domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules —
именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным;
- filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash.
@ -15,7 +13,11 @@
читателя и писателя;
- listings.merged_into, house_sources.raw_payload колонки без писателя;
- v_data_quality.price_disagreements_count показатель, который не мог быть
ненулевым.
ненулевым;
- houses.has_panorama (хвост, мигр. 259) признак, которого нет на площадке.
Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя;
подключённый писатель за 7 дней записал 12 значений, все false. Разница между
«проводка оборвана» и «мерить нечего» видна не по коду, а по площадке.
задокументировано:
- BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт —
@ -29,19 +31,13 @@ from __future__ import annotations
import os
import re
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.cian.valuation import _parse_valuation_state
from scraper_kit.providers.yandex.valuation import (
ValuationHistoryItem,
ValuationHouseMeta,
YandexValuationResult,
)
from app.services import estimator
from app.services.estimator import _save_yandex_history_items
REPO_ROOT = Path(__file__).resolve().parents[3]
TRADEIN = REPO_ROOT / "tradein-mvp"
@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql"
# ─────────────────────────────────────────────────────────────────────────────
# Подключено 1/3: houses.has_panorama
# ─────────────────────────────────────────────────────────────────────────────
def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult:
return YandexValuationResult(
address="Екатеринбург, ул. Куйбышева, 106",
offer_category="APARTMENT",
offer_type="SELL",
page=1,
source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test",
house=meta,
history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)],
)
def _panorama_updates(db: MagicMock) -> list[dict]:
"""Параметры всех db.execute, которые обновляли houses.has_panorama."""
found = []
for call in db.execute.call_args_list:
sql = str(call.args[0])
if "has_panorama" in sql and "UPDATE houses" in sql:
found.append(call.args[1])
return found
def test_has_panorama_written_when_page_rendered() -> None:
"""Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой."""
db = MagicMock()
result = _result_with_meta(
ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True)
)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(99, 0.9, "fp"),
):
_save_yandex_history_items(db, result)
updates = _panorama_updates(db)
assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674"
assert updates[0] == {"hid": 99, "panorama": True}
def test_has_panorama_false_written_when_page_rendered() -> None:
"""Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false."""
db = MagicMock()
result = _result_with_meta(
ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False)
)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(7, 0.9, "fp"),
):
_save_yandex_history_items(db, result)
assert _panorama_updates(db) == [{"hid": 7, "panorama": False}]
def test_has_panorama_written_when_page_has_no_history() -> None:
"""Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде.
Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие
страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение
о доме к наличию объявлений отношения не имеет.
"""
db = MagicMock()
result = _result_with_meta(
ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True)
)
result.history_items = []
with patch(
"app.services.estimator.match_or_create_house",
return_value=(42, 0.9, "fp"),
):
assert _save_yandex_history_items(db, result) == 0
assert _panorama_updates(db) == [{"hid": 42, "panorama": True}]
def test_has_panorama_not_written_when_page_unconfirmed() -> None:
"""Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false."""
db = MagicMock()
result = _result_with_meta(ValuationHouseMeta(has_panorama=False))
with patch(
"app.services.estimator.match_or_create_house",
return_value=(5, 0.9, "fp"),
):
_save_yandex_history_items(db, result)
assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали"
def test_has_panorama_not_written_without_house_id() -> None:
"""Дом не сматчился → писать некуда, но и падать нельзя."""
db = MagicMock()
result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16))
with patch(
"app.services.estimator.match_or_create_house",
side_effect=RuntimeError("no house"),
):
_save_yandex_history_items(db, result)
assert _panorama_updates(db) == []
# ─────────────────────────────────────────────────────────────────────────────
# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка
# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка
# ─────────────────────────────────────────────────────────────────────────────
@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None:
# ─────────────────────────────────────────────────────────────────────────────
# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали
# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали
# ─────────────────────────────────────────────────────────────────────────────
@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None:
"asking_to_sold_ratios_tiered",
"asking_to_sold_tier_bounds",
"price_disagreements_count",
# Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета
# источников. Гейт краснеет на любом из трёх, а не только на одном (первая
# редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет).
"has_panorama",
]
offenders: list[str] = []
for path in _live_python_sources():
@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None:
assert "COMMENT ON VIEW v_price_divergence" in sql
def test_has_panorama_dropped_together_with_the_contract_column() -> None:
"""Снос колонки обязан пересобрать market.v_houses и вернуть ему грант.
has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку
не удаляет значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три
части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен.
"""
sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8")
assert "DROP VIEW IF EXISTS market.v_houses" in sql
assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql
assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока"
view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0]
assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой"
# Контракт — обещание стабильности: пересборка не должна заодно потерять соседей.
for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"):
assert kept in view_ddl, f"{kept} пропала из контракта при пересборке"
assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, (
"DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит "
"permission denied на следующем прогоне"
)
def test_price_divergence_is_documented_as_structurally_empty() -> None:
"""Оставленный задел обязан говорить, чем он НЕ является сегодня."""
sql = MIGRATION.read_text(encoding="utf-8")

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

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

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, "_mark_status") as mock_mark,
):
db.execute.return_value.mappings.return_value.all.return_value = houses
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
db.execute.return_value.mappings.return_value.all.side_effect = [[], houses]
result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0)
mock_eval.assert_not_called()

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, без БД):
- fresh: age <= max_age_days (алерта нет);
- stale: age > max_age_days (алерт);
- граница порога (== max_age_days нет алерта; +1 день алерт).
2. check_sber_freshness с FakeDB (fresh / stale / emptymark_failed / кастомный lag).
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте /
загрузка встала / полных прогонов не было вовсе / граница порога.
2. check_sber_freshness с FakeDB прод-реплей 2026-08-12 и двусторонность:
при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой.
3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы
отставшее табло).
4. Свойства миграций 180/212 + регистрацию в kit product_handlers.
Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index):
latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток),
dinamika-tsen-obyavlenii = 2026-05-01 (103);
последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639);
такт загрузки interval_days = 7;
прогон id=37 (05-31) status='done' при {errors: 9, upserted: 0}, за успех НЕ считается.
"""
from __future__ import annotations
@ -22,7 +29,6 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import settings
from app.services.product_handlers import build_product_handlers
from app.tasks import sber_freshness_monitor as mon
@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql"
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
_MAY_2026 = date(2026, 5, 1)
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
# Прод-состояние 2026-08-12.
_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток
_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103
_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639
_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days
_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера
def _now(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, tzinfo=UTC)
def test_fresh_within_max_age() -> None:
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
assert v.stale is False
assert v.age_days == 30
assert v.latest_period == _MAY_2026
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
def test_stale_beyond_max_age() -> None:
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
assert v.stale is True
def test_loader_in_cadence_no_alert_even_at_age_72() -> None:
"""Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим.
После полного прогона наш max(period_month) равен максимуму источника ПО
ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток лаг ПУБЛИКАЦИИ Сбера,
а не наше отставание.
"""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=_LAST_FULL_PULL,
pull_interval_days=_PULL_INTERVAL,
)
assert v.age_days == 72
assert v.pull_lag_days == 6
assert v.stale is False
def test_loader_stalled_alerts_at_the_same_age() -> None:
"""Тот же возраст периода, но полный прогон 20 суток назад → тревога.
20 суток реальный разрыв прод-истории (07-17 08-06) при пороге 2×7=14.
"""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=_now(2026, 7, 23),
pull_interval_days=_PULL_INTERVAL,
)
assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше
assert v.pull_lag_days == 20
assert v.max_pull_lag_days == 14
assert v.stale is True
def test_no_complete_pull_ever_alerts() -> None:
"""Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=None,
pull_interval_days=_PULL_INTERVAL,
)
assert v.stale is True
assert v.pull_lag_days == -1
def test_threshold_boundary_exact_no_alert() -> None:
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
assert v.age_days == 60
"""Ровно на пороге (2 такта) алерта ещё нет — строгое >."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL),
last_complete_pull_at=_LAST_FULL_PULL,
pull_interval_days=_PULL_INTERVAL,
)
assert v.pull_lag_days == 14
assert v.stale is False
def test_threshold_boundary_next_day_alert() -> None:
"""Порог + 1 день (age=61) — первый алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
assert v.age_days == 61
def test_threshold_boundary_next_day_alerts() -> None:
"""Порог + 1 сутки — первый алерт (два такта подряд пропущены)."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1),
last_complete_pull_at=_LAST_FULL_PULL,
pull_interval_days=_PULL_INTERVAL,
)
assert v.pull_lag_days == 15
assert v.stale is True
def test_threshold_follows_pull_cadence() -> None:
"""Порог — производная такта загрузки, а не константа: такт 28 → порог 56."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=_now(2026, 7, 23), # 20 суток
pull_interval_days=28,
)
assert v.max_pull_lag_days == 56
assert v.stale is False # при месячном такте 20 суток — норма
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
class _Row:
def __init__(self, latest: date | None) -> None:
self.latest = latest
def __init__(self, **kw: Any) -> None:
self.__dict__.update(kw)
class _FakeResult:
def __init__(self, latest: date | None) -> None:
self._latest = latest
def __init__(self, row: _Row | None) -> None:
self._row = row
def first(self) -> _Row:
return _Row(self._latest)
def first(self) -> _Row | None:
return self._row
class _FakeDB:
def __init__(self, latest: date | None) -> None:
self._latest = latest
"""Отвечает на три запроса монитора; пригоден и для старой версии кода.
Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра на такой
запрос отдаём максимум по всем табло, ровно как это делал бы Postgres.
"""
def __init__(
self,
latest_by_dash: dict[str, date],
last_pull: datetime | None = _LAST_FULL_PULL,
interval_days: str | None = str(_PULL_INTERVAL),
) -> None:
self._latest_by_dash = latest_by_dash
self._last_pull = last_pull
self._interval_days = interval_days
self.rolled_back = False
self.asked_dashboards: list[str] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
return _FakeResult(self._latest)
sql = str(stmt)
params = params or {}
if "scrape_runs" in sql:
return _FakeResult(_Row(last_pull=self._last_pull))
if "scrape_schedules" in sql:
return _FakeResult(_Row(interval_days=self._interval_days))
if "dash" in params:
self.asked_dashboards.append(params["dash"])
return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"])))
# Старый монитор: max(period_month) по всей таблице.
latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None
return _FakeResult(_Row(latest=latest))
def rollback(self) -> None:
self.rolled_back = True
@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
return calls
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
assert calls["done"] == out
assert calls["failed"] is None
def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB:
"""Прод-состояние 2026-08-12 (оба табло вторички)."""
return _FakeDB(
{"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026},
last_pull=last_pull,
)
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
def test_prod_replay_healthy_loader_silent_source_no_alert(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина.
На main монитор мерил календарь (72 > 60) и писал ERROR двенадцатые сутки
подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации
Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же.
"""
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-07-12 (прод): age=72 > 60 → алерт.
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 1
db = _prod_db()
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["age_days"] == 72
assert out["latest_month"] == 5
# Монитор НЕ падает при алерте — прогон done, а не failed.
assert out["alert"] == 0
assert calls["done"] == out
assert calls["failed"] is None
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None:
"""Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой.
На main оба состояния дают alert=1 (вердикт зависит только от календаря)
сторож не умеет зеленеть, что и было исходным дефектом.
"""
_patch_runs(monkeypatch)
healthy = mon.check_sber_freshness(
_prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type]
run_id=2,
params={},
now=_PROD_NOW,
)
stalled = mon.check_sber_freshness(
_prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type]
run_id=3,
params={},
now=_PROD_NOW,
)
assert healthy["age_days"] == stalled["age_days"] == 72
assert (healthy["alert"], stalled["alert"]) == (0, 1)
def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None:
"""Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит.
Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт
SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь
ровно то состояние, что дал бы прод с одним лишь id=37.
"""
_patch_runs(monkeypatch)
out = mon.check_sber_freshness(
_prod_db(last_pull=None), # type: ignore[arg-type]
run_id=4,
params={},
now=_PROD_NOW,
)
assert out["alert"] == 1
assert out["pull_lag_days"] == -1
def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None:
"""Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице)."""
_patch_runs(monkeypatch)
db = _prod_db()
out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert db.asked_dashboards[0] == "real_estate_deals"
assert (out["latest_year"], out["latest_month"]) == (2026, 6)
def test_falls_back_to_next_dashboard_when_first_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Первое табло пусто → берём следующее, как и оценщик."""
_patch_runs(monkeypatch)
db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026})
out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["latest_month"] == 5
assert out["age_days"] == 103
def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одного табло с данными — оценивать нечего, это сбой монитора."""
calls = _patch_runs(monkeypatch)
db = _FakeDB(None)
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["alert"] == 0
assert calls["done"] is None
assert calls["failed"] is not None
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
"""Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь.
Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180).
С любым её значением вердикт один и тот же порог берётся из такта загрузки.
"""
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
out = mon.check_sber_freshness(
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
) # type: ignore[arg-type]
assert out["alert"] == 1
base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type]
tweaked = mon.check_sber_freshness(
_prod_db(), # type: ignore[arg-type]
run_id=9,
params={"lag_allowance_days": 0},
now=_PROD_NOW,
)
assert base["alert"] == tweaked["alert"] == 0
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
"""Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет."""
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
assert out_exact["age_days"] == threshold
assert out_exact["alert"] == 0
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
assert out_over["alert"] == 1
db = _FakeDB(
{"real_estate_deals": _JUN_2026},
last_pull=_now(2026, 7, 23), # 20 суток
interval_days="28",
)
out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["max_pull_lag_days"] == 56
assert out["alert"] == 0
def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем."""
_patch_runs(monkeypatch)
db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None)
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS
# ── Миграция 180 ──────────────────────────────────────────────────────────────
@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None:
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
def test_migration_180_lag_allowance_25() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "lag_allowance_days" in sql
assert "25" in sql
def test_migration_180_no_psycopg_trap() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert not re.search(r":\w+::", sql)
# ── Миграция 212: такт загрузки не должен пересекать порог монитора ───────────
# ── Миграция 212: такт загрузки = источник порога ─────────────────────────────
#
# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74
# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника.
# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора.
# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят
# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при
# полном прогоне шестидневной давности, потолок 53 держался бы только если бы
# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода
# не должен расходиться с тактом, который сеет миграция.
def test_migration_212_makes_pull_cadence_weekly() -> None:
@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None:
assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type)
def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None:
"""Инвариант: пол возраста + такт загрузки < порога монитора.
Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог =
sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 запас
7 суток. При прежних 28: 46+28=74 > 60 тревога каждый цикл, что и наблюдали.
"""
def test_default_pull_interval_matches_migration_212() -> None:
"""Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой."""
interval_days = int(
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1)
)
observed_floor_days = 46
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
assert observed_floor_days + interval_days < threshold, (
f"такт {interval_days}д даёт потолок возраста "
f"{observed_floor_days + interval_days}д при пороге {threshold}д — "
"монитор снова будет мерить наш такт, а не застой источника"
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr]
)
assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days
# ── Один порог, а не два ──────────────────────────────────────────────────────
def test_no_second_calendar_threshold_in_settings() -> None:
"""#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться."""
from app.core.config import settings
assert not hasattr(settings, "sber_index_max_age_days")
# ── Регистрация в kit registry ─────────────────────────────────────────────────

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState,
import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
import { dealsAsOfLabel } from "@/lib/rosreestr";
import { HeroTransparency } from "./HeroTransparency";
interface Props {
@ -175,6 +176,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
const showDkp =
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0;
// #2846: возраст ОТОБРАННЫХ сделок. Раньше здесь стояло «за {period_months}
// мес» — окно ПОИСКА, выданное за период данных: «12 мес» читалось как «сделки
// по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026.
// null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает.
const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter");
// Progressive enrichment state
const needsHouseType = estimate.house_type === null;
@ -547,6 +553,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</span>
<span className="bench-chip__sub mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} сд.
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
</span>
</div>
)}
@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{formatMln(dkp.low_ppm2 * dkpArea)} {formatMln(dkp.high_ppm2 * dkpArea)}
</div>
<div className="price-figure-perm2 mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> за{" "}
{dkp.period_months} мес
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr>
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
</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";
import { tokens } from "./tokens";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
import { dealsAsOfLabel } from "@/lib/rosreestr";
// Total number of source slots — meta "N / M". Single source of truth is
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
@ -599,12 +600,27 @@ interface DealTier {
medianPpm: number;
count: number;
bars: number[];
/** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */
asOf: string | null;
}
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] {
let best: AnalogLot | null = null;
for (const l of lots) {
if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l;
}
return [best?.listing_date ?? null, best?.date_precision ?? null];
}
/**
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
* real totals, then (2) the dkp_corridor /м² × area, then (3) the estimate's
* own actual_deals. null when no deal data at all.
*
* #2846: каждая ветка тянет СВОЙ as-of три ветки это три РАЗНЫЕ выборки
* (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до
* города), и подпись обязана описывать ту, чьи числа сейчас на экране.
*/
function resolveDealTier(
e: AggregatedEstimate,
@ -621,6 +637,9 @@ function resolveDealTier(
// same way the ads card does — otherwise a single mis-scraped lot bins
// over [min,max] and crushes the real deals into the left buckets.
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
// так что максимум по ним = максимум по всей выборке, не по показанным.
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
};
}
const area = e.area_m2;
@ -636,6 +655,9 @@ function resolveDealTier(
bars: bins8(
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
),
// Не из actual_deals: гистограмма заимствует их только как форму, а
// числа карточки (медиана/границы) — из коридора, у которого своя выборка.
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
};
}
if (e.actual_deals.length > 0) {
@ -652,6 +674,7 @@ function resolveDealTier(
count: e.actual_deals.length,
// Fix #1/#8: same high-outlier guard as the ads card (see above).
bars: bins8(guardPriceOutliers(ppms).clean),
asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
};
}
return null;
@ -1135,11 +1158,26 @@ export function mapResultPanel(
range: dealTier
? rangeLine(dealTier.loRub, dealTier.hiRub)
: "нет данных",
// #2846: хвост «· по I кв. 2026» — возраст ОТОБРАННЫХ сделок. Слово
// «ФАКТИЧЕСКИЕ» в заголовке читается как «недавние», а свежайшая пачка
// Росреестра на проде — I кв. 2026; молчание тут обещало свежесть,
// которой нет. Отдельной строки не заводим: ppm-строка по контракту
// ResultCard уже носит хвост «· N сделок», третий факт про тот же набор
// встаёт в тот же ряд и читается одним взглядом.
// dealTier == null (сделок нет) → «—», подписи нет: без чисел датировать
// нечего, а «по I кв. 2026» под прочерком выглядело бы как скрытые данные.
ppm: dealTier
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
dealTier.count,
["сделка", "сделки", "сделок"],
)}`
? [
fmtPpm(dealTier.medianPpm),
`${dealTier.count} ${pluralRu(dealTier.count, [
"сделка",
"сделки",
"сделок",
])}`,
dealTier.asOf,
]
.filter(Boolean)
.join(" · ")
: "—",
bars: dealTier ? dealTier.bars : [],
nav: 1,

View file

@ -1,6 +1,41 @@
export const ROSREESTR_REQUEST_URL =
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
const ROMAN_QUARTER = ["I", "II", "III", "IV"] as const;
/**
* #2846: «по I кв. 2026» до какого момента доходят ПОКАЗАННЫЕ ДКП-сделки.
*
* Гранулярность квартал, и это не округление ради красоты: Росреестр
* публикует ДКП пачками, deal_date = первый день квартала (бэкенд помечает это
* `date_precision: "quarter"`, #1995). Прод-замер 2026-08-12: 96 974 сделки,
* 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно {01,04,07,10}.
* Поэтому «223 дня назад» было бы ложной точностью в сторону состаривания:
* сделка из пачки «2026-01-01» могла случиться и 31 марта. «по I кв. 2026»
* ровно то, что известно, и ни бита сверх.
*
* «по» (а не «за») намеренно: выборка обычно накрывает несколько кварталов,
* и подпись называет ВЕРХНЮЮ границу свежести, а не период целиком.
*
* @param iso ISO-дата свежайшей сделки ВЫБОРКИ (не всей таблицы).
* @param precision 'day' у источника с настоящей датой; иначе квартал.
* @returns null, если датировать нечего подпись тогда не рисуется вовсе.
*/
export function dealsAsOfLabel(
iso: string | null | undefined,
precision?: "day" | "quarter" | null,
): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
// ISO date-only ("2026-01-01") парсится как UTC-полночь — читаем UTC-поля,
// иначе в отрицательных зонах квартал уезжает на предыдущий.
if (precision === "day") {
return `по ${String(d.getUTCMonth() + 1).padStart(2, "0")}.${d.getUTCFullYear()}`;
}
return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`;
}
export async function openRosreestrWithAddress(address: string): Promise<void> {
try {
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)
// 'T1_per_street' — street-level only (default for all rosreestr deals)
tier?: string | null;
// #1995 (бэкенд уже слал, фронт игнорировал; поднято #2846): честная точность
// listing_date. 'quarter' — rosreestr: дата = первый день КВАРТАЛА, а не день
// сделки. 'day' — объявления. undefined у старых оценок и когда source неизвестен.
date_precision?: "day" | "quarter" | null;
// ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional +
// nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем
// пины с null lat/lon.
@ -153,7 +157,12 @@ export interface DkpCorridor {
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: number; // медиана ₽/м²
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
period_months: number; // окно поиска сделок
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
// #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность
// — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
// посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
latest_deal_date?: string | null;
}
export interface AggregatedEstimate {

View file

@ -61,7 +61,22 @@ _PROBE_URLS: dict[str, str] = {
"avito": "https://www.avito.ru/robots.txt",
"cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url
"yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL
"domclick": "https://bff-search-web.domclick.ru/robots.txt",
# #2855: боевой ЗАЩИЩЁННЫЙ путь, а не robots.txt того же хоста. QRATOR закрывает
# /api/offers/*, а robots.txt отдаётся свободно — проба по нему возвращала 200 ровно
# тогда, когда свип с того же узла получал блок-страницу. Замер 13.08: за сутки
# 64 проверки пар и 5 банов на ВСЕ площадки, при этом свип Домклика блокировался
# каждые сутки; 04:30 проба «pair_banned=0» → 05:02 свип «QRATOR block».
# Взят count-эндпоинт: та же семья путей, что у работы (providers/domclick/serp.py
# ::_build_count_url), но ответ — одно число, без пагинации и без выдачи, то есть
# проба остаётся дешёвой для площадки (тот же довод, по которому здесь /fetch, а
# не /fetch-json). Параметры продублированы намеренно: импорт провайдера сюда дал
# бы цикл (providers/* уже импортируют browser_fetcher).
"domclick": (
"https://bff-search-web.domclick.ru/api/offers/count/v1"
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
"&deal_type=sale&category=living&offer_type=flat&rooms=st"
"&sort=qi&sort_dir=desc"
),
}
_PROXY_PROBE_URL: str = _PROBE_URLS["avito"]
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
@ -75,6 +90,11 @@ PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
# отказ с кодом 200».
_PROBE_CONTENT_MARKER: str = "User-agent"
# #2855: источник, чья проба идёт НЕ по robots.txt, требует своего признака «ресурс
# отдан». У count-эндпоинта Домклика это ключ ответа `snippetsCount` — он есть в живом
# JSON и отсутствует в блок-странице QRATOR. Держим отдельной картой, а не условием в
# теле: добавить площадку с боевым путём должно быть одной строкой, а не веткой.
_PROBE_CONTENT_MARKERS: dict[str, str] = {"domclick": "snippetsCount"}
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
@ -220,14 +240,16 @@ async def probe_proxy_via_browser(
html = ""
if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html"
if _PROBE_CONTENT_MARKER not in html:
# 200 и непустое тело, но это не robots.txt — площадка отдала заглушку
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»).
marker = _PROBE_CONTENT_MARKERS.get(source, _PROBE_CONTENT_MARKER)
if marker not in html:
# 200 и непустое тело, но это не запрошенный ресурс — площадка отдала заглушку
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»;
# 13.08: Домклик отдаёт блок-страницу QRATOR на /api/offers/*, #2855).
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
return (
False,
"page",
f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}",
f"no marker {marker!r} (html_len={len(html)}): {' '.join(html.split())[:120]}",
)
return True, None, f"html_len={len(html)}"

View file

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

View file

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

View file

@ -497,6 +497,132 @@ def _claim_run(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext)
return run_id
# ── подхват чекпоинта оборванного прогона (#930, вторая половина) ────────────
# #930 сделал чекпоинт (`counters.done_buckets`) и приёмную сторону
# (`run_*_full_load(resume_run_id=...)`), но единственным входом оставил админку. У
# avito full-load её нет вовсе, поэтому 299 из 433 «впустую перебранных» корзин за 90
# суток не имели НИКАКОГО пути возобновления, даже ручного. Планировщик передавал
# resume_run_id=None литералом.
#
# Точка берётся только когда выполнены ВСЕ условия ниже; иначе прогон честно начинает с
# нуля, а ПРИЧИНА пишется в его counters (молчаливый отказ неотличим от отсутствия
# правки — см. _resume_decision).
# 'zombie' НЕ в списке НАМЕРЕННО. reap_zombies снимает пометку 'running', но НЕ убивает
# процесс (прямо задокументировано в app/tasks/listing_source_snapshot.py) — а
# has_running_run гейтит claim именно по статусу. То есть после reap'а старый сборщик
# может продолжать писать в ту же строку: подхват читал бы ДВИЖУЩУЮСЯ точку и запускал
# второй сборщик на ту же площадку. 147 корзин в 10 zombie-прогонах за 90 суток
# остаются несобранными сознательно — это цена, а не недосмотр.
# 'failed' в списке: его чекпоинт больше не стирается финализатором (runs.py, мерж
# counters), а причина отказа («наш баг») ничего не говорит о полноте УЖЕ записанных
# корзин — они записаны тем же heartbeat'ом, что и у banned.
_RESUME_STATUSES = frozenset({"banned", "cancelled", "failed"})
# Длина цепочки возобновлений. Не круглое число: STALE_DIGEST_INTERVAL_FACTOR (=3) —
# уже существующий в этом файле порог «источник не собирал дольше 3× своего такта =
# сломан». Цепочка не имеет права отодвинуть полный обход дальше этой же черты,
# поэтому подряд идущих подхватов допускается на один меньше: прогоны 1 и 2 могут
# продолжать предшественника, третий обязан пойти с нуля. При такте avito 7 суток это
# гарантирует попытку полного обхода не реже, чем раз в 21 сутки — ровно в тот момент,
# когда сводка объявляет источник просроченным.
_MAX_RESUME_CHAIN = STALE_DIGEST_INTERVAL_FACTOR - 1
# Кандидат — ПОСЛЕДНИЙ прогон источника, а не последний подходящий: если после обрыва
# уже прошёл полный ('done') прогон, дерево обойдено и возобновлять нечего. Строки
# 'skipped' — бухгалтерия планировщика, а не прогоны, поэтому не в счёт.
_RESUME_CANDIDATE_SQL = text("""
WITH cur AS (
SELECT source, params FROM scrape_runs WHERE id = CAST(:rid AS bigint)
)
SELECT r.id AS prev_id,
r.status AS prev_status,
r.counters AS prev_counters,
(r.params IS NOT DISTINCT FROM cur.params) AS same_params,
EXTRACT(EPOCH FROM (clock_timestamp() - r.heartbeat_at)) / 3600.0 AS age_h,
cur.params ->> 'interval_days' AS interval_days
FROM scrape_runs r, cur
WHERE r.source = cur.source
AND r.id <> CAST(:rid AS bigint)
AND r.status <> 'skipped'
ORDER BY r.started_at DESC
LIMIT 1
""")
def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]:
"""Решение «подхватывать ли точку» + счётчики-объяснение. Чистая функция.
Возвращает (resume_run_id | None, counters-заготовка нового прогона). Причина
отказа машиночитаемый слаг в `resume_reason`, по нему «предыдущего прогона не
было» отличается от «параметры разъехались» ЗАПРОСОМ, а не грепом логов.
Срок годности точки такт источника ПЛЮС сутки (`interval_days` из его же params).
Ни одно из слагаемых не выбрано произвольно. Такт объявленный самим расписанием
срок, в течение которого собранное считается свежим; точка старше него пережила
цикл, в котором источник обязан был обойти дерево целиком. Сутки сетка запуска:
`compute_next_run_at` выбирает ДЕНЬ (сегодня+interval_days) и случайное время внутри
окна, поэтому два соседних запуска отстоят друг от друга на interval_days ± меньше
суток. Без этого слагаемого точку отвергал бы jitter расписания, а не устаревание:
прогон 3547 убит деплоем 09.08 16:53, расписание 139 подхватит его 16.08 13:37
164.6 ч при такте 168 ч, запас 3.4 ч при ширине окна 2 ч. Пропущенный цикл в окно
всё равно не влезает: для avito это 13 суток против порога 8.
"""
if row is None:
return None, {"resume_from": None, "resume_reason": "no_prev_run", "resume_chain": 0}
prev_counters = row.prev_counters if isinstance(row.prev_counters, dict) else {}
done_buckets = prev_counters.get("done_buckets")
done_n = len(done_buckets) if isinstance(done_buckets, list) else 0
chain_raw = prev_counters.get("resume_chain")
prev_chain = chain_raw if isinstance(chain_raw, int) else 0
verdict: dict[str, Any] = {
"resume_from": None,
"resume_candidate": int(row.prev_id),
"resume_buckets": done_n,
"resume_chain": 0,
}
if row.prev_status not in _RESUME_STATUSES:
verdict["resume_reason"] = f"status_{row.prev_status}"
elif not row.same_params:
verdict["resume_reason"] = "params_changed"
elif done_n == 0:
verdict["resume_reason"] = "no_checkpoint"
elif row.age_h is None or float(row.age_h) > 24.0 * (
_schedule_interval_days(row.interval_days) + 1
):
verdict["resume_reason"] = "checkpoint_stale"
elif prev_chain >= _MAX_RESUME_CHAIN:
verdict["resume_reason"] = "chain_limit"
else:
verdict["resume_from"] = int(row.prev_id)
verdict["resume_reason"] = "ok"
verdict["resume_chain"] = prev_chain + 1
return int(row.prev_id), verdict
return None, verdict
def _pick_resume(db: Session, run_id: int) -> int | None:
"""Чекпоинт какого прогона наследует `run_id` (или None) + запись вердикта.
Тождество задания сверяется РОВНО по тем полям, которыми задание задаётся: source
(кандидат ищется в пределах одного source) и params целиком, побайтово. Кандидат
сравнивается с ТЕКУЩИМ прогоном, а не с расписанием, потому что именно params
прогона поехали в pipeline. На проде за 90 суток 89 корзин из 433 (21%) лежат в
прогонах, чьи params отличаются от следующего по ним пропуск был бы неверным:
`incremental_days` меняет СМЫСЛ ключа (дочитано до watermark бакет перебран), а
`price_cap_per_bucket` меняет само дерево бисекции, то есть какие ключи существуют.
"""
row = db.execute(_RESUME_CANDIDATE_SQL, {"rid": run_id}).fetchone()
resume_run_id, verdict = _resume_decision(row)
# Вердикт кладём в counters НОВОГО прогона: `update_heartbeat` мержит jsonb, поэтому
# последующие heartbeat'ы пайплайна его не затрут и он доживёт до финализатора.
_kit_runs.update_heartbeat(db, run_id, verdict)
logger.info("scheduler: resume run_id=%d%s", run_id, verdict)
return resume_run_id
def _defer_next_run_at(db: Session, schedule_row: dict[str, Any]) -> None:
"""Сдвинуть next_run_at на следующее окно БЕЗ создания run (#1522).
@ -705,7 +831,7 @@ async def _job_avito_full_load(
concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)),
resume_run_id=None,
resume_run_id=_pick_resume(db, run_id),
incremental_days=incremental_days,
)
@ -725,7 +851,7 @@ async def _job_avito_full_load_exhaustive(
concurrency=int(params.get("concurrency", 5)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
secondary_only=bool(params.get("secondary_only", True)),
resume_run_id=None,
resume_run_id=_pick_resume(db, run_id),
incremental_days=None,
)
@ -796,7 +922,7 @@ async def _job_cian_full_load(
request_delay_sec=float(params.get("request_delay_sec", 4.0)),
enrich_detail=bool(params.get("enrich_detail", False)),
detail_top_n=int(params.get("detail_top_n", 0)),
resume_run_id=None,
resume_run_id=_pick_resume(db, run_id),
)

View file

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

View file

@ -621,8 +621,10 @@ class CianScraper(BaseScraper):
# Страница 1 уже есть (html из probe выше); остальные — параллельно.
sem = asyncio.Semaphore(concurrency)
dropped_pages = 0 if html else 1 # пустой probe-html = страница 1 не собрана
async def _one_page(p: int) -> list[ScrapedLot]:
nonlocal dropped_pages
if p == 1:
# Используем уже полученный HTML от probe
return self._parse_serp_html(html) if html else []
@ -630,6 +632,7 @@ class CianScraper(BaseScraper):
page_html = await self._fetch_page_html(rooms, p, _lo_param, hi)
await asyncio.sleep(self.request_delay_sec)
if page_html is None:
dropped_pages += 1
logger.warning(
"cian: page_html=None %s [%d, %s] page=%d — skipping page",
room_label,
@ -648,6 +651,7 @@ class CianScraper(BaseScraper):
bucket_lots: list[ScrapedLot] = []
for p_idx, res in enumerate(page_results, start=1):
if isinstance(res, BaseException):
dropped_pages += 1
logger.warning(
"cian: page exception %s [%d, %s] page=%d%r",
room_label,
@ -674,8 +678,16 @@ class CianScraper(BaseScraper):
if key:
seen[key] = lot
# ── Полнота бакета: ключ у целиком и частично собранного ОДИНАКОВ ─────
# См. avito/serp.py — те же два пути частичности (выпавшая страница,
# проглоченное gather'ом исключение) плюс признанный hard-cap выше. У cian это
# тяжелее: в SERP-слое НЕТ класса блок-исключения вообще (ban-детект #2625
# агрегатный, на выходе из скрапера), поэтому капча посреди бакета приходит сюда
# как page_html=None и раньше давала «сделанный» бакет из уцелевших страниц.
complete = dropped_pages == 0 and pages_needed <= max_pages
logger.info(
"cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d",
"cian: %s [%d, %s] paginated=%d pages collected=%d dropped_nb=%d unique_total=%d "
"complete=%s dropped_pages=%d",
room_label,
lo,
_hi_repr,
@ -683,11 +695,13 @@ class CianScraper(BaseScraper):
collected_this_bucket,
dropped_nb,
len(seen),
complete,
dropped_pages,
)
# ── on_bucket callback: инкрементальный save ──────────────────────────
if on_bucket is not None and bucket_lots:
res_cb = on_bucket(bucket_key, bucket_lots)
res_cb = on_bucket(bucket_key, bucket_lots, complete)
if inspect.isawaitable(res_cb):
await res_cb

View file

@ -62,7 +62,11 @@ class ValuationHouseMeta(BaseModel):
ceiling_height: float | None = None # in meters, e.g. 2.5
has_lift: bool | None = None
total_objects: int | None = None # 'N объектов' (full archive count)
has_panorama: bool = False # 'Панорама' label present
# #2674 (хвост, мигр. 259): признак «панорама» снят с разбора. Слова «панорам» в
# любом падеже на странице оценки нет вовсе — проверено боевым трактом 13.08.2026
# на трёх адресах ЕКБ (полный HTML ~1.19 МБ, вхождений 0/0/0), при том что мета
# разбиралась целиком (год/этажность/материал/потолки/объекты). Признак не
# «переехал» — его в предметной области нет; на проде 0 true из 1536 страниц.
def validate_match(
self,
@ -331,7 +335,6 @@ class YandexValuationScraper(BaseScraper):
ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None),
has_lift="Лифт" in body_text,
total_objects=int(objects_m.group(1)) if objects_m else None,
has_panorama="Панорама" in body_text,
)
def _parse_history_items(self, tree: HTMLParser, body_text: str) -> list[ValuationHistoryItem]: