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
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:
commit
d7017ebfee
57 changed files with 4857 additions and 664 deletions
|
|
@ -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-сессию.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import (
|
|||
estimate_via_cian_valuation,
|
||||
)
|
||||
from scraper_kit.providers.yandex.valuation import (
|
||||
ValuationHouseMeta,
|
||||
YandexValuationResult,
|
||||
YandexValuationScraper,
|
||||
)
|
||||
|
|
@ -490,6 +489,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
|
|||
return _REPAIR_COEF.get(repair_state, 1.0)
|
||||
|
||||
|
||||
# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
|
||||
_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
|
||||
|
||||
|
||||
def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
|
||||
"""#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
|
||||
|
||||
Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
|
||||
(`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
|
||||
к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
|
||||
и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента
|
||||
с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до
|
||||
×1.081, а сработавший blend возвращает половину этих −6% обратно вверх.
|
||||
|
||||
Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная
|
||||
эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
|
||||
применил к медиане, — чинится расхождение базисов, а не калибровка.
|
||||
|
||||
Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение).
|
||||
"""
|
||||
base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
|
||||
if base <= 0:
|
||||
return 1.0
|
||||
return _repair_coefficient(target_repair) / base
|
||||
|
||||
|
||||
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
|
||||
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
|
||||
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator
|
||||
|
|
@ -963,13 +988,13 @@ def _save_yandex_history_items(
|
|||
|
||||
Batch semantics: single try/except; on any failure the batch rolls back.
|
||||
|
||||
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
|
||||
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
|
||||
без единого объявления в истории, до записи панорамы не доходила — на проде это
|
||||
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
|
||||
переноса: match_or_create_house теперь вызывается и для таких страниц (может
|
||||
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
|
||||
остальных 90% — новых сущностей класс не появляется, появляется недостающая доля.
|
||||
#2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
|
||||
возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
|
||||
в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с
|
||||
историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
|
||||
«панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
|
||||
match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
|
||||
тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
|
||||
"""
|
||||
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
||||
# — stable across re-runs, distinguishes pages for different addresses.
|
||||
|
|
@ -1006,9 +1031,6 @@ def _save_yandex_history_items(
|
|||
result.address,
|
||||
)
|
||||
|
||||
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
|
||||
_save_yandex_house_panorama(db, house_id, result.house)
|
||||
|
||||
if not result.history_items:
|
||||
return 0
|
||||
|
||||
|
|
@ -1093,58 +1115,13 @@ def _save_yandex_history_items(
|
|||
return 0
|
||||
|
||||
|
||||
# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в
|
||||
# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но
|
||||
# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь —
|
||||
# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету.
|
||||
#
|
||||
# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text
|
||||
# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн,
|
||||
# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
|
||||
# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
|
||||
# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
|
||||
def _save_yandex_house_panorama(
|
||||
db: Session,
|
||||
house_id: int | None,
|
||||
meta: ValuationHouseMeta,
|
||||
) -> None:
|
||||
"""Пишет houses.has_panorama по разобранной мете yandex_valuation.
|
||||
|
||||
No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
|
||||
выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за
|
||||
справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
|
||||
только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
|
||||
"""
|
||||
if house_id is None:
|
||||
return
|
||||
if meta.year_built is None and meta.total_floors is None:
|
||||
logger.debug(
|
||||
"yandex_valuation: has_panorama не пишем для house_id=%s — "
|
||||
"страница не подтверждена (нет ни года, ни этажности)",
|
||||
house_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE houses
|
||||
SET has_panorama = CAST(:panorama AS boolean)
|
||||
WHERE id = CAST(:hid AS bigint)
|
||||
AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
|
||||
"""
|
||||
),
|
||||
{"hid": house_id, "panorama": meta.has_panorama},
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
|
||||
house_id,
|
||||
e,
|
||||
)
|
||||
db.rollback()
|
||||
# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
|
||||
# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
|
||||
# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
|
||||
# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
|
||||
# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
|
||||
# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
|
||||
# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
|
||||
|
||||
|
||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
|
||||
|
|
@ -1160,14 +1137,39 @@ def _fetch_house_imv_anchor(
|
|||
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
|
||||
|
||||
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
|
||||
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
|
||||
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
|
||||
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
|
||||
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
|
||||
house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress).
|
||||
скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
|
||||
WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
|
||||
None при любой ошибке / отсутствии house_id / пустой таблице — estimator
|
||||
продолжает на гео-tier'ах (no regress).
|
||||
|
||||
СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
|
||||
~2951 домов, fresh» — неправда по обоим пунктам, #2674):
|
||||
2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) —
|
||||
поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из
|
||||
них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после
|
||||
фикса параметров 2026-08-06, всего 47.
|
||||
Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
|
||||
и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
|
||||
объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
|
||||
(46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
|
||||
«панель» вместо «монолита»).
|
||||
|
||||
ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
|
||||
· Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка
|
||||
находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
|
||||
224 из 224, то есть просто выключил бы механизм.
|
||||
· Массовая переоценка — не чинит корень и упирается в очередь: 1628 из
|
||||
2633 домов уже в imv_status='ok', а батч берёт только pending/
|
||||
transient_error, где сейчас 7144 дома при темпе ~25/прогон.
|
||||
· Корень — не свежесть, а разные базисы ремонта; починено в #2677
|
||||
(`_anchor_repair_factor`), см. ниже.
|
||||
|
||||
Returns dict {recommended_price, lower_price, higher_price, market_count,
|
||||
rooms, area_m2} или None.
|
||||
rooms, area_m2, renovation_type} или None.
|
||||
|
||||
#2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
|
||||
Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
|
||||
(см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
|
||||
"""
|
||||
if target_house_id is None:
|
||||
return None
|
||||
|
|
@ -1177,7 +1179,7 @@ def _fetch_house_imv_anchor(
|
|||
text(
|
||||
"""
|
||||
SELECT recommended_price, lower_price, higher_price,
|
||||
market_count, rooms, area_m2
|
||||
market_count, rooms, area_m2, renovation_type
|
||||
FROM house_imv_evaluations
|
||||
WHERE house_id = CAST(:hid AS bigint)
|
||||
AND recommended_price > 0
|
||||
|
|
@ -1359,6 +1361,80 @@ def _lookup_quarter_indexes(
|
|||
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
|
||||
|
||||
|
||||
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
|
||||
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
|
||||
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
|
||||
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
|
||||
# порог ответ выдан чужой квартал точность покрытие
|
||||
# 15 м 475 26 94.5% 19.8%
|
||||
# 25 м 748 59 92.1% 31.2%
|
||||
# 50 м 1341 271 79.8% 55.9%
|
||||
# 100 м 2090 644 69.2% 87.2%
|
||||
# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
|
||||
# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
|
||||
# точность 92%, и это ровно тот компромисс, который видно в замере, а не
|
||||
# заимствованная у соседнего модуля константа.
|
||||
_TARGET_QUARTER_RADIUS_M = 25.0
|
||||
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
|
||||
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
|
||||
_TARGET_QUARTER_DEG_GATE = 0.0015
|
||||
|
||||
|
||||
def _lookup_target_quarter_by_coords(
|
||||
db: Session,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
) -> str | None:
|
||||
"""Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м).
|
||||
|
||||
Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на
|
||||
проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда.
|
||||
Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number`
|
||||
(tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не
|
||||
к координатам аналога.
|
||||
|
||||
Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен
|
||||
только 3-й компонент кадастрового номера (квартал), а он у соседних зданий
|
||||
одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не
|
||||
полный кадастр, чтобы вызывающий код не принял его за кадастр дома.
|
||||
|
||||
None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет
|
||||
или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M.
|
||||
"""
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
try:
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH p AS (
|
||||
SELECT ST_SetSRID(
|
||||
ST_MakePoint(CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)), 4326) AS g
|
||||
)
|
||||
SELECT cb.cad_num
|
||||
FROM cad_buildings_local cb, p
|
||||
WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision))
|
||||
AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision)
|
||||
ORDER BY cb.geom <-> p.g
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"deg": _TARGET_QUARTER_DEG_GATE,
|
||||
"radius": _TARGET_QUARTER_RADIUS_M,
|
||||
},
|
||||
).first()
|
||||
except Exception as exc:
|
||||
logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc)
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
return _quarter_from_cadastre(str(row[0]))
|
||||
|
||||
|
||||
def _apply_quarter_index(
|
||||
*,
|
||||
base_median_ppm2: float,
|
||||
|
|
@ -1493,7 +1569,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
|
|||
"""#794: monthly {period_month: index_value} for region from sber_price_index.
|
||||
|
||||
Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error.
|
||||
#audit-5a: если latest месяц серии старее sber_index_max_age_days → warning.
|
||||
|
||||
#2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest
|
||||
периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по
|
||||
построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на
|
||||
закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых
|
||||
наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени —
|
||||
нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip
|
||||
(event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor,
|
||||
и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт),
|
||||
а не календарь.
|
||||
"""
|
||||
for dash in SBER_COEFF_DASHBOARDS:
|
||||
try:
|
||||
|
|
@ -1521,21 +1606,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
|
|||
series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
|
||||
if not series:
|
||||
continue
|
||||
# #audit-5a: data-age guard — предупреждаем о stale СберИндексе.
|
||||
latest = max(series)
|
||||
today = datetime.now(tz=UTC).date()
|
||||
age_days = (today - latest).days
|
||||
if age_days > settings.sber_index_max_age_days:
|
||||
logger.warning(
|
||||
"sber_index stale #audit-5a: latest=%s age=%d days"
|
||||
" (> sber_index_max_age_days=%d) region=%s dash=%s"
|
||||
" — time-adjustment may be outdated",
|
||||
latest.isoformat(),
|
||||
age_days,
|
||||
settings.sber_index_max_age_days,
|
||||
region,
|
||||
dash,
|
||||
)
|
||||
return series
|
||||
return {}
|
||||
|
||||
|
|
@ -1700,6 +1770,10 @@ def _fetch_dkp_corridor(
|
|||
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
|
||||
adjusted: list[float] = []
|
||||
factors_applied: list[float] = []
|
||||
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
|
||||
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
|
||||
# заявленную свежесть коридора, не участвуя в его границах.
|
||||
latest_deal: date | None = None
|
||||
for r in rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1711,6 +1785,8 @@ def _fetch_dkp_corridor(
|
|||
factor = _sber_time_factor(series, deal_month)
|
||||
adjusted.append(float(ppm2) * factor)
|
||||
factors_applied.append(factor)
|
||||
if dd is not None and (latest_deal is None or dd > latest_deal):
|
||||
latest_deal = dd
|
||||
ppm2_values = sorted(adjusted)
|
||||
|
||||
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
||||
|
|
@ -1763,6 +1839,7 @@ def _fetch_dkp_corridor(
|
|||
city_rows = []
|
||||
|
||||
city_adjusted: list[float] = []
|
||||
city_latest: date | None = None
|
||||
for r in city_rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1773,6 +1850,8 @@ def _fetch_dkp_corridor(
|
|||
deal_month = date(dd.year, dd.month, 1)
|
||||
factor = _sber_time_factor(series, deal_month)
|
||||
city_adjusted.append(float(ppm2) * factor)
|
||||
if dd is not None and (city_latest is None or dd > city_latest):
|
||||
city_latest = dd
|
||||
|
||||
if len(city_adjusted) > len(ppm2_values):
|
||||
logger.info(
|
||||
|
|
@ -1783,6 +1862,10 @@ def _fetch_dkp_corridor(
|
|||
city,
|
||||
)
|
||||
ppm2_values = sorted(city_adjusted)
|
||||
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||
# выборки, которую на витрине уже никто не видит.
|
||||
latest_deal = city_latest
|
||||
|
||||
if not ppm2_values:
|
||||
return None
|
||||
|
|
@ -1805,6 +1888,7 @@ def _fetch_dkp_corridor(
|
|||
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
||||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||
"period_months": period_months,
|
||||
"latest_deal_date": latest_deal,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2695,6 +2779,9 @@ def _price_from_inputs(
|
|||
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
|
||||
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
|
||||
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
|
||||
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
|
||||
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
|
||||
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
|
||||
target_house_cadnum: str | None,
|
||||
dadata_coarse: bool,
|
||||
geo: GeocodeResult,
|
||||
|
|
@ -3058,6 +3145,7 @@ def _price_from_inputs(
|
|||
anchor_total: int | None = None
|
||||
anchor_higher: int | None = None
|
||||
anchor_label: str | None = None
|
||||
anchor_repair_note: str = ""
|
||||
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
||||
anchor_total = int(imv_anchor["recommended_price"])
|
||||
anchor_higher = (
|
||||
|
|
@ -3076,6 +3164,22 @@ def _price_from_inputs(
|
|||
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
|
||||
),
|
||||
)
|
||||
# #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
|
||||
# не правим), а в blend уходит якорь, приведённый к базису ремонта target'а —
|
||||
# тому же, в котором median_price уже посчитан выше (repair_coef).
|
||||
_rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
|
||||
if _rep_k != 1.0:
|
||||
anchor_total = round(anchor_total * _rep_k)
|
||||
anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
|
||||
anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
|
||||
logger.info(
|
||||
"imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
|
||||
" → anchor %d",
|
||||
imv_anchor.get("renovation_type"),
|
||||
repair_state,
|
||||
_rep_k,
|
||||
anchor_total,
|
||||
)
|
||||
elif imv_eval is not None and imv_eval.recommended_price:
|
||||
anchor_total = int(imv_eval.recommended_price)
|
||||
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
|
||||
|
|
@ -3125,8 +3229,8 @@ def _price_from_inputs(
|
|||
median_price = new_median
|
||||
median_ppm2 = new_ppm2
|
||||
explanation = (explanation or "") + (
|
||||
f" Оценка скорректирована по {anchor_label} "
|
||||
f"({anchor_used / 1_000_000:.1f} млн ₽)."
|
||||
f" Оценка скорректирована по {anchor_label}"
|
||||
f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
|
||||
)
|
||||
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
|
||||
# Диапазон расширяем даже если медиану не двигали.
|
||||
|
|
@ -3164,13 +3268,16 @@ def _price_from_inputs(
|
|||
and median_price > 0
|
||||
and area_m2
|
||||
):
|
||||
# target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по
|
||||
# координатам цели (_lookup_target_quarter_by_coords, вызывающий код).
|
||||
# Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» —
|
||||
# снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал
|
||||
# цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м
|
||||
# от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза
|
||||
# по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой).
|
||||
# Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя
|
||||
# заменить чужим кварталом, он и есть предмет поправки.
|
||||
target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum)
|
||||
if target_quarter is None:
|
||||
for lot in listings_clean:
|
||||
cq = _quarter_from_cadastre(lot.get("building_cadastral_number"))
|
||||
if cq is not None:
|
||||
target_quarter = cq
|
||||
break
|
||||
|
||||
if target_quarter is not None:
|
||||
qindex_result = quarter_index_lookup(target_quarter)
|
||||
|
|
@ -4240,6 +4347,22 @@ async def estimate_quality(
|
|||
# ── Coarse-geo signals ────────────────────────────────────────────────────
|
||||
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
|
||||
|
||||
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
|
||||
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
|
||||
# квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
|
||||
# сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
|
||||
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
|
||||
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
|
||||
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
|
||||
if (
|
||||
settings.estimate_quarter_from_coords_enabled
|
||||
and not target_quarter_cadnum
|
||||
and geo is not None
|
||||
):
|
||||
target_quarter_cadnum = await asyncio.to_thread(
|
||||
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
|
||||
)
|
||||
|
||||
# ── DB-callable wrappers injected into pure pricing ───────────────────────
|
||||
def _ratio_resolver(
|
||||
appm2: float | None,
|
||||
|
|
@ -4284,7 +4407,7 @@ async def estimate_quality(
|
|||
ratio_resolver=_ratio_resolver,
|
||||
quarter_index_lookup=_qi_lookup,
|
||||
quarter_indexes_lookup=_qis_lookup,
|
||||
target_house_cadnum=dadata.house_cadnum if dadata else None,
|
||||
target_house_cadnum=target_quarter_cadnum,
|
||||
dadata_coarse=dadata_coarse,
|
||||
geo=geo,
|
||||
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
||||
|
|
|
|||
|
|
@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
|
|||
|
||||
# ── Page 3: Deals ────────────────────────────────────────────────────────────
|
||||
|
||||
_ROMAN_QUARTER = ("I", "II", "III", "IV")
|
||||
|
||||
|
||||
def deals_as_of_label(estimate: AggregatedEstimate) -> str | None:
|
||||
"""#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки.
|
||||
|
||||
Раньше страница печатала «Период сделок: 08.2025 – 08.2026» — окно ПОИСКА,
|
||||
посчитанное как `today - period_months*30 … today`. Правым концом оно обещало
|
||||
сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде
|
||||
(замер 2026-08-12) — I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно
|
||||
по тем сделкам, из которых страница строит диапазон и таблицу примеров.
|
||||
|
||||
Гранулярность — квартал: rosreestr пишет deal_date = первый день квартала
|
||||
(#1995, ровно то, что помечает AnalogLot.date_precision == "quarter").
|
||||
Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания —
|
||||
сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока
|
||||
такого нет) подписывается месяцем.
|
||||
|
||||
None — сделок нет либо ни у одной нет даты: подписывать нечего.
|
||||
"""
|
||||
dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date]
|
||||
if not dated:
|
||||
return None
|
||||
newest, precision = max(dated, key=lambda p: p[0])
|
||||
if precision == "day":
|
||||
return f"по {newest.strftime('%m.%Y')}"
|
||||
return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}"
|
||||
|
||||
|
||||
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
n_deals = len(estimate.actual_deals)
|
||||
today = dt.date.today()
|
||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
||||
# #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
|
||||
# Реальная граница — as-of по показанным сделкам; окна поиска на странице
|
||||
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
|
||||
deals_as_of = deals_as_of_label(estimate)
|
||||
|
||||
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||||
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
||||
|
|
@ -1597,9 +1627,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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): успех — это
|
||||
# «сделали то, что собирались», а не «не поймали известное исключение».
|
||||
|
|
|
|||
|
|
@ -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): она = «впервые увидели период».
|
||||
"""
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,56 +1,68 @@
|
|||
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
|
||||
"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода).
|
||||
|
||||
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
|
||||
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
|
||||
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
|
||||
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
|
||||
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
|
||||
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
|
||||
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||||
ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846).
|
||||
|
||||
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||||
поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||||
Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток
|
||||
(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по
|
||||
построению: `period_month` — метка ПЕРВОГО числа месяца, поэтому на закрытии месяца
|
||||
возрасту уже ≥30; плюс собственный лаг публикации источника. За 31 сутки прямых
|
||||
наблюдений монитора (07-13 … 08-12, scrape_runs.counters) возраст лежал в 46..76 и
|
||||
НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени — ноль бит.
|
||||
|
||||
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
|
||||
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
|
||||
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
|
||||
застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor)
|
||||
писал ERROR с самого начала — расходилась только эта джоба.
|
||||
Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор
|
||||
мерил не источник, а нашу же пилу. Миграция 212 (такт 28 → 7) обещала потолок
|
||||
возраста ≈46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне
|
||||
загрузки шестидневной давности (08-06, errors=0, upserted=639) — источник просто не
|
||||
опубликовал июль. Двенадцать суток подряд (08-01 … 08-12) монитор писал ERROR при
|
||||
исправной загрузке. Потолок 53 держится, только если источник публикует строго
|
||||
помесячно; он не публикует.
|
||||
|
||||
ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов
|
||||
монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего
|
||||
собственного такта загрузки:
|
||||
13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65
|
||||
17.07 alert=0 age 46 latest=июнь (день загрузки)
|
||||
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же
|
||||
считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок
|
||||
46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать
|
||||
такое до ERROR без починки такта значило бы завести ежедневное ложное событие на
|
||||
две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ
|
||||
такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает
|
||||
«источник/загрузка встали», а не «мы давно не ходили».
|
||||
ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду
|
||||
нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН
|
||||
максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» — это вопрос
|
||||
«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода:
|
||||
|
||||
Порог алерта (документирование выбора):
|
||||
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
||||
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||||
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||||
публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл
|
||||
ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
|
||||
Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
|
||||
~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
|
||||
поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
|
||||
рабочего диапазона — если такт загрузки будут менять, пересчитай потолок
|
||||
(пол + interval_days) и сверь с 60.
|
||||
последний полный прогон свежий → наш max == max источника → источник не публиковал,
|
||||
молчание ПРАВИЛЬНОЕ (возраст = лаг источника);
|
||||
последний полный прогон старый → мы не забрали → тревога про ЗАГРУЗЧИК.
|
||||
|
||||
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||||
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||||
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||||
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||||
ЛОВУШКА: `status='done'` НЕ означает успех — прогон id=37 (2026-05-31) имеет
|
||||
{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий
|
||||
3 табло × 3 региона прошли: errors — счётчик по всему прогону).
|
||||
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи
|
||||
достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||||
оценивать).
|
||||
ПОРОГ — не круглое число, а такт самой загрузки: `scrape_schedules.default_params
|
||||
.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик
|
||||
запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с
|
||||
тактом порог не может: поменяли такт — порог поехал следом. Тревога после
|
||||
MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети)
|
||||
поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14
|
||||
суток; на прод-истории такое состояние ДОСТИЖИМО — разрывы между полными прогонами
|
||||
были 14.8 и 20 суток (05-31→06-15 и 07-17→08-06).
|
||||
|
||||
ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из
|
||||
estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у
|
||||
dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблице маскирует
|
||||
отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же
|
||||
серию — список импортируется из estimator, дублировать его тут нельзя.
|
||||
|
||||
ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а
|
||||
загрузка останется исправной — монитор промолчит: по нашим данным «источник не
|
||||
публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт
|
||||
публикации источника ретроспективно невосстановим — его затёр апсерт
|
||||
(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at
|
||||
не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период»,
|
||||
т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
|
||||
имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026).
|
||||
|
||||
ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
|
||||
LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
|
||||
|
||||
Задача синхронная (DB-only) — запускается kit-scheduler'ом через
|
||||
product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает
|
||||
ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД).
|
||||
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed
|
||||
только если у оценщика вообще нет серии (нечего оценивать).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -62,153 +74,239 @@ from datetime import UTC, date, datetime
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LAG_ALLOWANCE_DAYS",
|
||||
"DEFAULT_PULL_INTERVAL_DAYS",
|
||||
"MISSED_PULL_CYCLES",
|
||||
"SBER_FRESHNESS_PULL_SOURCE",
|
||||
"SberFreshnessVerdict",
|
||||
"check_sber_freshness",
|
||||
"evaluate_sber_freshness",
|
||||
]
|
||||
|
||||
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
|
||||
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
|
||||
DEFAULT_LAG_ALLOWANCE_DAYS = 25
|
||||
# Джоба-загрузчик, чей такт и успешность мы и мониторим.
|
||||
SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull"
|
||||
|
||||
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
|
||||
# sber_price_index для областного уровня.
|
||||
SBER_MONITOR_CITY = "Свердловская область"
|
||||
# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг
|
||||
# окна (поглощаем), 2 = загрузка встала (алерт).
|
||||
MISSED_PULL_CYCLES = 2
|
||||
|
||||
_LATEST_SBER_PERIOD_SQL = text("""
|
||||
# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7).
|
||||
DEFAULT_PULL_INTERVAL_DAYS = 7
|
||||
|
||||
_LATEST_PERIOD_SQL = text("""
|
||||
SELECT max(period_month) AS latest
|
||||
FROM sber_price_index
|
||||
WHERE city = CAST(:city AS text)
|
||||
AND dashboard = CAST(:dash AS text)
|
||||
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
|
||||
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
|
||||
-- estimator._load_sber_index_series.
|
||||
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||||
""")
|
||||
|
||||
# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно:
|
||||
# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без
|
||||
# CAST(... AS int): counters других источников планировщик может отфильтровать позже
|
||||
# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел
|
||||
# оператор > численный.
|
||||
_LAST_COMPLETE_PULL_SQL = text("""
|
||||
SELECT max(finished_at) AS last_pull
|
||||
FROM scrape_runs
|
||||
WHERE source = CAST(:src AS text)
|
||||
AND counters @> CAST('{"errors": 0}' AS jsonb)
|
||||
AND counters -> 'upserted' > CAST('0' AS jsonb)
|
||||
""")
|
||||
|
||||
# Такт загрузки — из той же строки, по которой планировщик считает next_run_at.
|
||||
_PULL_INTERVAL_SQL = text("""
|
||||
SELECT default_params ->> 'interval_days' AS interval_days
|
||||
FROM scrape_schedules
|
||||
WHERE source = CAST(:src AS text)
|
||||
""")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SberFreshnessVerdict:
|
||||
"""Вердикт свежести СберИндекса по max(period_month)."""
|
||||
"""Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта."""
|
||||
|
||||
latest_period: date
|
||||
age_days: int
|
||||
age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги
|
||||
pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было
|
||||
max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки
|
||||
stale: bool
|
||||
|
||||
|
||||
def evaluate_sber_freshness(
|
||||
latest_period: date,
|
||||
now: datetime,
|
||||
max_age_days: int,
|
||||
*,
|
||||
last_complete_pull_at: datetime | None,
|
||||
pull_interval_days: int,
|
||||
) -> SberFreshnessVerdict:
|
||||
"""Чистая логика: устарел ли latest период СберИндекса.
|
||||
"""Чистая логика: отстала ли загрузка от собственного такта.
|
||||
|
||||
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
|
||||
`max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
|
||||
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
|
||||
stale = полных прогонов не было ВООБЩЕ, либо последний старше
|
||||
MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в
|
||||
вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш
|
||||
max(period_month) равен максимуму источника по построению, и его возраст — это
|
||||
лаг ПУБЛИКАЦИИ, на который мы повлиять не можем.
|
||||
"""
|
||||
age_days = (now.date() - latest_period).days
|
||||
stale = age_days > max_age_days
|
||||
max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days
|
||||
if last_complete_pull_at is None:
|
||||
return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True)
|
||||
pull_lag_days = (now - last_complete_pull_at).days
|
||||
return SberFreshnessVerdict(
|
||||
latest_period=latest_period,
|
||||
age_days=age_days,
|
||||
stale=stale,
|
||||
pull_lag_days=pull_lag_days,
|
||||
max_pull_lag_days=max_pull_lag_days,
|
||||
stale=pull_lag_days > max_pull_lag_days,
|
||||
)
|
||||
|
||||
|
||||
def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None:
|
||||
"""Табло, которое возьмёт оценщик, и его latest период.
|
||||
|
||||
Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
|
||||
из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
|
||||
"""
|
||||
for dash in SBER_COEFF_DASHBOARDS:
|
||||
row = db.execute(
|
||||
_LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash}
|
||||
).first()
|
||||
latest = row.latest if row is not None else None
|
||||
if latest is not None:
|
||||
return dash, latest
|
||||
return None
|
||||
|
||||
|
||||
def _pull_interval_days(db: Session) -> int:
|
||||
"""Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS)."""
|
||||
row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first()
|
||||
raw = row.interval_days if row is not None else None
|
||||
try:
|
||||
return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d",
|
||||
raw,
|
||||
DEFAULT_PULL_INTERVAL_DAYS,
|
||||
)
|
||||
return DEFAULT_PULL_INTERVAL_DAYS
|
||||
|
||||
|
||||
def check_sber_freshness(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
params: dict | None = None, # type: ignore[type-arg]
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
|
||||
"""Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании.
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
|
||||
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
|
||||
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
|
||||
и финализирует run.
|
||||
Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона
|
||||
sber_index_pull, такт загрузки из scrape_schedules. Вердикт — чистой функцией.
|
||||
|
||||
Params (default_params jsonb):
|
||||
lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25).
|
||||
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
|
||||
`params` больше ничего не настраивает: порог берётся из такта самой загрузки
|
||||
(унаследованный default_params.lag_allowance_days=25 монитора игнорируется —
|
||||
он кодировал мёртвый календарный порог). `now` инъектируется в тестах.
|
||||
|
||||
Returns counters {latest_year, latest_month, age_days, alert}.
|
||||
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
|
||||
Returns counters {latest_year, latest_month, age_days, pull_lag_days,
|
||||
max_pull_lag_days, alert}.
|
||||
mark_failed только если у оценщика нет серии вообще (нечего оценивать);
|
||||
при алерте прогон помечается done (это монитор, не сбой джобы).
|
||||
"""
|
||||
params = params or {}
|
||||
now = now or datetime.now(UTC)
|
||||
counters: dict[str, int] = {
|
||||
"latest_year": 0,
|
||||
"latest_month": 0,
|
||||
"age_days": 0,
|
||||
"pull_lag_days": -1,
|
||||
"max_pull_lag_days": 0,
|
||||
"alert": 0,
|
||||
}
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
|
||||
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
||||
latest: date | None = row.latest if row is not None else None
|
||||
if latest is None:
|
||||
found = _load_estimator_dashboard(db)
|
||||
if found is None:
|
||||
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
|
||||
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
|
||||
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
|
||||
logger.error(
|
||||
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||||
"(вторичка) — оценить свежесть нельзя",
|
||||
SBER_MONITOR_CITY,
|
||||
"sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
|
||||
"для region=%s (вторичка); оценить нечего",
|
||||
list(SBER_COEFF_DASHBOARDS),
|
||||
SBER_TIME_ADJUST_REGION,
|
||||
)
|
||||
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
|
||||
return counters
|
||||
|
||||
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
|
||||
max_age_days = settings.sber_index_max_age_days + lag_days
|
||||
verdict = evaluate_sber_freshness(latest, now, max_age_days)
|
||||
dashboard, latest = found
|
||||
last_pull_row = db.execute(
|
||||
_LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
|
||||
).first()
|
||||
last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None
|
||||
verdict = evaluate_sber_freshness(
|
||||
latest,
|
||||
now,
|
||||
last_complete_pull_at=last_complete_pull_at,
|
||||
pull_interval_days=_pull_interval_days(db),
|
||||
)
|
||||
|
||||
counters = {
|
||||
"latest_year": latest.year,
|
||||
"latest_month": latest.month,
|
||||
"age_days": verdict.age_days,
|
||||
"pull_lag_days": verdict.pull_lag_days,
|
||||
"max_pull_lag_days": verdict.max_pull_lag_days,
|
||||
"alert": int(verdict.stale),
|
||||
}
|
||||
|
||||
if verdict.stale:
|
||||
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
|
||||
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
|
||||
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
|
||||
logger.error(
|
||||
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||||
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||||
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||||
"проверь sber_index_pull и доступность новых периодов источника",
|
||||
"sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон "
|
||||
"%s (%s суток назад, порог %d = %d такта × %d суток; "
|
||||
"status='done' с errors>0 за успех НЕ считается). "
|
||||
"Наш max(period_month)=%s (табло %s) мог разойтись с источником — "
|
||||
"проверь sber_index_pull: планировщик, сеть, /api/sowa 404",
|
||||
last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ",
|
||||
verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "∞",
|
||||
verdict.max_pull_lag_days,
|
||||
MISSED_PULL_CYCLES,
|
||||
verdict.max_pull_lag_days // MISSED_PULL_CYCLES,
|
||||
latest,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
settings.sber_index_max_age_days,
|
||||
lag_days,
|
||||
dashboard,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
|
||||
"region=%s — алерта нет",
|
||||
"sber freshness: загрузка в такте — последний полный прогон %d суток назад "
|
||||
"(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен "
|
||||
"максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, "
|
||||
"не наше отставание — алерта нет",
|
||||
verdict.pull_lag_days,
|
||||
verdict.max_pull_lag_days,
|
||||
latest,
|
||||
dashboard,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
SBER_MONITOR_CITY,
|
||||
)
|
||||
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
|
||||
"check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d "
|
||||
"pull_lag_days=%d age_days=%d",
|
||||
run_id,
|
||||
latest,
|
||||
dashboard,
|
||||
counters["alert"],
|
||||
counters["pull_lag_days"],
|
||||
counters["age_days"],
|
||||
)
|
||||
return counters
|
||||
|
|
|
|||
|
|
@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
|
|||
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
|
||||
такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они
|
||||
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
|
||||
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
|
||||
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
|
||||
целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
|
||||
|
||||
Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий
|
||||
у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по
|
||||
собранному из него каноническому URL страница отдаётся и парсится. Поэтому в
|
||||
очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены:
|
||||
url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько
|
||||
не адресуемо вообще (ни offer-URL, ни числового source_id).
|
||||
|
||||
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||||
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
||||
|
|
@ -51,6 +57,8 @@ from app.services.proxy_egress import resolve_proxy_url
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"CANONICAL_URL_SQL",
|
||||
"OFFER_ID_PATTERN",
|
||||
"OFFER_URL_PATTERN",
|
||||
"YandexDetailBackfillResult",
|
||||
"run_yandex_detail_backfill",
|
||||
|
|
@ -63,8 +71,7 @@ __all__ = [
|
|||
#
|
||||
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
|
||||
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
|
||||
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
|
||||
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
-- 258_houses_imv_transient_attempts.sql
|
||||
-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
|
||||
--
|
||||
-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
|
||||
-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
|
||||
-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен
|
||||
-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из
|
||||
-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All
|
||||
-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой
|
||||
-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе
|
||||
-- 108 из 108 POST /fetch-json = 200).
|
||||
--
|
||||
-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически
|
||||
-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из
|
||||
-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт
|
||||
-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд.
|
||||
--
|
||||
-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error',
|
||||
-- прежние разрезы по imv_status/imv_error_reason работают как работали, а
|
||||
-- «застряли окончательно» — это
|
||||
-- SELECT count(*) FROM houses
|
||||
-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3;
|
||||
--
|
||||
-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по
|
||||
-- imv_status IN ('pending','transient_error') с сортировкой по
|
||||
-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на
|
||||
-- выборке в тысячи строк.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE houses
|
||||
ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN houses.imv_transient_attempts IS
|
||||
'Сколько раз подряд домовая IMV-оценка падала в transient_error. '
|
||||
'Растёт только на transient_error, обнуляется успехом. '
|
||||
'>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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;
|
||||
179
tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
Normal file
179
tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
-- 260_houses_drop_has_panorama.sql
|
||||
-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области.
|
||||
--
|
||||
-- Dependencies: 031_houses_alter_yandex.sql (завела колонку),
|
||||
-- 154_market_contract_views.sql (внесла её в публичный контракт
|
||||
-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант
|
||||
-- gendesign_reader на этот view).
|
||||
-- Apply after: 258_houses_imv_transient_attempts.sql
|
||||
-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama),
|
||||
-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов
|
||||
-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок
|
||||
-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке
|
||||
-- yandex_valuation — молча проглоченный, но с WARNING в логах.
|
||||
--
|
||||
-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ──────────────────────────────────────
|
||||
-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса.
|
||||
-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.05–12.08.2026:
|
||||
-- страниц ............................................................. 1536
|
||||
-- has_panorama = true .................................................... 0
|
||||
-- has_panorama = false ................................................ 1536
|
||||
-- ключ отсутствует ....................................................... 0
|
||||
-- houses: 9468 строк, has_panorama непустых 12, из них true 0.
|
||||
--
|
||||
-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом
|
||||
-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же
|
||||
-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200:
|
||||
-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель,
|
||||
-- 2,50 м потолки, 46 объектов
|
||||
-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт.
|
||||
-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта
|
||||
-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama /
|
||||
-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную
|
||||
-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице:
|
||||
-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт ·
|
||||
-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») —
|
||||
-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме.
|
||||
--
|
||||
-- Ноль был механически гарантирован самим кодом и о предметной области не говорил
|
||||
-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится
|
||||
-- удалением, а не починкой разбора.
|
||||
--
|
||||
-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только
|
||||
-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не
|
||||
-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной
|
||||
-- истории её тоже не было.
|
||||
--
|
||||
-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ────────────────────────────────
|
||||
-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a
|
||||
-- column later is backward compatible, renaming/removing one is not». Это осознанное
|
||||
-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не
|
||||
-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч.
|
||||
-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель
|
||||
-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и
|
||||
-- никогда не станет ничем другим, — обещать данные, которых не существует.
|
||||
--
|
||||
-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся:
|
||||
-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что
|
||||
-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный
|
||||
-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что
|
||||
-- появится позже, без единого слова в логе.
|
||||
--
|
||||
-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE).
|
||||
-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он
|
||||
-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки
|
||||
-- внешний ETL получил бы permission denied на следующем же прогоне.
|
||||
--
|
||||
-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ──────────────────────────
|
||||
-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не
|
||||
-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в
|
||||
-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий
|
||||
-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать
|
||||
-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут
|
||||
-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено
|
||||
-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание =
|
||||
-- честный красный деплой через 5 секунд, миграция не помечается применённой.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP —
|
||||
-- безопасный re-run.
|
||||
-- - Без CASCADE.
|
||||
-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean),
|
||||
-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false.
|
||||
--
|
||||
-- Критерий приёмки (записан ДО применения):
|
||||
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
|
||||
-- 2. information_schema.columns по houses: has_panorama отсутствует.
|
||||
-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на
|
||||
-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк.
|
||||
-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на
|
||||
-- market.v_houses.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
|
||||
-- и в .claude/rules/sql.md § lock_timeout.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DROP VIEW IF EXISTS market.v_houses;
|
||||
|
||||
ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama;
|
||||
|
||||
-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна
|
||||
-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *.
|
||||
CREATE VIEW market.v_houses AS
|
||||
SELECT
|
||||
id,
|
||||
source,
|
||||
ext_house_id,
|
||||
url,
|
||||
slug,
|
||||
address,
|
||||
full_address,
|
||||
short_address,
|
||||
lat,
|
||||
lon,
|
||||
geom,
|
||||
year_built,
|
||||
house_type,
|
||||
house_class,
|
||||
material_walls,
|
||||
material_floors,
|
||||
series_name,
|
||||
total_floors,
|
||||
total_units,
|
||||
entrances,
|
||||
flat_count,
|
||||
is_emergency,
|
||||
passenger_elevators,
|
||||
cargo_elevators,
|
||||
has_concierge,
|
||||
closed_yard,
|
||||
has_playground,
|
||||
hot_water,
|
||||
heat_supply_type,
|
||||
gas_supply_type,
|
||||
overlap_type,
|
||||
parking_type,
|
||||
infrastructure_summary,
|
||||
infrastructure_walk_distance,
|
||||
developer_name,
|
||||
developer_key,
|
||||
management_company_id,
|
||||
rating,
|
||||
reviews_count,
|
||||
rating_score,
|
||||
rating_string,
|
||||
transport_accessibility_rate,
|
||||
advantages,
|
||||
banks,
|
||||
builders,
|
||||
houses_by_turn,
|
||||
corpus_count,
|
||||
commission_year,
|
||||
commission_month,
|
||||
total_area_ha,
|
||||
cadastral_number,
|
||||
house_fias_id,
|
||||
yandex_jk_id,
|
||||
yandex_jk_slug,
|
||||
cian_internal_house_id,
|
||||
cian_zhk_url,
|
||||
raw_payload,
|
||||
first_seen_at,
|
||||
last_scraped_at
|
||||
FROM public.houses;
|
||||
|
||||
COMMENT ON VIEW market.v_houses IS
|
||||
'Stable public contract over public.houses (#2130). Explicit column list is the '
|
||||
'stability promise — do not SELECT * against the base table from external '
|
||||
'consumers. raw_payload is included because it is read today by gendesign ETL '
|
||||
'#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at '
|
||||
'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama '
|
||||
'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно '
|
||||
'(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).';
|
||||
|
||||
-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155.
|
||||
GRANT SELECT ON market.v_houses TO gendesign_reader;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
226
tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
Normal file
226
tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
Normal 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.3–9.8 % выше базис-согласованной (суммарно +4.13 млн ₽).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
from app.services.estimator import _fetch_house_imv_anchor
|
||||
|
||||
# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки
|
||||
# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и
|
||||
# упали на ЦИФРАХ (красный по существу), а не на collection error.
|
||||
|
||||
# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽.
|
||||
_PPM2 = 150_000.0
|
||||
_AREA = 40.0
|
||||
_BASE_MEDIAN = int(_PPM2 * _AREA)
|
||||
|
||||
|
||||
def _make_listing(price_per_m2: float) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(price_per_m2),
|
||||
"source": "avito",
|
||||
"address": "ЕКБ, ул. Учителей, 18",
|
||||
"price_rub": int(price_per_m2 * _AREA),
|
||||
"price_per_m2": price_per_m2,
|
||||
"area_m2": _AREA,
|
||||
"rooms": 1,
|
||||
"floor": 4,
|
||||
"total_floors": 16,
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"listing_date": None,
|
||||
"days_on_market": None,
|
||||
"source_url": f"https://example.test/{int(price_per_m2)}",
|
||||
"photo_urls": [],
|
||||
"distance_m": 10.0,
|
||||
}
|
||||
|
||||
|
||||
_ANALOGS = [
|
||||
_make_listing(135_000.0),
|
||||
_make_listing(140_000.0),
|
||||
_make_listing(145_000.0),
|
||||
_make_listing(_PPM2),
|
||||
_make_listing(155_000.0),
|
||||
_make_listing(160_000.0),
|
||||
_make_listing(165_000.0),
|
||||
]
|
||||
|
||||
|
||||
def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any:
|
||||
"""estimate_quality со всеми I/O застабленными; домовой якорь форсирован."""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.estimator import estimate_quality
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Учителей, 18",
|
||||
area_m2=_AREA,
|
||||
rooms=1,
|
||||
floor=4,
|
||||
total_floors=16,
|
||||
repair_state=repair_state,
|
||||
)
|
||||
geo = GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(_ANALOGS), False, "S"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")),
|
||||
patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
|
||||
):
|
||||
return await estimate_quality(payload, MagicMock())
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"recommended_price": recommended,
|
||||
"lower_price": int(recommended * 0.97),
|
||||
"higher_price": int(recommended * 1.05),
|
||||
"market_count": 500,
|
||||
"rooms": 1,
|
||||
"area_m2": _AREA,
|
||||
"renovation_type": renovation,
|
||||
}
|
||||
|
||||
|
||||
# ── чистая функция ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_repair_factor_is_ratio_of_the_same_coefficients() -> None:
|
||||
"""Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое."""
|
||||
from app.services.estimator import _anchor_repair_factor
|
||||
|
||||
# строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94)
|
||||
assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94
|
||||
# строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00)
|
||||
assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05
|
||||
# строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10)
|
||||
assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94
|
||||
|
||||
|
||||
def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None:
|
||||
"""Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать».
|
||||
|
||||
Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном
|
||||
ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в
|
||||
1.0 при дрейфе вокабуляра тихо вернул бы дефект — поэтому базис, а не no-op.
|
||||
"""
|
||||
from app.services.estimator import _anchor_repair_factor
|
||||
|
||||
assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94
|
||||
assert _anchor_repair_factor(None, "needs_repair") == 0.94
|
||||
# Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента.
|
||||
assert _anchor_repair_factor(None, None) == 1.0
|
||||
assert _anchor_repair_factor("cosmetic", None) == 1.0
|
||||
assert _anchor_repair_factor(None, "standard") == 1.0
|
||||
|
||||
|
||||
# ── SQL-граница: без колонки правка молча выродится в no-op ──────────────────
|
||||
|
||||
|
||||
def test_anchor_query_selects_renovation_type() -> None:
|
||||
"""`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0.
|
||||
|
||||
Проверка строковая намеренно — это единственная точка, где видно, доедет ли
|
||||
колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону
|
||||
границы получает dict уже от вызывающего и такую регрессию не увидит.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = None
|
||||
_fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA)
|
||||
sql = str(db.execute.call_args[0][0])
|
||||
assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0]
|
||||
|
||||
|
||||
# ── сквозь estimate_quality: цена, которую видит пользователь ────────────────
|
||||
|
||||
|
||||
def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
|
||||
"""«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт.
|
||||
|
||||
median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000
|
||||
— якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000.
|
||||
Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000
|
||||
< 6 486 000 → медиана остаётся 5 640 000 (−8.6 % к старому поведению).
|
||||
"""
|
||||
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
|
||||
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
|
||||
assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
|
||||
"""Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен.
|
||||
|
||||
median = 6 000 000 (coef 1.0). Старый порог 6 900 000 — якорь 7 000 000 его
|
||||
перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro'
|
||||
стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 → медиана не двигается.
|
||||
"""
|
||||
est = _run_estimate(_anchor(7_000_000, "euro"), None)
|
||||
assert est.median_price_rub == _BASE_MEDIAN
|
||||
|
||||
|
||||
def test_blend_still_fires_and_reports_the_rebased_anchor() -> None:
|
||||
"""Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится.
|
||||
|
||||
median = 5 640 000, якорь 'cosmetic' 10 000 000 → в базисе клиента 9 400 000
|
||||
> 6 486 000 → blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении —
|
||||
ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы
|
||||
с карточкой, где показана сырая оценка Avito).
|
||||
"""
|
||||
est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair")
|
||||
assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2
|
||||
explanation = est.confidence_explanation or ""
|
||||
assert "9.4 млн ₽" in explanation
|
||||
assert "приведённой к состоянию ремонта квартиры" in explanation
|
||||
# Карточка Avito остаётся сырой — чужое число мы не правим.
|
||||
assert est.avito_imv is not None
|
||||
assert est.avito_imv.recommended_price == 10_000_000
|
||||
86
tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
Normal file
86
tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
Normal 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"
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 без него невозможен."
|
||||
)
|
||||
|
|
@ -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, "лоты частичного бакета обязаны быть сохранены"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
208
tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
Normal file
208
tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
Normal 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
|
||||
|
|
@ -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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
277
tradein-mvp/backend/tests/test_geocoder_city_substitution.py
Normal file
277
tradein-mvp/backend/tests/test_geocoder_city_substitution.py
Normal 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"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
267
tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
Normal file
267
tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную.
|
||||
|
||||
Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон
|
||||
(`only_status`, дефолт 'pending'). Возможность повтора существовала и была
|
||||
задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026)
|
||||
её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине,
|
||||
которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в
|
||||
'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026.
|
||||
|
||||
Что проверяем — по существу, а не по настройке:
|
||||
* дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами
|
||||
(тот же вызов, что делает расписание) — на старом коде он не попадает;
|
||||
* pending при этом из пакета НЕ вытесняется;
|
||||
* дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а
|
||||
ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его;
|
||||
* дом без пригодных параметров помечается no_params БЕЗ запроса к площадке.
|
||||
|
||||
Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие
|
||||
строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml
|
||||
поднимает Postgres и накатывает схему).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import house_imv_backfill as hib
|
||||
|
||||
# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py).
|
||||
_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь
|
||||
_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём
|
||||
_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью
|
||||
_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса
|
||||
_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS)
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
"""Session к достижимому не-заглушечному Postgres, иначе None."""
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
conn = engine.connect()
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.close()
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _seed(db: Any) -> None:
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
# url/source/ext_house_id — NOT NULL в houses (002_core_tables).
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, "
|
||||
" imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES "
|
||||
"(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, "
|
||||
" 'transient_error', 0, NOW() - INTERVAL '10 days'),"
|
||||
"(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, "
|
||||
" 'transient_error', :cap, NOW() - INTERVAL '10 days'),"
|
||||
"(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, "
|
||||
" 'pending', 0, NULL),"
|
||||
"(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, "
|
||||
" 'pending', 0, NULL)"
|
||||
),
|
||||
{
|
||||
"s": _H_STUCK,
|
||||
"x": _H_EXHAUSTED,
|
||||
"p": _H_PENDING,
|
||||
"n": _H_NO_PARAMS,
|
||||
# Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана
|
||||
# работать и на СТАРОМ коде, иначе красный прогон получится про
|
||||
# отсутствующий атрибут, а не про застрявший дом. Синхронность
|
||||
# литерала и константы стережёт test_transient_attempts_counter_*.
|
||||
"cap": 3,
|
||||
},
|
||||
)
|
||||
# Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS.
|
||||
# source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE.
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, "
|
||||
" house_id_fk, rooms, area_m2, floor, total_floors, house_type) "
|
||||
"VALUES "
|
||||
"(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, "
|
||||
" 2, 52.0, 3, 9, 'brick'),"
|
||||
"(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, "
|
||||
" 2, 52.0, 3, 9, 'brick'),"
|
||||
"(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, "
|
||||
" 2, 52.0, 3, 9, 'brick')"
|
||||
),
|
||||
{
|
||||
"l1": 910601,
|
||||
"l2": 910602,
|
||||
"l3": 910603,
|
||||
"s": _H_STUCK,
|
||||
"x": _H_EXHAUSTED,
|
||||
"p": _H_PENDING,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _cleanup(db: Any) -> None:
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db.rollback()
|
||||
db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)"))
|
||||
db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)})
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
async def _run(db: Any, **kwargs: Any) -> Any:
|
||||
"""Прогнать backfill с замоканным вызовом площадки.
|
||||
|
||||
Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден»,
|
||||
поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен —
|
||||
проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать
|
||||
сайдкар.
|
||||
"""
|
||||
err = hib.IMVAddressNotFoundError("address not found (test)")
|
||||
with (
|
||||
patch.object(hib.settings, "avito_imv_use_browser_fetcher", False),
|
||||
patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)),
|
||||
):
|
||||
return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None:
|
||||
"""Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом.
|
||||
|
||||
На старом коде выборка — `WHERE imv_status = :status` с единственным
|
||||
значением 'pending', поэтому дом остаётся в 'transient_error' навсегда.
|
||||
"""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
if db.execute(_t("SELECT count(*) FROM houses")).scalar():
|
||||
pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
|
||||
try:
|
||||
_seed(db)
|
||||
|
||||
# Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию.
|
||||
result = await _run(db, batch_size=50)
|
||||
|
||||
picked = {
|
||||
r.id: r.imv_status
|
||||
for r in db.execute(
|
||||
_t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
|
||||
{"ids": list(_IDS)},
|
||||
)
|
||||
}
|
||||
assert picked[_H_STUCK] != "transient_error", (
|
||||
"дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет "
|
||||
f"обычного прогона; фактически статус {picked[_H_STUCK]!r} "
|
||||
f"(в пакет прогона попало домов: {result.checked})"
|
||||
)
|
||||
# pending не вытеснен повтором.
|
||||
assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете"
|
||||
# Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл.
|
||||
assert picked[_H_EXHAUSTED] == "transient_error"
|
||||
assert result.retried == 1
|
||||
# Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен.
|
||||
assert picked[_H_NO_PARAMS] == "no_params"
|
||||
assert result.premarked == 1
|
||||
finally:
|
||||
_cleanup(db)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
async def test_explicit_only_status_still_takes_exhausted_houses() -> None:
|
||||
"""Явный only_status — ручной запрос оператора: лимит попыток его не режет."""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
if db.execute(_t("SELECT count(*) FROM houses")).scalar():
|
||||
pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
|
||||
try:
|
||||
_seed(db)
|
||||
|
||||
result = await _run(db, batch_size=50, only_status="transient_error")
|
||||
|
||||
statuses = {
|
||||
r.id: r.imv_status
|
||||
for r in db.execute(
|
||||
_t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"),
|
||||
{"ids": list(_IDS)},
|
||||
)
|
||||
}
|
||||
assert (
|
||||
statuses[_H_EXHAUSTED] != "transient_error"
|
||||
), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит"
|
||||
# Автоповтора поверх явного запроса нет: pending не тронут.
|
||||
assert statuses[_H_PENDING] == "pending"
|
||||
assert result.retried == 0
|
||||
finally:
|
||||
_cleanup(db)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
async def test_transient_attempts_counter_only_counts_transient() -> None:
|
||||
"""Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем.
|
||||
|
||||
Без верхней границы у повтора не было бы выхода; без обнуления успехом дом,
|
||||
однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор.
|
||||
"""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
if db.execute(_t("SELECT count(*) FROM houses")).scalar():
|
||||
pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди")
|
||||
try:
|
||||
_seed(db)
|
||||
assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован"
|
||||
|
||||
def _attempts() -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
_t("SELECT imv_transient_attempts FROM houses WHERE id = :i"),
|
||||
{"i": _H_STUCK},
|
||||
).scalar()
|
||||
)
|
||||
|
||||
hib._mark_status(db, _H_STUCK, "transient_error", "boom")
|
||||
assert _attempts() == 1
|
||||
hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден")
|
||||
assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают"
|
||||
db.execute(
|
||||
_t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"),
|
||||
{"i": _H_STUCK},
|
||||
)
|
||||
db.commit()
|
||||
assert _attempts() == 0
|
||||
finally:
|
||||
_cleanup(db)
|
||||
|
||||
|
||||
def test_retry_queue_is_not_bound_to_only_status() -> None:
|
||||
"""Статика (бежит и без БД): retry-выборка не зависит от параметра only_status.
|
||||
|
||||
Ровно этот дефект и застрял на проде: единственный путь к 'transient_error'
|
||||
шёл через значение, которое никто не передавал.
|
||||
"""
|
||||
retry_sql = str(hib._RETRY_QUEUE_SQL.text)
|
||||
assert "imv_status = 'transient_error'" in retry_sql
|
||||
assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status"
|
||||
assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход"
|
||||
# psycopg v3: только CAST(:x AS type), никогда :x::type.
|
||||
for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)):
|
||||
assert "::" not in sql
|
||||
|
|
@ -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
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
|
||||
"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846).
|
||||
|
||||
Покрывает:
|
||||
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
|
||||
- fresh: age <= max_age_days (алерта нет);
|
||||
- stale: age > max_age_days (алерт);
|
||||
- граница порога (== max_age_days → нет алерта; +1 день → алерт).
|
||||
2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag).
|
||||
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
|
||||
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
|
||||
1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте /
|
||||
загрузка встала / полных прогонов не было вовсе / граница порога.
|
||||
2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность:
|
||||
при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой.
|
||||
3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы
|
||||
отставшее табло).
|
||||
4. Свойства миграций 180/212 + регистрацию в kit product_handlers.
|
||||
|
||||
Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index):
|
||||
latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток),
|
||||
dinamika-tsen-obyavlenii = 2026-05-01 (103);
|
||||
последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639);
|
||||
такт загрузки interval_days = 7;
|
||||
прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,7 +29,6 @@ import pytest
|
|||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.product_handlers import build_product_handlers
|
||||
from app.tasks import sber_freshness_monitor as mon
|
||||
|
||||
|
|
@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
|||
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
|
||||
_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql"
|
||||
|
||||
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
|
||||
_MAY_2026 = date(2026, 5, 1)
|
||||
|
||||
|
||||
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
|
||||
# Прод-состояние 2026-08-12.
|
||||
_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток
|
||||
_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103
|
||||
_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639
|
||||
_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days
|
||||
_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера
|
||||
|
||||
|
||||
def _now(y: int, m: int, d: int) -> datetime:
|
||||
return datetime(y, m, d, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_fresh_within_max_age() -> None:
|
||||
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
|
||||
assert v.stale is False
|
||||
assert v.age_days == 30
|
||||
assert v.latest_period == _MAY_2026
|
||||
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
|
||||
|
||||
|
||||
def test_stale_beyond_max_age() -> None:
|
||||
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
|
||||
assert v.stale is True
|
||||
def test_loader_in_cadence_no_alert_even_at_age_72() -> None:
|
||||
"""Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим.
|
||||
|
||||
После полного прогона наш max(period_month) равен максимуму источника ПО
|
||||
ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера,
|
||||
а не наше отставание.
|
||||
"""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=_LAST_FULL_PULL,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.age_days == 72
|
||||
assert v.pull_lag_days == 6
|
||||
assert v.stale is False
|
||||
|
||||
|
||||
def test_loader_stalled_alerts_at_the_same_age() -> None:
|
||||
"""Тот же возраст периода, но полный прогон 20 суток назад → тревога.
|
||||
|
||||
20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14.
|
||||
"""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=_now(2026, 7, 23),
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше
|
||||
assert v.pull_lag_days == 20
|
||||
assert v.max_pull_lag_days == 14
|
||||
assert v.stale is True
|
||||
|
||||
|
||||
def test_no_complete_pull_ever_alerts() -> None:
|
||||
"""Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=None,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.stale is True
|
||||
assert v.pull_lag_days == -1
|
||||
|
||||
|
||||
def test_threshold_boundary_exact_no_alert() -> None:
|
||||
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
|
||||
assert v.age_days == 60
|
||||
"""Ровно на пороге (2 такта) алерта ещё нет — строгое >."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL),
|
||||
last_complete_pull_at=_LAST_FULL_PULL,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.pull_lag_days == 14
|
||||
assert v.stale is False
|
||||
|
||||
|
||||
def test_threshold_boundary_next_day_alert() -> None:
|
||||
"""Порог + 1 день (age=61) — первый алерт."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
|
||||
assert v.age_days == 61
|
||||
def test_threshold_boundary_next_day_alerts() -> None:
|
||||
"""Порог + 1 сутки — первый алерт (два такта подряд пропущены)."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1),
|
||||
last_complete_pull_at=_LAST_FULL_PULL,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.pull_lag_days == 15
|
||||
assert v.stale is True
|
||||
|
||||
|
||||
def test_threshold_follows_pull_cadence() -> None:
|
||||
"""Порог — производная такта загрузки, а не константа: такт 28 → порог 56."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=_now(2026, 7, 23), # 20 суток
|
||||
pull_interval_days=28,
|
||||
)
|
||||
assert v.max_pull_lag_days == 56
|
||||
assert v.stale is False # при месячном такте 20 суток — норма
|
||||
|
||||
|
||||
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Row:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self.latest = latest
|
||||
def __init__(self, **kw: Any) -> None:
|
||||
self.__dict__.update(kw)
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self._latest = latest
|
||||
def __init__(self, row: _Row | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def first(self) -> _Row:
|
||||
return _Row(self._latest)
|
||||
def first(self) -> _Row | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self._latest = latest
|
||||
"""Отвечает на три запроса монитора; пригоден и для старой версии кода.
|
||||
|
||||
Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой
|
||||
запрос отдаём максимум по всем табло, ровно как это делал бы Postgres.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
latest_by_dash: dict[str, date],
|
||||
last_pull: datetime | None = _LAST_FULL_PULL,
|
||||
interval_days: str | None = str(_PULL_INTERVAL),
|
||||
) -> None:
|
||||
self._latest_by_dash = latest_by_dash
|
||||
self._last_pull = last_pull
|
||||
self._interval_days = interval_days
|
||||
self.rolled_back = False
|
||||
self.asked_dashboards: list[str] = []
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
return _FakeResult(self._latest)
|
||||
sql = str(stmt)
|
||||
params = params or {}
|
||||
if "scrape_runs" in sql:
|
||||
return _FakeResult(_Row(last_pull=self._last_pull))
|
||||
if "scrape_schedules" in sql:
|
||||
return _FakeResult(_Row(interval_days=self._interval_days))
|
||||
if "dash" in params:
|
||||
self.asked_dashboards.append(params["dash"])
|
||||
return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"])))
|
||||
# Старый монитор: max(period_month) по всей таблице.
|
||||
latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None
|
||||
return _FakeResult(_Row(latest=latest))
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
|
@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|||
return calls
|
||||
|
||||
|
||||
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
|
||||
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
|
||||
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
|
||||
assert calls["done"] == out
|
||||
assert calls["failed"] is None
|
||||
def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB:
|
||||
"""Прод-состояние 2026-08-12 (оба табло вторички)."""
|
||||
return _FakeDB(
|
||||
{"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026},
|
||||
last_pull=last_pull,
|
||||
)
|
||||
|
||||
|
||||
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_prod_replay_healthy_loader_silent_source_no_alert(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина.
|
||||
|
||||
На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки
|
||||
подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации
|
||||
Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же.
|
||||
"""
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# @2026-07-12 (прод): age=72 > 60 → алерт.
|
||||
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||
assert out["alert"] == 1
|
||||
db = _prod_db()
|
||||
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["age_days"] == 72
|
||||
assert out["latest_month"] == 5
|
||||
# Монитор НЕ падает при алерте — прогон done, а не failed.
|
||||
assert out["alert"] == 0
|
||||
assert calls["done"] == out
|
||||
assert calls["failed"] is None
|
||||
|
||||
|
||||
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой.
|
||||
|
||||
На main оба состояния дают alert=1 (вердикт зависит только от календаря) —
|
||||
сторож не умеет зеленеть, что и было исходным дефектом.
|
||||
"""
|
||||
_patch_runs(monkeypatch)
|
||||
healthy = mon.check_sber_freshness(
|
||||
_prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type]
|
||||
run_id=2,
|
||||
params={},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
stalled = mon.check_sber_freshness(
|
||||
_prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type]
|
||||
run_id=3,
|
||||
params={},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
assert healthy["age_days"] == stalled["age_days"] == 72
|
||||
assert (healthy["alert"], stalled["alert"]) == (0, 1)
|
||||
|
||||
|
||||
def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит.
|
||||
|
||||
Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт —
|
||||
SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь
|
||||
ровно то состояние, что дал бы прод с одним лишь id=37.
|
||||
"""
|
||||
_patch_runs(monkeypatch)
|
||||
out = mon.check_sber_freshness(
|
||||
_prod_db(last_pull=None), # type: ignore[arg-type]
|
||||
run_id=4,
|
||||
params={},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
assert out["alert"] == 1
|
||||
assert out["pull_lag_days"] == -1
|
||||
|
||||
|
||||
def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице)."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _prod_db()
|
||||
out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert db.asked_dashboards[0] == "real_estate_deals"
|
||||
assert (out["latest_year"], out["latest_month"]) == (2026, 6)
|
||||
|
||||
|
||||
def test_falls_back_to_next_dashboard_when_first_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Первое табло пусто → берём следующее, как и оценщик."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026})
|
||||
out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["latest_month"] == 5
|
||||
assert out["age_days"] == 103
|
||||
|
||||
|
||||
def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ни одного табло с данными — оценивать нечего, это сбой монитора."""
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(None)
|
||||
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||
out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["alert"] == 0
|
||||
assert calls["done"] is None
|
||||
assert calls["failed"] is not None
|
||||
|
||||
|
||||
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь.
|
||||
|
||||
Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180).
|
||||
С любым её значением вердикт один и тот же — порог берётся из такта загрузки.
|
||||
"""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
|
||||
out = mon.check_sber_freshness(
|
||||
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
|
||||
) # type: ignore[arg-type]
|
||||
assert out["alert"] == 1
|
||||
base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
tweaked = mon.check_sber_freshness(
|
||||
_prod_db(), # type: ignore[arg-type]
|
||||
run_id=9,
|
||||
params={"lag_allowance_days": 0},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
assert base["alert"] == tweaked["alert"] == 0
|
||||
|
||||
|
||||
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
|
||||
def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
|
||||
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
|
||||
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
|
||||
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
|
||||
assert out_exact["age_days"] == threshold
|
||||
assert out_exact["alert"] == 0
|
||||
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
|
||||
assert out_over["alert"] == 1
|
||||
db = _FakeDB(
|
||||
{"real_estate_deals": _JUN_2026},
|
||||
last_pull=_now(2026, 7, 23), # 20 суток
|
||||
interval_days="28",
|
||||
)
|
||||
out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["max_pull_lag_days"] == 56
|
||||
assert out["alert"] == 0
|
||||
|
||||
|
||||
def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None)
|
||||
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS
|
||||
|
||||
|
||||
# ── Миграция 180 ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None:
|
|||
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
|
||||
|
||||
|
||||
def test_migration_180_lag_allowance_25() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert "lag_allowance_days" in sql
|
||||
assert "25" in sql
|
||||
|
||||
|
||||
def test_migration_180_no_psycopg_trap() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
# ── Миграция 212: такт загрузки не должен пересекать порог монитора ───────────
|
||||
# ── Миграция 212: такт загрузки = источник порога ─────────────────────────────
|
||||
#
|
||||
# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74
|
||||
# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника.
|
||||
# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора.
|
||||
# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят
|
||||
# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при
|
||||
# полном прогоне шестидневной давности, потолок 53 держался бы только если бы
|
||||
# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода
|
||||
# не должен расходиться с тактом, который сеет миграция.
|
||||
|
||||
|
||||
def test_migration_212_makes_pull_cadence_weekly() -> None:
|
||||
|
|
@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None:
|
|||
assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type)
|
||||
|
||||
|
||||
def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None:
|
||||
"""Инвариант: пол возраста + такт загрузки < порога монитора.
|
||||
|
||||
Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог =
|
||||
sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас
|
||||
7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали.
|
||||
"""
|
||||
def test_default_pull_interval_matches_migration_212() -> None:
|
||||
"""Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой."""
|
||||
interval_days = int(
|
||||
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1)
|
||||
)
|
||||
observed_floor_days = 46
|
||||
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
|
||||
assert observed_floor_days + interval_days < threshold, (
|
||||
f"такт {interval_days}д даёт потолок возраста "
|
||||
f"{observed_floor_days + interval_days}д при пороге {threshold}д — "
|
||||
"монитор снова будет мерить наш такт, а не застой источника"
|
||||
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr]
|
||||
)
|
||||
assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days
|
||||
|
||||
|
||||
# ── Один порог, а не два ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_second_calendar_threshold_in_settings() -> None:
|
||||
"""#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert not hasattr(settings, "sber_index_max_age_days")
|
||||
|
||||
|
||||
# ── Регистрация в kit registry ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]:
|
|||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
сломалась, как только у функции появился второй execute перед вставкой истории.
|
||||
Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
|
|
@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None:
|
|||
assert saved == 0
|
||||
# db.execute не должен вызываться для пустого rows (нет INSERT)
|
||||
assert _history_rows(db) == []
|
||||
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
|
||||
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
|
||||
# ли история по площади.
|
||||
assert db.commit.call_count == 2
|
||||
# Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит
|
||||
# (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259.
|
||||
assert db.commit.call_count == 1
|
||||
db.rollback.assert_not_called()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Unit tests for YandexValuationScraper — anonymous house-history scraper.
|
||||
|
||||
Fixture HTML simulates the Yandex valuation page body text containing:
|
||||
- House meta block (year, floors, type, ceiling, lift, total objects, panorama)
|
||||
- House meta block (year, floors, type, ceiling, lift, total objects)
|
||||
- 2-3 historical offer entries with full structure
|
||||
|
||||
Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг
|
||||
|
|
@ -78,7 +78,6 @@ def test_parse_house_meta_full():
|
|||
assert meta.ceiling_height == 2.50
|
||||
assert meta.has_lift is True
|
||||
assert meta.total_objects == 12
|
||||
assert meta.has_panorama is False
|
||||
|
||||
|
||||
def test_parse_house_meta_no_lift():
|
||||
|
|
@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift():
|
|||
assert meta.ceiling_height == 3.0
|
||||
|
||||
|
||||
def test_parse_house_meta_with_panorama():
|
||||
text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание"
|
||||
def test_parse_house_meta_brick_with_lift():
|
||||
"""#2674 (хвост): фикстура больше не содержит «Панорама».
|
||||
|
||||
Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором;
|
||||
зелёный он был по построению. На настоящей странице оценки этого слова нет —
|
||||
проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов.
|
||||
Остаток теста осмысленный: кирпич + лифт на короткой мета-строке.
|
||||
"""
|
||||
text = "7 объектов Дом 2010 года Лифт Кирпичное здание"
|
||||
meta = YandexValuationScraper._parse_house_meta(text)
|
||||
assert meta.has_panorama is True
|
||||
assert meta.has_lift is True
|
||||
assert meta.house_type == "brick"
|
||||
|
||||
|
|
@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items():
|
|||
Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item.
|
||||
"""
|
||||
text = (
|
||||
"Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт "
|
||||
"Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт "
|
||||
"1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже "
|
||||
"2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]:
|
|||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
сломалась, как только у функции появился второй execute перед вставкой истории.
|
||||
Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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: прогон оборван на середине — прошёл часть комнатных бакетов и
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,11 @@ class ValuationHouseMeta(BaseModel):
|
|||
ceiling_height: float | None = None # in meters, e.g. 2.5
|
||||
has_lift: bool | None = None
|
||||
total_objects: int | None = None # 'N объектов' (full archive count)
|
||||
has_panorama: bool = False # 'Панорама' label present
|
||||
# #2674 (хвост, мигр. 259): признак «панорама» снят с разбора. Слова «панорам» в
|
||||
# любом падеже на странице оценки нет вовсе — проверено боевым трактом 13.08.2026
|
||||
# на трёх адресах ЕКБ (полный HTML ~1.19 МБ, вхождений 0/0/0), при том что мета
|
||||
# разбиралась целиком (год/этажность/материал/потолки/объекты). Признак не
|
||||
# «переехал» — его в предметной области нет; на проде 0 true из 1536 страниц.
|
||||
|
||||
def validate_match(
|
||||
self,
|
||||
|
|
@ -331,7 +335,6 @@ class YandexValuationScraper(BaseScraper):
|
|||
ceiling_height=(float(ceiling_m.group(1).replace(",", ".")) if ceiling_m else None),
|
||||
has_lift="Лифт" in body_text,
|
||||
total_objects=int(objects_m.group(1)) if objects_m else None,
|
||||
has_panorama="Панорама" in body_text,
|
||||
)
|
||||
|
||||
def _parse_history_items(self, tree: HTMLParser, body_text: str) -> list[ValuationHistoryItem]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue