Merge remote-tracking branch 'origin/main' into fix/ptica-best-layouts
All checks were successful
CI / backend-tests (pull_request) Successful in 10m16s
CI Trade-In / changes (pull_request) Successful in 20s
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
CI / openapi-codegen-check (pull_request) Successful in 4m13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-09-17 15:49:02 +05:00
commit cd504c1eb9
77 changed files with 3508 additions and 1021 deletions

View file

@ -63,6 +63,7 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
from scraper_kit.providers.yandex.serp import YandexRealtyScraper from scraper_kit.providers.yandex.serp import YandexRealtyScraper
from scraper_kit.providers.yandex.valuation import YandexValuationScraper from scraper_kit.providers.yandex.valuation import YandexValuationScraper
from scraper_kit.proxy_errors import NoProxyAvailableError
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -516,16 +517,13 @@ async def cian_auto_login(
) )
try: try:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но # #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает # сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам # До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) — # на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле async with build_browser_fetcher(
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError → RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара. ) as fetcher:
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
raw_cookies = await fetcher.login( raw_cookies = await fetcher.login(
url=settings.cian_login_url, url=settings.cian_login_url,
email=email, email=email,
@ -537,6 +535,13 @@ async def cian_auto_login(
pre_click_selectors=settings.cian_login_pre_click_selectors, pre_click_selectors=settings.cian_login_pre_click_selectors,
wait_ms=settings.cian_login_wait_ms, wait_ms=settings.cian_login_wait_ms,
) )
except NoProxyAvailableError as exc:
# Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо.
logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался")
raise HTTPException(
status_code=503,
detail="Нет свободного узла прокси для cian в пуле — логин не запускался",
) from exc
except Exception as exc: except Exception as exc:
logger.error("cian auto-login failed: %s", type(exc).__name__) logger.error("cian auto-login failed: %s", type(exc).__name__)
raise HTTPException( raise HTTPException(
@ -1252,8 +1257,11 @@ async def start_avito_city_sweep(
request_delay_sec=payload.request_delay_sec, request_delay_sec=payload.request_delay_sec,
enrich_imv=payload.enrich_imv, enrich_imv=payload.enrich_imv,
) )
except Exception: except Exception as exc:
logger.exception("city-sweep background task run_id=%d crashed", run_id) logger.exception("city-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally: finally:
sweep_db.close() sweep_db.close()
@ -1344,8 +1352,11 @@ async def start_cian_city_sweep(
detail_top_n=payload.detail_top_n, detail_top_n=payload.detail_top_n,
enrich_houses=payload.enrich_houses, enrich_houses=payload.enrich_houses,
) )
except Exception: except Exception as exc:
logger.exception("cian-sweep background task run_id=%d crashed", run_id) logger.exception("cian-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally: finally:
sweep_db.close() sweep_db.close()
@ -1487,8 +1498,11 @@ async def start_cian_full_load(
resume_run_id=payload.resume_run_id, resume_run_id=payload.resume_run_id,
secondary_only=payload.secondary_only, secondary_only=payload.secondary_only,
) )
except Exception: except Exception as exc:
logger.exception("cian-full-load background task run_id=%d crashed", run_id) logger.exception("cian-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally: finally:
task_db.close() task_db.close()
@ -1590,8 +1604,11 @@ async def start_yandex_full_load(
concurrency=payload.concurrency, concurrency=payload.concurrency,
resume_run_id=payload.resume_run_id, resume_run_id=payload.resume_run_id,
) )
except Exception: except Exception as exc:
logger.exception("yandex-full-load background task run_id=%d crashed", run_id) logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(task_db, run_id, exc)
finally: finally:
task_db.close() task_db.close()
@ -1658,8 +1675,11 @@ async def start_yandex_city_sweep(
request_delay_sec=payload.request_delay_sec, request_delay_sec=payload.request_delay_sec,
enrich_address=payload.enrich_address, enrich_address=payload.enrich_address,
) )
except Exception: except Exception as exc:
logger.exception("yandex-sweep background task run_id=%d crashed", run_id) logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
# до финализатора пайплайна прогон висел 'running' до zombie.
runs_mod.mark_crashed(sweep_db, run_id, exc)
finally: finally:
sweep_db.close() sweep_db.close()

View file

@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import secrets import secrets
@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты. # timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0 _INTERACTIVE_SEND_TIMEOUT_S = 8.0
_INTERACTIVE_SEND_MAX_RETRIES = 1 _INTERACTIVE_SEND_MAX_RETRIES = 1
# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос,
# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор
# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую +
# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0),
# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному
# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и
# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело.
_INTERACTIVE_SEND_DEADLINE_S = 7.0
class GlitchTipAttachment(BaseModel): class GlitchTipAttachment(BaseModel):
@ -234,19 +243,21 @@ async def glitchtip_webhook(
# до api.telegram.org перед каждой отправкой. Живёт в lifespan. # до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client() client = get_telegram_client()
try: try:
await client.send_message( async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
chat_id=settings.telegram_alerts_chat_id, await client.send_message(
text=text, chat_id=settings.telegram_alerts_chat_id,
message_thread_id=settings.telegram_alerts_topic_id or None, text=text,
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не message_thread_id=settings.telegram_alerts_topic_id or None,
# может легально висеть воркерные минуты ретраев. # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
timeout=_INTERACTIVE_SEND_TIMEOUT_S, # может легально висеть воркерные минуты ретраев.
max_retries=_INTERACTIVE_SEND_MAX_RETRIES, timeout=_INTERACTIVE_SEND_TIMEOUT_S,
) max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
except TelegramError: )
except (TelegramError, TimeoutError):
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram — # Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не # тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный # 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого # сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон # текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry). # (#3471, см. app.tasks.glitchtip_alert_retry).

View file

@ -718,6 +718,7 @@ def load_estimate(
_qc_geo_to_precision, _qc_geo_to_precision,
_resolve_target_city, _resolve_target_city,
_source_counts, _source_counts,
analog_tier_from_explanation,
rehydrate_search_radius_m, rehydrate_search_radius_m,
) )
@ -896,6 +897,10 @@ def load_estimate(
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить # осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
# сюда дефолт значило бы выдать догадку за то, что просил пользователь. # сюда дефолт значило бы выдать догадку за то, что просил пользователь.
search_radius_m=search_radius_m, search_radius_m=search_radius_m,
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
) )

View file

@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк). # никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
CORRIDOR_CLAMP_MIN_N = 10
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -378,17 +384,6 @@ class Settings(BaseSettings):
dadata_api_token: str | None = None dadata_api_token: str | None = None
dadata_api_secret: str | None = None dadata_api_secret: str | None = None
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# При отсутствии IMV/Yandex no-op (медиана не меняется).
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на # #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
# данные ЭТОГО адреса, а не на среднее по городу. # данные ЭТОГО адреса, а не на среднее по городу.
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного # Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
@ -408,25 +403,6 @@ class Settings(BaseSettings):
# False ⇒ прежнее поведение целиком, без релиза. # False ⇒ прежнее поведение целиком, без релиза.
estimate_require_local_evidence: bool = True estimate_require_local_evidence: bool = True
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
estimate_sb_floor_sigma: float = 0.25
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ───── # ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
@ -521,128 +497,13 @@ class Settings(BaseSettings):
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30 estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
#
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
estimate_corridor_clamp_min_n: int = 10
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
estimate_corridor_clamp_slack: float = 0.40
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
# (избегаем ложных подъёмов) и ловит явный undershoot.
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_factor: float = 0.8
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
# к point И range (expected_sold_low/high/price) консистентно.
# False -> старое поведение без clamp (backward-compat).
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
estimate_expected_sold_le_asking: bool = Field(
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
)
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
estimate_sb_mad_k_small_n: float = 2.5
estimate_sb_small_n_threshold: int = 10
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
estimate_anchor_tier_c_corridor_mult: float = 1.5
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
estimate_outlier_small_n_threshold: int = 15
estimate_outlier_tukey_k_small: float = 1.0
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
# ratio — не затрагиваются.
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
# ── #audit-3: price_trend freshness filter ──────────────────────────────── # ── #audit-3: price_trend freshness filter ────────────────────────────────
# Исключать items старше N месяцев из price_trend (house_placement_history). # Исключать items старше N месяцев из price_trend (house_placement_history).
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо. # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6 estimate_price_trend_max_age_months: int = 6
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
estimate_manual_review_elite_ppm2: int = 250000
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
# (freshest scraped_at на (source, source_id|source_url|ctid)).
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
# building (building_cadastral_number | нормализованный address)
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
# («лот считается один раз»).
#
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
# 13.89%, coverage 83.33% → 83.33%, bias 3.83% → 3.83%, median width 0.743 →
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%, median width/cv
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
estimate_dedup_analogs_enabled: bool = True
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ── # ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки # Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 / # estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
@ -711,6 +572,16 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
estimate_unknown_attr_penalty_enabled: bool = False estimate_unknown_attr_penalty_enabled: bool = False
estimate_unknown_attr_penalty_min_n: int = 5 estimate_unknown_attr_penalty_min_n: int = 5
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
estimate_tier_h_min_comps: int = 5
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
# #
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
@ -725,20 +596,6 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED. # ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
estimate_is_apartments_filter_enabled: bool = False estimate_is_apartments_filter_enabled: bool = False
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
estimate_wide_corridor_threshold: float = 1.2
# ── Mera-audit fix-1: Cian valuation sanity bounds ──────────────────────── # ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999). # API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется). # sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
@ -763,26 +620,7 @@ class Settings(BaseSettings):
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER). # datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
avito_imv_use_browser_fetcher: bool = False avito_imv_use_browser_fetcher: bool = False
# ── #764: per-cadastral-quarter price index correction ─────────────────── # ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
# аналоги уже несут локацию квартала → skip (location in median).
estimate_quarter_match_skip_ratio: float = 0.6
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
estimate_quarter_index_max_for_small_n: float = 2.0
estimate_quarter_index_small_n_threshold: int = 50
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
@ -971,7 +809,7 @@ class Settings(BaseSettings):
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138) # регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062) # регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50), # Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
# 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают. # 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его # NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод # bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025. # обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
@ -1194,14 +1032,6 @@ class Settings(BaseSettings):
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY. # False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY") avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
# ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None
# ── #639: Cian browser auto-login (Variant B) ──────────────────────────── # ── #639: Cian browser auto-login (Variant B) ────────────────────────────
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый # Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable. # (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
@ -1221,25 +1051,19 @@ class Settings(BaseSettings):
cian_login_success_cookie: str = "DMIR_AUTH" cian_login_success_cookie: str = "DMIR_AUTH"
cian_login_wait_ms: int = 4000 cian_login_wait_ms: int = 4000
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv. # detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv # False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов # True = curl_cffi через settings.scraper_proxy_url.
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
# 1 коннект/запрос) развязывает прокси-аккаунты.
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# #
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR # Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны # curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на # обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято: # curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect. # BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
# Прод переведён на браузер через docker-compose.prod.yml (environment # environment'ом сервиса scraper, а tradein-backend (ручка админки
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение # scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
# других окружений вслепую.
avito_detail_backfill_use_curl: bool = Field( avito_detail_backfill_use_curl: bool = Field(
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
) )
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший # #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший

View file

@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а Это РЕАЛЬНАЯ рыночная оценка Avito по дому служит anchor'ом для blend'а
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на (см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи. ценовой шкале. None если для дома нет свежей IMV-записи.
""" """
@ -163,9 +163,13 @@ class DkpCorridor(BaseModel):
[low,high]×slack добавляется текстовая пометка. [low,high]×slack добавляется текстовая пометка.
None / count=0 если по улице нет сопоставимых сделок. None / count=0 если по улице нет сопоставимых сделок.
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n #3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only. порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
из коридора, а поле обязано быть верным и на GET-rehydrate.
""" """
count: int # число ДКП-сделок в выборке count: int # число ДКП-сделок в выборке
@ -192,10 +196,13 @@ class DkpCorridor(BaseModel):
def advisory_only(self) -> bool: def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены. """#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит (estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=3..9 на экране была неотличима от работающей. цену зона n=1..9 на экране была неотличима от работающей.
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
обе страховки выключены якорем, а не порогом (#3466).
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и (#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
@ -207,9 +214,9 @@ class DkpCorridor(BaseModel):
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом. вторым числом.
""" """
from app.core.config import settings # локально: schemas остаётся import-light from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light
return self.count < settings.estimate_corridor_clamp_min_n return self.count < CORRIDOR_CLAMP_MIN_N
class PriceTrendPoint(BaseModel): class PriceTrendPoint(BaseModel):
@ -344,6 +351,9 @@ class AggregatedEstimate(BaseModel):
# "city" — radius Tier W: широкий fallback # "city" — radius Tier W: широкий fallback
# null — нет данных / оценка не построена # null — нет данных / оценка не построена
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
# #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius)
# восстанавливаются из подписи якорного блока в confidence_explanation
# (estimator.analog_tier_from_explanation), радиусные остаются null.
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались # search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m: # listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:

View file

@ -67,8 +67,12 @@ class BlockRatioBreaker:
ratio_threshold: float ratio_threshold: float
safety_min: int safety_min: int
snapshot_size: int snapshot_size: int
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
# площадки, рвут прогон. 0 — критерий выключен.
infra_streak_limit: int = 0
_window: deque[bool] = field(init=False, repr=False) _window: deque[bool] = field(init=False, repr=False)
_consecutive_blocks: int = field(default=0, init=False) _consecutive_blocks: int = field(default=0, init=False)
_consecutive_infra: int = field(default=0, init=False)
_pure_block_run: bool = field(default=True, init=False) _pure_block_run: bool = field(default=True, init=False)
streak_histogram: Counter[int] = field(default_factory=Counter, init=False) streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
@ -106,10 +110,16 @@ class BlockRatioBreaker:
короче окна рвал бы прогон по тем же infra-отказам, только другим короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают. не считают.
Свой предохранитель у такого отказа серия infra_streak_limit (#3439): без
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
1600, status='done'). Серию гасит любой ответ площадки успех, её блок, 404.
""" """
if kind != BAN_KIND_PLATFORM: if kind != BAN_KIND_PLATFORM:
self._consecutive_infra += 1
self.record_failure() self.record_failure()
return return
self._consecutive_infra = 0
self._consecutive_blocks += 1 self._consecutive_blocks += 1
self._window.append(True) self._window.append(True)
@ -117,6 +127,7 @@ class BlockRatioBreaker:
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя """Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
бы одного успеха -- уже не "чистый с рождения прогона" burst).""" бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
self._flush_streak() self._flush_streak()
self._consecutive_infra = 0
self._pure_block_run = False self._pure_block_run = False
self._window.append(False) self._window.append(False)
@ -131,8 +142,11 @@ class BlockRatioBreaker:
AvitoListingGoneError). Класс написан источник-агностично на будущее (не AvitoListingGoneError). Класс написан источник-агностично на будущее (не
только avito), но сейчас единственный вызывающий -- avito_detail_backfill только avito), но сейчас единственный вызывающий -- avito_detail_backfill
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой (#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
задачи -- своя калибровка, свои ограничения прокси-пула).""" задачи -- своя калибровка, свои ограничения прокси-пула).
return
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
значит наш тракт жив."""
self._consecutive_infra = 0
def _flush_streak(self) -> None: def _flush_streak(self) -> None:
if self._consecutive_blocks: if self._consecutive_blocks:
@ -142,9 +156,11 @@ class BlockRatioBreaker:
def abort_reason(self) -> str | None: def abort_reason(self) -> str | None:
"""Какой критерий требует обрыва прямо сейчас, или None. """Какой критерий требует обрыва прямо сейчас, или None.
Возвращает "safety_net" / "ratio" / None. Состояние не меняет, поэтому Возвращает "infra_streak" / "safety_net" / "ratio" / None. Состояние не меняет,
вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога. поэтому вызывать можно сколько угодно раз -- в том числе повторно, ради текста лога.
""" """
if self.infra_streak_limit > 0 and self._consecutive_infra >= self.infra_streak_limit:
return "infra_streak"
# Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот # Safety-net -- ТОЛЬКО когда ratio-критерий физически недостижим (снапшот
# короче окна), иначе пачка safety_min блоков в начале длинного прогона # короче окна), иначе пачка safety_min блоков в начале длинного прогона
# абортила бы его так же, как до правки (#3184 review MAJOR 2). # абортила бы его так же, как до правки (#3184 review MAJOR 2).
@ -176,6 +192,11 @@ class BlockRatioBreaker:
should_abort(), так что в логи не попадает. should_abort(), так что в логи не попадает.
""" """
reason = self.abort_reason() reason = self.abort_reason()
if reason == "infra_streak":
return (
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
"без единого ответа площадки"
)
if reason == "ratio": if reason == "ratio":
return ( return (
f"доля блоков {self.window_blocks}/{self.window_len} в окне " f"доля блоков {self.window_blocks}/{self.window_len} в окне "

View file

@ -55,7 +55,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings from app.core.config import CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings
from app.core.db import SessionLocal, run_db_thread from app.core.db import SessionLocal, run_db_thread
from app.schemas.trade_in import ( from app.schemas.trade_in import (
AggregatedEstimate, AggregatedEstimate,
@ -283,6 +283,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
return None return None
# #3466: тир якоря → подпись в confidence_explanation и enum ответа (analog_tier).
# Колонки под analog_tier нет, поэтому подпись, которую пишет якорный блок
# _price_from_inputs, — единственное место, где тир попадает в persisted-состояние.
# Отсюда же его читает GET-rehydrate (analog_tier_from_explanation): тот же приём,
# что у радиуса (#2632). Нужен витрине: при same_building коридор ДКП в цену не
# входит вовсе (кламп exempt, radius-floor только без якоря), и без тира ссылка
# ?id= показывала бы такой коридор как работающий.
# ponytail: разбор прозы, а не колонка; колонка analog_tier — когда понадобятся
# радиусные тиры на GET или подпись начнут переписывать мимо этой константы.
ANCHOR_TIER_LABELS: dict[str, str] = {"A": "того же дома", "C": "ближайшего окружения (≤500 м)"}
ANCHOR_API_TIERS: dict[str, str] = {"A": "same_building", "C": "micro_radius"}
def analog_tier_from_explanation(explanation: str | None) -> str | None:
"""Якорный analog_tier сохранённой оценки по подписи якорного блока, иначе None.
Ищется вся фраза «Оценка построена по N аналогам из <тир>», а не хвост «из того
же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)», и это другой
путь цены коридор там клампит. None = радиусный путь либо старая формулировка.
"""
for tier, label in ANCHOR_TIER_LABELS.items():
if re.search(rf"Оценка построена по \d+ аналогам из {re.escape(label)}", explanation or ""):
return ANCHOR_API_TIERS[tier]
return None
def rehydrate_search_radius_m( def rehydrate_search_radius_m(
relaxations: list[str] | None, analog_distances_m: list[int | None] relaxations: list[str] | None, analog_distances_m: list[int | None]
) -> int | None: ) -> int | None:
@ -316,7 +342,7 @@ def rehydrate_search_radius_m(
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев # Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому — # time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при # московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor # n>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor
# и Tier-C anchor gate, т.е. едет итоговая цена. # и Tier-C anchor gate, т.е. едет итоговая цена.
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE) SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
@ -416,6 +442,107 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
("премиум", float("inf")), ("премиум", float("inf")),
) )
# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ──
# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу
# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые
# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые
# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять
# число = правка здесь + регрессионный гейт бэктеста
# (tests/test_backtest_regression_gate.py).
# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по
# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на
# split-доме разной этажности comp_min..max растягивается через несколько ценовых
# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога
# → confidence на ступень ниже + disclosure в explanation; point/median/range не
# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил
# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост.
WIDE_CORRIDOR_THRESHOLD = 1.2
# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто
# метаданные — median/expected_sold/ranges не трогают.
MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка
MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
MANUAL_REVIEW_ELITE_PPM2 = 250_000
# #764 (#2379): поправка на квартальный индекс цены. Применяется ТОЛЬКО на
# pure-radius пути (same-building anchor и IMV-blend не сработали) и корректирует
# РАЗРЫВ между квартальным уровнем цели и средним уровнем аналогов:
# adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# Минимум сделок в квартале, чтобы индексу верить (меньше — поправки нет).
QUARTER_INDEX_MIN_N_DEALS = 10
# Guard-2 (no double-count): доля аналогов ИЗ ТОГО ЖЕ квартала выше порога —
# локация квартала уже в медиане → skip.
QUARTER_MATCH_SKIP_RATIO = 0.6
# Bimodal/nominal guard (бэктест 2026-05-31): структурно неоднородные кварталы дают
# индекс > 2.0 на малой выборке (< 50 сделок) → no-op, иначе регрессия.
QUARTER_INDEX_MAX_FOR_SMALL_N = 2.0
QUARTER_INDEX_SMALL_N_THRESHOLD = 50
# #859: sanity-clamp factor = target_index / avg_analog_index. Нормальные
# квартальные индексы РФ лежат в [0.6, 1.8]; за порогами — артефакт FDW, не сигнал.
QUARTER_INDEX_FACTOR_MIN = 0.6
QUARTER_INDEX_FACTOR_MAX = 1.8
# #1795 (#2380): premium headline anti-inflation. Диагноз: бизнес/премиум headline
# завышался ~2× против медианы реальных ДКП (Малышева 30 = 296k при медиане сделок
# 138k), эконом/комфорт сходились ±5%. Порог числа сделок CORRIDOR_CLAMP_MIN_N — в
# app.core.config (его же читает DkpCorridor.advisory_only, #3452).
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок: median_ppm2 > high_ppm2×(1+slack)
# И count ≥ CORRIDOR_CLAMP_MIN_N И anchor_tier != "A" (комплы того же дома EXEMPT) →
# headline прижимается к high_ppm2×(1+slack). 0.40 → cap ×1.40 даёт премиум-домам без
# своих листингов (tier-C) воздух над sold-коридором, не возвращая 2× инфляцию.
CORRIDOR_CLAMP_SLACK = 0.40
# Нижний floor radius-пути (anchor_tier is None, коридор с count ≥ min_n):
# median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor. 0.8 — 20% зазор ниже P10
# коридора: достаточно мягко для эконома, ловит явный undershoot.
RADIUS_FLOOR_FACTOR = 0.8
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < порога k = 1.0 вместо
# 1.5, иначе несколько элитных листингов тонкой выборки тянут медиану вверх.
OUTLIER_SMALL_N_THRESHOLD = 15
OUTLIER_TUKEY_K_SMALL = 1.0
# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0
# для trade-in физически невозможен, это артефакт high-price бакета.
# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает
# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV
# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем
# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT
# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем.
IMV_BLEND_WEIGHT = 0.5
IMV_BLEND_THRESHOLD = 1.15
# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная
# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт 15-25%. v2
# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по
# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded
# по ppm²), hard guardrail и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов
SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian)
SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target
# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции
# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену
# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия
# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%).
SB_FLOOR_SIGMA = 0.25
SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
SB_MAD_K_SMALL_N = 2.5
SB_SMALL_N_THRESHOLD = 10
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
# FSD<0.15) проходят без изменений.
SB_GATE_MIN_N = 3
SB_GATE_MAX_FSD = 0.20
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -2013,7 +2140,7 @@ def _resolve_target_city(address_text: str | None) -> str | None:
Returns None если ни один город не распознан (адрес вне известного Returns None если ни один город не распознан (адрес вне известного
списка) caller (_fetch_dkp_corridor) откатывается на behaviour без списка) caller (_fetch_dkp_corridor) откатывается на behaviour без
city-фильтра. NB: коридор НЕ чисто advisory при city-фильтра. NB: коридор НЕ чисто advisory при
n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor + n>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor +
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену. Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
""" """
if not address_text: if not address_text:
@ -2223,7 +2350,7 @@ def _fetch_dkp_corridor(
latest_deal = dd latest_deal = dd
ppm2_values = sorted(adjusted) ppm2_values = sorted(adjusted)
# #3452: чем получен коридор — улицей или расширением до города (widen ниже). # #3452: чем получен коридор — улицей или расширением до города (widen ниже).
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и # Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит # «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
# (pydantic игнорирует лишние kwargs), это служебная метка для логов. # (pydantic игнорирует лишние kwargs), это служебная метка для логов.
scope = "street" scope = "street"
@ -2592,7 +2719,7 @@ def _fetch_anchor_comps(
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
floor (int|None), total_floors (int|None) последние два для floor-веса (#680-WB). floor (int|None), total_floors (int|None) последние два для floor-веса (#680-WB).
""" """
min_comps = settings.estimate_sb_min_comps min_comps = SB_MIN_COMPS
# ── Tier A: same building ──────────────────────────────────────────────── # ── Tier A: same building ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address) street, base_no, letter = _normalize_building_key(address)
@ -2975,12 +3102,8 @@ def _compute_same_building_anchor(
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь # легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip. # < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
effective_mad_k = mad_k effective_mad_k = mad_k
if ( if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
tier != "A" effective_mad_k = SB_MAD_K_SMALL_N
and settings.estimate_sb_mad_k_small_n < mad_k
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
):
effective_mad_k = settings.estimate_sb_mad_k_small_n
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k) surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
if len(surviving_idx) < min_comps: if len(surviving_idx) < min_comps:
logger.info( logger.info(
@ -3394,7 +3517,7 @@ def _price_from_inputs(
""" """
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и # 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не # агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False. # по кросс-постам.
listings = _dedup_cross_source(listings) listings = _dedup_cross_source(listings)
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A # #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше # same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
@ -3591,32 +3714,28 @@ def _price_from_inputs(
area_target=area_m2, area_target=area_m2,
rooms_target=rooms, rooms_target=rooms,
tier=anchor_tier or "", tier=anchor_tier or "",
sigma=settings.estimate_sb_area_sigma, sigma=SB_AREA_SIGMA,
rooms_boost=settings.estimate_sb_rooms_match_boost, rooms_boost=SB_ROOMS_MATCH_BOOST,
floor_target=floor, floor_target=floor,
total_floors_target=total_floors, total_floors_target=total_floors,
floor_sigma=settings.estimate_sb_floor_sigma, floor_sigma=SB_FLOOR_SIGMA,
min_comps=settings.estimate_sb_min_comps, min_comps=SB_MIN_COMPS,
mad_k=settings.estimate_sb_mad_k, mad_k=SB_MAD_K,
) )
# #1795 шаг 3: гейт Tier C. # #1795 шаг 3: гейт Tier C.
if ( if anchor is not None and anchor_tier == "C":
anchor is not None
and anchor_tier == "C"
and settings.estimate_anchor_tier_c_corridor_mult > 0
):
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0: if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
corridor_high_for_gate = float(dkp_raw["high_ppm2"]) corridor_high_for_gate = float(dkp_raw["high_ppm2"])
else: else:
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0 corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold: if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
logger.info( logger.info(
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d" "sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
" → keep radius median (anchor suppressed)", " → keep radius median (anchor suppressed)",
int(anchor["anchor_ppm2"]), int(anchor["anchor_ppm2"]),
settings.estimate_anchor_tier_c_corridor_mult, ANCHOR_TIER_C_CORRIDOR_MULT,
int(gate_threshold), int(gate_threshold),
) )
anchor = None anchor = None
@ -3624,10 +3743,7 @@ def _price_from_inputs(
# #audit-1: low-confidence gate. # #audit-1: low-confidence gate.
if anchor is not None: if anchor is not None:
gate_low = anchor["confidence"] == "low" gate_low = anchor["confidence"] == "low"
gate_thin = ( gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
if gate_low or gate_thin: if gate_low or gate_thin:
logger.info( logger.info(
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s" "sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
@ -3667,13 +3783,13 @@ def _price_from_inputs(
anchor_comps_used = anchor.get("comps") or anchor_comps anchor_comps_used = anchor.get("comps") or anchor_comps
est_ppm2 = anchor["anchor_ppm2"] est_ppm2 = anchor["anchor_ppm2"]
# PREMIUM GUARDRAIL (hard). # PREMIUM GUARDRAIL (hard).
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol) floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
if est_ppm2 < floor_ppm2: if est_ppm2 < floor_ppm2:
est_ppm2 = floor_ppm2 est_ppm2 = floor_ppm2
new_ppm2 = est_ppm2 * repair_coef new_ppm2 = est_ppm2 * repair_coef
point = int(new_ppm2 * area_m2) point = int(new_ppm2 * area_m2)
# FSD-диапазон. # FSD-диапазон.
half = settings.estimate_fsd_k * anchor["fsd"] half = FSD_K * anchor["fsd"]
new_range_low = int(point * max(0.0, 1.0 - half)) new_range_low = int(point * max(0.0, 1.0 - half))
new_range_high = int(point * (1.0 + half)) new_range_high = int(point * (1.0 + half))
# Спред комплов. # Спред комплов.
@ -3699,10 +3815,10 @@ def _price_from_inputs(
range_low = new_range_low range_low = new_range_low
range_high = new_range_high range_high = new_range_high
confidence = anchor["confidence"] confidence = anchor["confidence"]
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)" # #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает
# #695: explanation описывает ИМЕННО якорные комплы. # GET-rehydrate (analog_tier_from_explanation) — менять только через константу.
explanation = ( explanation = (
f"Оценка построена по {anchor['n']} аналогам из {tier_label}" f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}"
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}." f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
) + repair_note ) + repair_note
# #695 (QA fixup): n_analogs по anchor-популяции. # #695 (QA fixup): n_analogs по anchor-популяции.
@ -3726,7 +3842,7 @@ def _price_from_inputs(
# #1871 P2: split-дома wide-corridor disclosure. # #1871 P2: split-дома wide-corridor disclosure.
if anchor_tier == "A" and median_price > 0: if anchor_tier == "A" and median_price > 0:
corridor_pct = (range_high - range_low) / median_price corridor_pct = (range_high - range_low) / median_price
if corridor_pct > settings.estimate_wide_corridor_threshold: if corridor_pct > WIDE_CORRIDOR_THRESHOLD:
confidence = _downgrade_confidence(confidence) confidence = _downgrade_confidence(confidence)
explanation = (explanation or "") + ( explanation = (explanation or "") + (
" Очень широкий ценовой диапазон по дому (вероятно, дом " " Очень широкий ценовой диапазон по дому (вероятно, дом "
@ -3830,8 +3946,8 @@ def _price_from_inputs(
area=area_m2, area=area_m2,
anchor_total=anchor_total, anchor_total=anchor_total,
anchor_higher=anchor_higher, anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight, weight=IMV_BLEND_WEIGHT,
threshold=settings.estimate_imv_blend_threshold, threshold=IMV_BLEND_THRESHOLD,
market_count=( market_count=(
avito_imv_summary.market_count if avito_imv_summary is not None else None avito_imv_summary.market_count if avito_imv_summary is not None else None
), ),
@ -3843,7 +3959,7 @@ def _price_from_inputs(
median_price, median_price,
new_median, new_median,
anchor_used, anchor_used,
settings.estimate_imv_blend_weight, IMV_BLEND_WEIGHT,
range_high, range_high,
new_range_high, new_range_high,
) )
@ -3911,15 +4027,15 @@ def _price_from_inputs(
# Bimodal/nominal guard (Guard-4). # Bimodal/nominal guard (Guard-4).
if ( if (
target_qi > settings.estimate_quarter_index_max_for_small_n target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N
and target_n_deals < settings.estimate_quarter_index_small_n_threshold and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD
): ):
logger.info( logger.info(
"quarter_index: bimodal guard triggered " "quarter_index: bimodal guard triggered "
"(index=%.3f n=%d < %d) for %s — no-op", "(index=%.3f n=%d < %d) for %s — no-op",
target_qi, target_qi,
target_n_deals, target_n_deals,
settings.estimate_quarter_index_small_n_threshold, QUARTER_INDEX_SMALL_N_THRESHOLD,
target_quarter, target_quarter,
) )
else: else:
@ -3944,11 +4060,11 @@ def _price_from_inputs(
if lot_quarters_for_guard2 if lot_quarters_for_guard2
else 0.0 else 0.0
) )
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio: if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO:
logger.info( logger.info(
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s", "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
same_quarter_ratio, same_quarter_ratio,
settings.estimate_quarter_match_skip_ratio, QUARTER_MATCH_SKIP_RATIO,
target_quarter, target_quarter,
) )
else: else:
@ -4001,8 +4117,8 @@ def _price_from_inputs(
base_range_high=range_high, base_range_high=range_high,
target_index=target_qi, target_index=target_qi,
avg_analog_index=avg_analog_index, avg_analog_index=avg_analog_index,
min_factor=settings.estimate_quarter_index_factor_min, min_factor=QUARTER_INDEX_FACTOR_MIN,
max_factor=settings.estimate_quarter_index_factor_max, max_factor=QUARTER_INDEX_FACTOR_MAX,
) )
analogs_with_qi = sum( analogs_with_qi = sum(
1 for lq, _lp in analog_quarters if lq in analog_index_map 1 for lq, _lp in analog_quarters if lq in analog_index_map
@ -4024,7 +4140,7 @@ def _price_from_inputs(
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ────────────── # ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
slack = settings.estimate_corridor_clamp_slack slack = CORRIDOR_CLAMP_SLACK
if dkp_raw is not None and median_ppm2 > 0: if dkp_raw is not None and median_ppm2 > 0:
old_ppm2 = median_ppm2 old_ppm2 = median_ppm2
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp( median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
@ -4036,7 +4152,7 @@ def _price_from_inputs(
corridor_count=dkp_raw["count"], corridor_count=dkp_raw["count"],
anchor_tier=anchor_tier, anchor_tier=anchor_tier,
slack=slack, slack=slack,
min_n=settings.estimate_corridor_clamp_min_n, min_n=CORRIDOR_CLAMP_MIN_N,
) )
if clamped: if clamped:
logger.info( logger.info(
@ -4059,9 +4175,9 @@ def _price_from_inputs(
and dkp_raw is not None and dkp_raw is not None
and dkp_raw.get("low_ppm2", 0) > 0 and dkp_raw.get("low_ppm2", 0) > 0
and median_ppm2 > 0 and median_ppm2 > 0
and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n and dkp_raw.get("count", 0) >= CORRIDOR_CLAMP_MIN_N
): ):
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR
if median_ppm2 < radius_floor_ppm2: if median_ppm2 < radius_floor_ppm2:
floor_factor = radius_floor_ppm2 / median_ppm2 floor_factor = radius_floor_ppm2 / median_ppm2
logger.info( logger.info(
@ -4069,7 +4185,7 @@ def _price_from_inputs(
" → lifting (factor=%.3f)", " → lifting (factor=%.3f)",
int(median_ppm2), int(median_ppm2),
dkp_raw["low_ppm2"], dkp_raw["low_ppm2"],
settings.estimate_radius_floor_factor, RADIUS_FLOOR_FACTOR,
int(radius_floor_ppm2), int(radius_floor_ppm2),
floor_factor, floor_factor,
) )
@ -4089,7 +4205,7 @@ def _price_from_inputs(
if asking_to_sold_ratio is not None and median_price > 0: if asking_to_sold_ratio is not None and median_price > 0:
effective_ratio = asking_to_sold_ratio effective_ratio = asking_to_sold_ratio
if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0: if effective_ratio > 1.0:
logger.info( logger.info(
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)", "expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
effective_ratio, effective_ratio,
@ -4132,7 +4248,7 @@ def _price_from_inputs(
# which is an overpay risk for trade-in. Re-clamp the corrected point back to # which is an overpay risk for trade-in. Re-clamp the corrected point back to
# the headline so the calibrated PI range below wraps the clamped point. # the headline so the calibrated PI range below wraps the clamped point.
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float). # round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
if settings.estimate_expected_sold_le_asking and expected_sold_price: if expected_sold_price:
expected_sold_price = min(expected_sold_price, median_price) expected_sold_price = min(expected_sold_price, median_price)
if expected_sold_per_m2: if expected_sold_per_m2:
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2)) expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
@ -4214,7 +4330,7 @@ def _price_from_inputs(
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict), # #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when # NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its # `_compute_same_building_anchor` rejects a candidate outright (e.g. its
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None # own MAD-clip drops comps below SB_MIN_COMPS), it returns None
# WITHOUT the caller resetting `anchor_tier` back to None (it stays # WITHOUT the caller resetting `anchor_tier` back to None (it stays
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired, # whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
# but the stale tier flag falsely reads as "anchor claimed the headline" # but the stale tier flag falsely reads as "anchor claimed the headline"
@ -5166,14 +5282,14 @@ async def estimate_quality(
return _lookup_quarter_index( return _lookup_quarter_index(
db, db,
quarter_cad_number=q, quarter_cad_number=q,
min_n_deals=settings.estimate_quarter_index_min_n_deals, min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
) )
def _qis_lookup(qs: list[str]) -> dict[str, float]: def _qis_lookup(qs: list[str]) -> dict[str, float]:
return _lookup_quarter_indexes( return _lookup_quarter_indexes(
db, db,
quarter_cad_numbers=qs, quarter_cad_numbers=qs,
min_n_deals=settings.estimate_quarter_index_min_n_deals, min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
) )
# ── Deterministic pricing orchestration ────────────────────────────────── # ── Deterministic pricing orchestration ──────────────────────────────────
@ -5311,10 +5427,11 @@ async def estimate_quality(
estimate_id = uuid4() estimate_id = uuid4()
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок # #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и # меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе). # soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому # Зона n=1..9 на экране неотличима от работающего коридора, поэтому
# попадание в неё пишется явной строкой: число оценок за сутки — # попадание в неё пишется явной строкой (#3466: n=1..2 достижимы — уличный
# коридор отдаётся с одной сделки, widen только вне ЕКБ): число оценок за сутки —
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`. # `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит. # Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
if dkp_corridor is not None and dkp_corridor.advisory_only: if dkp_corridor is not None and dkp_corridor.advisory_only:
@ -5322,7 +5439,7 @@ async def estimate_quality(
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s", "corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
estimate_id, estimate_id,
dkp_corridor.count, dkp_corridor.count,
settings.estimate_corridor_clamp_min_n, CORRIDOR_CLAMP_MIN_N,
(dkp_raw or {}).get("scope", "unknown"), (dkp_raw or {}).get("scope", "unknown"),
target_city or "unknown", target_city or "unknown",
) )
@ -5573,10 +5690,8 @@ async def estimate_quality(
# radius-путь: analog_tier "W" → "city", остальные → "district". # radius-путь: analog_tier "W" → "city", остальные → "district".
# None только если нет аналогов (median=0, insufficient_data=True). # None только если нет аналогов (median=0, insufficient_data=True).
if median_price > 0: if median_price > 0:
if anchor_tier == "A": if anchor_tier in ANCHOR_API_TIERS:
api_analog_tier: str | None = "same_building" api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier]
elif anchor_tier == "C":
api_analog_tier = "micro_radius"
elif analog_tier == "W": elif analog_tier == "W":
api_analog_tier = "city" api_analog_tier = "city"
else: else:
@ -5641,7 +5756,6 @@ async def estimate_quality(
confidence, confidence,
range_low, range_low,
range_high, range_high,
settings,
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)), # asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите. # что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
asking_ppm2=int(median_ppm2), asking_ppm2=int(median_ppm2),
@ -5781,8 +5895,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int |
None) premium_building флаг не выставится, даже если дом объективно элитный по None) premium_building флаг не выставится, даже если дом объективно элитный по
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже, рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости
`estimate_manual_review_high_value_rub` (expected_sold_price порога) и `MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price порога) и
`estimate_manual_review_elite_ppm2` (asking /м² порога) независимо от `MANUAL_REVIEW_ELITE_PPM2` (asking /м² порога) независимо от
premium_building. То есть oblast-элита не помечается как «премиальный дом», но premium_building. То есть oblast-элита не помечается как «премиальный дом», но
почти всегда подхватывается value-based триггером той же функции. почти всегда подхватывается value-based триггером той же функции.
""" """
@ -5819,7 +5933,6 @@ def _manual_review(
confidence: str, confidence: str,
range_low: int | None, range_low: int | None,
range_high: int | None, range_high: int | None,
settings: Settings,
asking_ppm2: int | None = None, asking_ppm2: int | None = None,
) -> tuple[bool, list[str]]: ) -> tuple[bool, list[str]]:
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена. """#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
@ -5849,15 +5962,13 @@ def _manual_review(
reasons: list[str] = [] reasons: list[str] = []
if premium_building: if premium_building:
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)") reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
high_value = settings.estimate_manual_review_high_value_rub if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB:
if expected_sold_price and expected_sold_price >= high_value:
reasons.append("высокая стоимость (≥20 млн ₽)") reasons.append("высокая стоимость (≥20 млн ₽)")
if confidence == "low": if confidence == "low":
reasons.append("низкая уверенность оценки") reasons.append("низкая уверенность оценки")
wide_ratio = settings.estimate_manual_review_wide_range_ratio if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO:
if range_low and range_high and range_high / range_low >= wide_ratio:
reasons.append("широкий диапазон цены") reasons.append("широкий диапазон цены")
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2: if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2:
reasons.append( reasons.append(
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, " "элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше" "вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
@ -6551,8 +6662,9 @@ def _fetch_analogs(
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor _normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
Tier A) + ST_DWithin(radius_m). Если 3 результатов tier='S'. Tier A) + ST_DWithin(radius_m). Если 3 результатов tier='S'.
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%. **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors
Если 5 результатов возвращаем; tier='H'. ±estimate_tier_h_floors_tol (0.30). Если estimate_tier_h_min_comps (5)
результатов возвращаем; tier='H'.
Пропускается если year_built или total_floors неизвестны. Пропускается если year_built или total_floors неизвестны.
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра. **Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
@ -6747,12 +6859,12 @@ def _fetch_analogs(
) )
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S" return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
# ── Tier H: same class (year ±15, total_floors ±30%) ──────────────────── # ── Tier H: same class (year ±15, total_floors ±tol, #3234: tol/порог в settings)
if year_built is not None and total_floors is not None: if year_built is not None and total_floors is not None:
year_min = year_built - 15 year_min = year_built - 15
year_max = year_built + 15 year_max = year_built + 15
tf_min = math.floor(total_floors * 0.7) tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol))
tf_max = math.ceil(total_floors * 1.3) tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol))
tier_h_rows = ( tier_h_rows = (
db.execute( db.execute(
@ -6860,7 +6972,7 @@ def _fetch_analogs(
min_n=settings.estimate_unknown_attr_penalty_min_n, min_n=settings.estimate_unknown_attr_penalty_min_n,
) )
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0) tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
if len(tier_h) >= 5: if len(tier_h) >= settings.estimate_tier_h_min_comps:
logger.info( logger.info(
"analogs tier=H year=%d±15 tf=%d-%d%d results", "analogs tier=H year=%d±15 tf=%d-%d%d results",
year_built, year_built,
@ -7328,15 +7440,18 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
) )
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3 # «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц # буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед # («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис:
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия, # «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я
# Новосибирская», «3-й Конный», в т.ч. перед нумерованным именем «2-й переулок
# 1 Мая»); она разрешена ТОЛЬКО перед буквенным словом
# (?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского». # не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела # #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую # («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом. # (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
_DEDUP_STREET_NAME_RE = re.compile( _DEDUP_STREET_NAME_RE = re.compile(
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+" rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})", r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik: # #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
@ -7348,10 +7463,19 @@ _DEDUP_STREET_NAME_RE = re.compile(
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает. # Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile( _DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
r"(?:^|[,\s])" r"(?:^|[,\s])"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+" r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)", rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
# #2291 класс 4: третий формат порядковой улицы — номер ПЕРЕД типом («8-я улица
# Текстильщиков», «2-я ул. Синичкина», «12-й пр. Марьиной Рощи»). Переставляем в
# «улица 8-я текстильщиков», чтобы он дал тот же токен, что «улица 8-я Текстильщиков»
# и «8-я Текстильщиков улица». Без перестановки ветка 1 взяла бы «текстильщиков»
# без номера, а номер нельзя терять: «9-я Парковая, 5» и «12-я Парковая, 5» — разные дома.
_DEDUP_ORDINAL_BEFORE_TYPE_RE = re.compile(
rf"(^|[,\s])(\d+-[а-яё]{{1,2}})\s+((?:{_DEDUP_STREET_TYPE_ALT})\.?)\s+",
flags=re.IGNORECASE,
)
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или # #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr », # «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица). # чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
@ -7537,6 +7661,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/ # #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
# «Минометчиков» должны схлопываться в один канонический токен. # «Минометчиков» должны схлопываться в один канонический токен.
s = s.replace("ё", "е") s = s.replace("ё", "е")
s = _DEDUP_ORDINAL_BEFORE_TYPE_RE.sub(r"\1\3 \2 ", s)
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»). # Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута. # Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
m = _DEDUP_STREET_NAME_RE.search(s) m = _DEDUP_STREET_NAME_RE.search(s)
@ -7655,8 +7780,7 @@ def _union_find_phys_dedup(
не дедупятся остаются уникальными. не дедупятся остаются уникальными.
include_price=True статистический ключ (влияет на n_analogs/median/cv, include_price=True статистический ключ (влияет на n_analogs/median/cv,
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего вызывающий _dedup_cross_source). include_price=False display-only ключ без
_dedup_cross_source). include_price=False display-only ключ без
price_bucket (см. _dedup_display_lots) ценовой дрейф между кросс-постами price_bucket (см. _dedup_display_lots) ценовой дрейф между кросс-постами
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
пары для merge (require_diff_source) без цены единственный оставшийся пары для merge (require_diff_source) без цены единственный оставшийся
@ -7777,13 +7901,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/ """Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
median/cv/source_counts/sources_used (представитель несёт свой source лот median/cv/source_counts/sources_used (представитель несёт свой source лот
считается один раз; source кросс-поста, не ставший представителем, выпадает считается один раз; source кросс-поста, не ставший представителем, выпадает
из выборки по нему нет независимой ценовой точки). Флаг из выборки по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель
estimate_dedup_analogs_enabled OFF no-op (байт-идентичный проход, нужно снят (#2378) — бэктест #1966 OFF vs ON был
для frozen backtest regression gate). Ключ включает price_bucket accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%), дедуп меняет
только честность счёта n_analogs. Ключ включает price_bucket
(_lot_dedup_components include_price=True по умолчанию) см. (_lot_dedup_components include_price=True по умолчанию) см.
_union_find_phys_dedup. _union_find_phys_dedup.
""" """
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2: if len(lots) < 2:
return lots return lots
return _union_find_phys_dedup(lots, include_price=True) return _union_find_phys_dedup(lots, include_price=True)
@ -7829,9 +7954,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
случай: перевыставили на другой площадке дешевле/дороже) без такого случай: перевыставили на другой площадке дешевле/дороже) без такого
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог». дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет Независим от _dedup_cross_source: тот управляет статистикой
статистикой (median/n_analogs), этот только честностью UI-карточек и (median/n_analogs), этот только честностью UI-карточек. НЕ трогает
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
n_analogs/median/cv вызывается ПОСЛЕ того, как эти величины уже вычислены n_analogs/median/cv вызывается ПОСЛЕ того, как эти величины уже вычислены
из отдельного (не обязательно совпадающего по итоговой длине) пула. из отдельного (не обязательно совпадающего по итоговой длине) пула.
""" """
@ -7843,10 +7967,10 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR]. """Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold) #1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD)
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small несколько элитных ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL несколько элитных
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
тянут радиусную медиану вверх. threshold=0 выключено (старое поведение). тянут радиусную медиану вверх.
""" """
if len(lots) < 5: if len(lots) < 5:
return lots # на маленькой выборке нечего фильтровать return lots # на маленькой выборке нечего фильтровать
@ -7856,11 +7980,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
return lots return lots
tukey_k = 1.5 tukey_k = 1.5
if ( if len(prices) < OUTLIER_SMALL_N_THRESHOLD:
settings.estimate_outlier_tukey_k_small < 1.5 tukey_k = OUTLIER_TUKEY_K_SMALL
and len(prices) < settings.estimate_outlier_small_n_threshold
):
tukey_k = settings.estimate_outlier_tukey_k_small
q1 = _percentile(prices, 0.25) q1 = _percentile(prices, 0.25)
q3 = _percentile(prices, 0.75) q3 = _percentile(prices, 0.75)

View file

@ -42,6 +42,12 @@ counters["block_streak_histogram"], иначе эффект правки неч
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
"ABORT -- 1 consecutive blocks". "ABORT -- 1 consecutive blocks".
Третий критерий, "infra_streak" (#3439): max_consecutive_failures отказов НАШЕГО
тракта (сайдкар/пул, ban_kind='infra') подряд без единого ответа площадки. Такой
отказ в долю не входит (#3288), и до этого серия любой длины не рвала прогон ничем:
7193/7264/7302 по 1600 попыток, 1-2 карточки, status='done'. Прогон завершается
'failed' с причиной «тракт недоступен», а не 'banned': площадка молчала.
""" """
from __future__ import annotations from __future__ import annotations
@ -98,9 +104,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает # принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси), # его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() — # зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима, # без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча
# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое # терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же
# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310. # класс бага что #2322/#2310.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -232,12 +238,10 @@ async def _rotate_current_proxy(
settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не
зовёт эту функцию в том режиме вовсе. зовёт эту функцию в том режиме вовсе.
Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False
живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при (#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка
SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация админки scrape_avito_detail_backfill в tradein-backend, поэтому режим задаётся
активна. Значение true стоит только у tradein-backend, который добор не запускает. дефолтом кода, а не environment'ом одного сервиса.
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом»
это предсуществующее заблуждение, а не описание текущего прода.
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост) Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ
@ -329,7 +333,8 @@ async def run_avito_detail_backfill(
скользящем окне, см. settings.detail_backfill_block_ratio_window/ скользящем окне, см. settings.detail_backfill_block_ratio_window/
_threshold (module docstring). _threshold (module docstring).
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам, max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
default 25 (см. комментарий у чтения параметра ниже). default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674); mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
@ -549,6 +554,10 @@ async def run_avito_detail_backfill(
window_size=int(settings.detail_backfill_block_ratio_window), window_size=int(settings.detail_backfill_block_ratio_window),
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold), ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
safety_min=max_consecutive_blocks, safety_min=max_consecutive_blocks,
# #3439: отказ сайдкара/пула — отказ нашей стороны, тот же разряд, что
# отказы-не-блоки, и тот же порог. Без него серия infra не рвала прогон
# ничем: 7302 — 1598 отказов сайдкара из 1600 попыток, 72 минуты, 'done'.
infra_streak_limit=max_consecutive_failures,
# snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в # snapshot_size гейтит safety-net (#3184 review MAJOR 2): пачка блоков в
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше -- # начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
# safety-net включён только когда снапшот короче окна и ratio-критерий # safety-net включён только когда снапшот короче окна и ratio-критерий
@ -705,7 +714,7 @@ async def run_avito_detail_backfill(
# config.scraper_proxy_url=None для backconnect-gate'а # config.scraper_proxy_url=None для backconnect-gate'а
# (elif not use_curl own-session path), теряя reconnect- # (elif not use_curl own-session path), теряя reconnect-
# on-403 поведение legacy (settings.scraper_proxy_url # on-403 поведение legacy (settings.scraper_proxy_url
# напрямую). Не влияет на use_curl=True (прод-дефолт) # напрямую). Не влияет на use_curl=True
# там reconnect_on_block=False уже гасит backconnect. # там reconnect_on_block=False уже гасит backconnect.
config=RealScraperConfig(), config=RealScraperConfig(),
origin=serp_origin, origin=serp_origin,
@ -1025,6 +1034,12 @@ async def run_avito_detail_backfill(
counters.duration_sec = time.monotonic() - start counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict() current_counters = counters.to_dict()
# #3288 п.5: перепись диагнозов блоков — в counters ЛЮБОГО финала, а не только
# 'banned' (mark_backfill_finished кладёт её лишь на ветке бана). Прогоны
# 7193/7264/7302 (1600 попыток, ~1599 блоков, status='done') ушли без диагноза
# в БД: чей был отказ — площадки или нашего тракта — SQL'ем не узнать.
if block_ban_kinds:
current_counters["ban_kinds"] = dict(block_ban_kinds) # type: ignore[assignment]
# #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) -- # #3184: гистограмма длин пачек блоков (streak -> сколько раз встретилась) --
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize() # иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
# досчитывает хвостовую пачку, если прогон оборвался посреди серии. # досчитывает хвостовую пачку, если прогон оборвался посреди серии.
@ -1036,21 +1051,28 @@ async def run_avito_detail_backfill(
# и без этого ключа "banned" опять не отличить по причине (#3178). # и без этого ключа "banned" опять не отличить по причине (#3178).
if abort_reason is not None: if abort_reason is not None:
current_counters["abort_reason"] = abort_reason # type: ignore[assignment] current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
# утверждать про площадку то, чего не было.
stop_reason: str | None = None
if no_proxy_stop: if no_proxy_stop:
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок, # #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе.
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
current_counters["no_proxy_stop"] = 1 current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed( stop_reason = "пул прокси пуст — к площадке не ходили (#3288)"
db, elif abort_reason == "infra_streak":
run_id, # #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала.
"пул прокси пуст — к площадке не ходили (#3288)", stop_reason = (
current_counters, f"тракт недоступен: {breaker.abort_explanation()}, обогащено "
f"{counters.enriched} из {counters.attempted}; причина: "
f"{_top_failure(failure_census)} (#3439)"
) )
if stop_reason is not None:
runs_mod.mark_failed(db, run_id, stop_reason, current_counters)
logger.info( logger.info(
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d " "avito_detail_backfill: run_id=%d FINISHED (%s) -- attempted=%d "
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs", "enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
run_id, run_id,
stop_reason,
counters.attempted, counters.attempted,
counters.enriched, counters.enriched,
counters.blocked, counters.blocked,

View file

@ -0,0 +1,29 @@
-- 320_listings_domclick_zero_area_to_null.sql
-- Issue #3252: Домклик отдаёт `livingArea: 0` / `kitchenArea: 0` как «не указано»
-- (живая карточка 2078257603, 17.09: обе площади 0, ремонт пустой). Парсер карточки
-- писал этот 0 в колонку как настоящую площадь; COALESCE(:new, old) в UPDATE при этом
-- затирал нулём ранее известное значение. Писатель починен в том же PR
-- (providers/domclick/detail.py::_pos_float), но починка разбора строк не чинит:
-- COALESCE(NULL, 0) оставит 0 навсегда.
--
-- ЗАМЕР ПРОДА 2026-09-17 (SELECT source, count(*) FILTER (WHERE kitchen_area_m2=0) ...):
-- domklik: kitchen_area_m2 = 0 — 303 строки, living_area_m2 = 0 — 84 строки;
-- все обогащены карточкой 26.0814.09 (detail_enriched_at), ни одной до этого.
-- avito / cian / yandex / n1: нулей нет вовсе.
-- Ожидаемо тронуто: 303 + 84 строки. Отрицательных площадей нет ни у кого.
--
-- Idempotent: повторный прогон — 0 строк.
BEGIN;
UPDATE listings
SET kitchen_area_m2 = NULL
WHERE source = 'domklik'
AND kitchen_area_m2 = 0;
UPDATE listings
SET living_area_m2 = NULL
WHERE source = 'domklik'
AND living_area_m2 = 0;
COMMIT;

View file

@ -1975,12 +1975,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile / ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
``params`` block. Touches NO DB / network and does NOT consult ``params`` block. Touches NO DB / network and does NOT consult
``settings_at_capture`` it prices against the live committed ``settings_at_capture`` it prices against the live committed
``estimator.settings`` defaults (so a settings change is caught as a metric ``estimator.settings`` defaults and module constants (so a change is caught as a
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного (закоммиченная такая); более старая упадёт на «control flow diverged from
потока (см. комментарий у пина ниже). Deterministic: same fixture identical capture» её надо перезахватить. Deterministic: same fixture identical dict.
dict.
""" """
est = _import_estimator_full() est = _import_estimator_full()
m = est.m m = est.m
@ -1991,83 +1990,60 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
pred_ppm2_all: list[float] = [] pred_ppm2_all: list[float] = []
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был for rec in deals:
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти kw = dict(rec["kwargs"])
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source sold_ppm2_all.append(float(rec["sold_ppm2"]))
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность kw["geo"] = est.GeocodeResult(**kw["geo"])
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг kw["ratio_resolver"] = _make_call_stub(
# сам, а документированная регенерация baseline (--from-fixture --update-baseline) rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА. )
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры, # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не # которое надо знать при чтении метрик: для этих сделок квартальный индекс в
# вызывается, а реплей с dedup=False их оценивает и упирается в # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
# False — для старых фикстур без settings_at_capture поведение прежнее). # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
_dedup_capture = bool( kw["quarter_index_lookup"] = _make_call_stub(
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False) rec.get("qi_calls") or [],
) label="quarter_index_lookup",
_dedup_saved = m.settings.estimate_dedup_analogs_enabled coerce=_coerce_qi_return,
m.settings.estimate_dedup_analogs_enabled = _dedup_capture on_exhausted=None,
try: unrecorded_counter=unrecorded,
for rec in deals: )
kw = dict(rec["kwargs"]) kw["quarter_indexes_lookup"] = _make_call_stub(
sold_ppm2_all.append(float(rec["sold_ppm2"])) rec.get("qis_calls") or [],
kw["geo"] = est.GeocodeResult(**kw["geo"]) label="quarter_indexes_lookup",
kw["ratio_resolver"] = _make_call_stub( coerce=_coerce_qis_return,
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return on_exhausted={},
) unrecorded_counter=unrecorded,
# #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых )
# в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
# Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
# которое надо знать при чтении метрик: для этих сделок квартальный индекс в
# реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
# эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
# Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
kw["quarter_index_lookup"] = _make_call_stub(
rec.get("qi_calls") or [],
label="quarter_index_lookup",
coerce=_coerce_qi_return,
on_exhausted=None,
unrecorded_counter=unrecorded,
)
kw["quarter_indexes_lookup"] = _make_call_stub(
rec.get("qis_calls") or [],
label="quarter_indexes_lookup",
coerce=_coerce_qis_return,
on_exhausted={},
unrecorded_counter=unrecorded,
)
pr = m._price_from_inputs(**kw) pr = m._price_from_inputs(**kw)
_es_ppm2_raw = pr.expected_sold_per_m2 _es_ppm2_raw = pr.expected_sold_per_m2
_es_price_raw = pr.expected_sold_price _es_price_raw = pr.expected_sold_price
_r_low_raw = pr.expected_sold_range_low _r_low_raw = pr.expected_sold_range_low
_r_high_raw = pr.expected_sold_range_high _r_high_raw = pr.expected_sold_range_high
es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
es_price = float(_es_price_raw) if _es_price_raw is not None else None es_price = float(_es_price_raw) if _es_price_raw is not None else None
r_low = float(_r_low_raw) if _r_low_raw is not None else None r_low = float(_r_low_raw) if _r_low_raw is not None else None
r_high = float(_r_high_raw) if _r_high_raw is not None else None r_high = float(_r_high_raw) if _r_high_raw is not None else None
prediction = Prediction( prediction = Prediction(
deal_id=int(rec["deal_id"]), deal_id=int(rec["deal_id"]),
rooms=rec["rooms"], rooms=rec["rooms"],
area_m2=float(rec["area_m2"]), area_m2=float(rec["area_m2"]),
sold_ppm2=float(rec["sold_ppm2"]), sold_ppm2=float(rec["sold_ppm2"]),
median_ppm2=float(pr.median_ppm2), median_ppm2=float(pr.median_ppm2),
confidence=pr.confidence, confidence=pr.confidence,
anchor_tier=pr.anchor_tier, anchor_tier=pr.anchor_tier,
expected_sold_ppm2=es_ppm2, expected_sold_ppm2=es_ppm2,
expected_sold_price=es_price, expected_sold_price=es_price,
range_low=r_low, range_low=r_low,
range_high=r_high, range_high=r_high,
) )
predictions.append(prediction) predictions.append(prediction)
pred_ppm2_all.append(prediction.median_ppm2) pred_ppm2_all.append(prediction.median_ppm2)
finally:
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here. # The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report) metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
@ -2123,7 +2099,6 @@ def _predict_full_spine(
whether the house IMV anchor actually resolved a row for this deal. whether the house IMV anchor actually resolved a row for this deal.
""" """
m = est.m m = est.m
settings = est.settings
listings, analog_tier, fallback_used, area_widened = _select_analogs_full( listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
db, deal, est, target_house_id=target_house_id db, deal, est, target_house_id=target_house_id
@ -2212,7 +2187,7 @@ def _predict_full_spine(
res = m._lookup_quarter_index( res = m._lookup_quarter_index(
db, db,
quarter_cad_number=q, quarter_cad_number=q,
min_n_deals=settings.estimate_quarter_index_min_n_deals, min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
) )
if capture is not None: if capture is not None:
ret = _sanitize_json(list(res) if res is not None else None) ret = _sanitize_json(list(res) if res is not None else None)
@ -2223,7 +2198,7 @@ def _predict_full_spine(
res = m._lookup_quarter_indexes( res = m._lookup_quarter_indexes(
db, db,
quarter_cad_numbers=qs, quarter_cad_numbers=qs,
min_n_deals=settings.estimate_quarter_index_min_n_deals, min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
) )
if capture is not None: if capture is not None:
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))]) qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
@ -2658,7 +2633,9 @@ def _write_fixture(
) -> None: ) -> None:
"""Freeze captured per-deal replay records into a committed JSON fixture. """Freeze captured per-deal replay records into a committed JSON fixture.
``settings_at_capture`` records every ``estimate_*`` Settings field as an ``settings_at_capture`` records every ``estimate_*`` Settings field plus the
estimator's numeric module constants (#1970: калибровочные пороги переехали из
Settings в код без них снимок не говорит, с какими порогами захватили) as an
informational snapshot (NOT consulted by ``replay_fixture``). A final informational snapshot (NOT consulted by ``replay_fixture``). A final
recursive ``_sanitize_json`` pass guarantees the whole document is finite + recursive ``_sanitize_json`` pass guarantees the whole document is finite +
JSON-plain before ``json.dump(allow_nan=False)``. JSON-plain before ``json.dump(allow_nan=False)``.
@ -2668,6 +2645,11 @@ def _write_fixture(
for name in sorted(type(est.settings).model_fields) for name in sorted(type(est.settings).model_fields)
if name.startswith("estimate_") if name.startswith("estimate_")
} }
settings_at_capture.update(
(name, _sanitize_json(value))
for name, value in sorted(vars(est.m).items())
if name.isupper() and isinstance(value, int | float)
)
fixture = { fixture = {
"schema_version": FIXTURE_SCHEMA_VERSION, "schema_version": FIXTURE_SCHEMA_VERSION,
"engine": "full", "engine": "full",

View file

@ -2,19 +2,19 @@
"calibration": { "calibration": {
"high": { "high": {
"coverage_pct": 75.0, "coverage_pct": 75.0,
"mape_pct": 19.6, "mape_pct": 19.97,
"n": 8, "n": 8,
"n_covered": 6 "n_covered": 6
}, },
"low": { "low": {
"coverage_pct": 83.59, "coverage_pct": 83.67,
"mape_pct": 15.64, "mape_pct": 15.64,
"n": 1562, "n": 1562,
"n_covered": 1029 "n_covered": 1030
}, },
"medium": { "medium": {
"coverage_pct": 70.0, "coverage_pct": 70.0,
"mape_pct": 27.59, "mape_pct": 26.53,
"n": 30, "n": 30,
"n_covered": 21 "n_covered": 21
} }
@ -26,17 +26,17 @@
], ],
"expected_sold": { "expected_sold": {
"overall": { "overall": {
"mape_pct": 15.88, "mape_pct": 15.92,
"median_bias_pct": 2.87, "median_bias_pct": 2.87,
"n": 1269, "n": 1269,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -10.88, "p25_pct": -10.94,
"p75_pct": 22.98 "p75_pct": 22.81
}, },
"per_area_bucket": { "per_area_bucket": {
"0 <30": { "0 <30": {
"mape_pct": 19.76, "mape_pct": 19.76,
"median_bias_pct": -1.49, "median_bias_pct": -1.02,
"n": 161, "n": 161,
"p25_pct": -16.45, "p25_pct": -16.45,
"p75_pct": 34.7 "p75_pct": 34.7
@ -45,21 +45,21 @@
"mape_pct": 15.64, "mape_pct": 15.64,
"median_bias_pct": 4.37, "median_bias_pct": 4.37,
"n": 503, "n": 503,
"p25_pct": -10.58, "p25_pct": -10.56,
"p75_pct": 22.86 "p75_pct": 22.86
}, },
"2 44-62": { "2 44-62": {
"mape_pct": 13.86, "mape_pct": 13.93,
"median_bias_pct": 4.15, "median_bias_pct": 4.13,
"n": 379, "n": 379,
"p25_pct": -7.92, "p25_pct": -8.12,
"p75_pct": 23.85 "p75_pct": 23.5
}, },
"3 62-85": { "3 62-85": {
"mape_pct": 15.97, "mape_pct": 15.97,
"median_bias_pct": -2.33, "median_bias_pct": -2.33,
"n": 179, "n": 179,
"p25_pct": -12.1, "p25_pct": -11.99,
"p75_pct": 18.18 "p75_pct": 18.18
}, },
"4 >=85": { "4 >=85": {
@ -74,7 +74,7 @@
"0": { "0": {
"label": "студия", "label": "студия",
"mape_pct": 19.76, "mape_pct": 19.76,
"median_bias_pct": -1.49, "median_bias_pct": -1.02,
"n": 161, "n": 161,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -16.45, "p25_pct": -16.45,
@ -86,17 +86,17 @@
"median_bias_pct": 4.37, "median_bias_pct": 4.37,
"n": 503, "n": 503,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -10.58, "p25_pct": -10.56,
"p75_pct": 22.86 "p75_pct": 22.86
}, },
"2": { "2": {
"label": "2к", "label": "2к",
"mape_pct": 13.86, "mape_pct": 13.93,
"median_bias_pct": 4.15, "median_bias_pct": 4.13,
"n": 379, "n": 379,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -7.92, "p25_pct": -8.12,
"p75_pct": 23.85 "p75_pct": 23.5
}, },
"3": { "3": {
"label": "3к", "label": "3к",
@ -104,7 +104,7 @@
"median_bias_pct": -2.33, "median_bias_pct": -2.33,
"n": 179, "n": 179,
"n_no_analogs": 0, "n_no_analogs": 0,
"p25_pct": -12.1, "p25_pct": -11.99,
"p75_pct": 18.18 "p75_pct": 18.18
}, },
"4": { "4": {
@ -126,11 +126,11 @@
"p75_pct": -12.79 "p75_pct": -12.79
}, },
"комфорт": { "комфорт": {
"mape_pct": 10.68, "mape_pct": 10.69,
"median_bias_pct": -5.78, "median_bias_pct": -5.78,
"n": 425, "n": 425,
"p25_pct": -15.88, "p25_pct": -15.98,
"p75_pct": 4.49 "p75_pct": 4.54
}, },
"премиум": { "премиум": {
"mape_pct": null, "mape_pct": null,
@ -140,11 +140,11 @@
"p75_pct": null "p75_pct": null
}, },
"эконом": { "эконом": {
"mape_pct": 20.17, "mape_pct": 20.22,
"median_bias_pct": 18.98, "median_bias_pct": 18.98,
"n": 697, "n": 697,
"p25_pct": 1.25, "p25_pct": 0.96,
"p75_pct": 41.97 "p75_pct": 42.31
}, },
"элит": { "элит": {
"mape_pct": 36.11, "mape_pct": 36.11,
@ -162,9 +162,9 @@
}, },
"range_coverage": { "range_coverage": {
"overall": { "overall": {
"coverage_pct": 83.22, "coverage_pct": 83.29,
"n": 1269, "n": 1269,
"n_covered": 1056 "n_covered": 1057
}, },
"per_confidence": { "per_confidence": {
"high": { "high": {
@ -173,9 +173,9 @@
"n_covered": 6 "n_covered": 6
}, },
"low": { "low": {
"coverage_pct": 83.59, "coverage_pct": 83.67,
"n": 1231, "n": 1231,
"n_covered": 1029 "n_covered": 1030
}, },
"medium": { "medium": {
"coverage_pct": 70.0, "coverage_pct": 70.0,

View file

@ -36,6 +36,7 @@ import pytest
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py. # Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS
from scraper_kit.providers.avito.imv import ( from scraper_kit.providers.avito.imv import (
IMVAddressNotFoundError as KitIMVAddressNotFoundError, IMVAddressNotFoundError as KitIMVAddressNotFoundError,
) )
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
captured.update(kwargs) captured.update(kwargs)
return mock_session return mock_session
with patch("curl_cffi.requests.AsyncSession", _fake_session): with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
asyncio.run(_call_kit_own_session(config=None)) asyncio.run(_call_kit_own_session(config=None))
assert captured.get("proxies") is None assert captured.get("proxies") is None
@ -123,10 +124,15 @@ def test_kit_evaluate_via_imv_with_config_uses_proxy(monkeypatch: pytest.MonkeyP
captured.update(kwargs) captured.update(kwargs)
return mock_session return mock_session
with patch("curl_cffi.requests.AsyncSession", _fake_session): with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
asyncio.run(_call_kit_own_session(config=RealScraperConfig())) asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
assert captured.get("proxies") == { assert captured.get("proxies") == {
"http": "http://test-proxy.local:8080", "http": "http://test-proxy.local:8080",
"https": "http://test-proxy.local:8080", "https": "http://test-proxy.local:8080",
} }
# #2386: сессия строится общей build_document_session — параметры те же, что были у
# собственной (impersonate/timeout=25/document-заголовки).
assert captured.get("impersonate") == DEFAULT_IMPERSONATE
assert captured.get("timeout") == 25
assert captured.get("headers") == DOCUMENT_HEADERS

View file

@ -83,9 +83,9 @@ _SSR_LITERAL = """{
}, },
"legalOptions": {"saleType": "Свободная продажа"}, "legalOptions": {"saleType": "Свободная продажа"},
"egrnData": { "egrnData": {
"area": 38.2, "area": {"status": "success", "value": 38.2},
"floor": 5, "floor": {"status": "success", "value": 5},
"owners_count": 1, "owners_count": {"status": "success", "value": 1},
"collateral": true, "collateral": true,
"collateral_sber": false "collateral_sber": false
}, },
@ -264,7 +264,7 @@ def test_parse_detail_html_raw_extra() -> None:
# wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type. # wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type.
assert "house_type" not in raw assert "house_type" not in raw
assert raw["domclick_building_guid"] == "abc-guid-123" assert raw["domclick_building_guid"] == "abc-guid-123"
assert raw["egrn_area"] == 38.2 assert raw["egrn_area"] == {"status": "success", "value": 38.2}
assert raw["demand"]["calls"] == 5 assert raw["demand"]["calls"] == 5
assert raw["demand"]["favorites"] == 12 assert raw["demand"]["favorites"] == 12
# AVM (Layer C, top-level pricePrediction) → raw_extra.avm # AVM (Layer C, top-level pricePrediction) → raw_extra.avm

View file

@ -15,6 +15,7 @@ from __future__ import annotations
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
# DATABASE_URL required by config before any app import. # DATABASE_URL required by config before any app import.
@ -28,6 +29,8 @@ from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
resolve_cian_zhk_url_via_search, resolve_cian_zhk_url_via_search,
) )
# config обязателен (#2385); на пути с переданной session он не читается.
_CONFIG = SimpleNamespace(cian_proxy_url=None)
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html" _FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
@ -114,7 +117,7 @@ def test_extract_zhk_url_no_match_returns_none() -> None:
async def test_resolve_via_search_happy_path() -> None: async def test_resolve_via_search_happy_path() -> None:
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed.""" """HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
session = _mock_session(status_code=200, text=_serp_fixture()) session = _mock_session(status_code=200, text=_serp_fixture())
url = await resolve_cian_zhk_url_via_search(48853, session=session) url = await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session)
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
# caller owns a passed-in session — resolver must NOT close it. # caller owns a passed-in session — resolver must NOT close it.
session.close.assert_not_awaited() session.close.assert_not_awaited()
@ -128,14 +131,14 @@ async def test_resolve_via_search_happy_path() -> None:
async def test_resolve_via_search_no_match_returns_none() -> None: async def test_resolve_via_search_no_match_returns_none() -> None:
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift).""" """HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
session = _mock_session(status_code=200, text="<html>no results</html>") session = _mock_session(status_code=200, text="<html>no results</html>")
assert await resolve_cian_zhk_url_via_search(999, session=session) is None assert await resolve_cian_zhk_url_via_search(999, config=_CONFIG, session=session) is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_via_search_non_200_returns_none() -> None: async def test_resolve_via_search_non_200_returns_none() -> None:
"""A non-200 (block / 404) → None without attempting extraction.""" """A non-200 (block / 404) → None without attempting extraction."""
session = _mock_session(status_code=429, text="too many requests") session = _mock_session(status_code=429, text="too many requests")
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
@pytest.mark.asyncio @pytest.mark.asyncio
@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None:
session = MagicMock() session = MagicMock()
session.get = AsyncMock(side_effect=ConnectionError("proxy refused")) session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
session.close = AsyncMock() session.close = AsyncMock()
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None

View file

@ -147,6 +147,13 @@ tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted
tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling
tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction
# Жилая площадь и балконы переживают переобход выдачи (#3252) — тот же `_live_session()`.
# Проверяют ПОВЕДЕНИЕ апсерта на живой схеме: SERP без полей не стирает то, что
# добыла карточка (COALESCE), и настоящее новое значение всё ещё перезаписывает.
# В ci-tradein.yml бегут по-настоящему (postgres-сервис); без БД — skip.
tests/test_3252_domclick_card_fields.py::test_serp_rescrape_keeps_detail_living_area_and_balconies
tests/test_3252_domclick_card_fields.py::test_real_new_value_still_overwrites
# Миграция 310 (#3385): настоящий SQL-файл с DELETE и RAISE-остановкой исполняет только # Миграция 310 (#3385): настоящий SQL-файл с DELETE и RAISE-остановкой исполняет только
# Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от # Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от
# снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09. # снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09.

View file

@ -194,7 +194,7 @@ async def test_backfill_build_warmed_session_receives_config() -> None:
"""#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts """#2397 Part D1 / #2330 regression guard: kit's build_warmed_session() now accepts
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси) config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
-- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed -- NO crash, just a proxy-less session -- unless config=RealScraperConfig() is passed
at the call site. use_curl=True is the prod-default path (avito_detail_backfill_use_curl), at the call site. use_curl=True is the curl path (avito_detail_backfill_use_curl),
so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once() so this is the setup-time build_warmed_session() call (~line 177). assert_awaited_once()
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config= alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
guard above, lines 141-148).""" guard above, lines 141-148)."""

View file

@ -0,0 +1,202 @@
"""Упавший хендлер не оставляет строку scrape_runs в 'running' до zombie-reaper'а (#1940).
Прод 17.09 06:58: avito_city_sweep run 7349 через 10 мс после claim BrowserFetcher.__aenter__
бросил NoProxyAvailableError, планировщик записал «crashed run_id=7349» и всё. Строка
осталась 'running' с heartbeat_at == started_at; 21 сутки до этого так ушли в 'zombie'
18 avito-свипов, каждый через 6 ч. Проверяется значение статуса в строке прогона.
Финализатор `runs.mark_crashed`. Его зовут оба запускающих: планировщик и ручные
запуски из админки (они идут мимо `_dispatch`). Здесь боевой `mark_crashed` над
двойником одной строки scrape_runs: `_Row` отвечает на те же SQL, что пишет runs.py,
с гейтом `WHERE status = 'running'`.
"""
from __future__ import annotations
import asyncio
import os
import re
import types
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, CONSECUTIVE_FAILURE_ALERT_THRESHOLD
from scraper_kit.orchestration.scheduler import Handler, SchedulerContext, _dispatch
from scraper_kit.proxy_errors import NoProxyAvailableError
RUN_ID = 7349
class _Row:
"""Сессия над одной строкой scrape_runs (status/ban_kind/error/counters).
История источника для стрик-алерта `CONSECUTIVE_FAILURE_ALERT_THRESHOLD` неудач:
ровно веха лестницы `_streak_alert_due`, на которой дубль и уходил бы в Sentry.
"""
def __init__(self) -> None:
self.status = "running"
self.ban_kind: str | None = None
self.error: str | None = None
self.updates = 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
p = params or {}
res = MagicMock()
res.fetchone.return_value = None # гейт _claim_run: «не running»
res.scalar.return_value = True # advisory-lock взят
if "INSERT INTO scrape_runs" in sql:
res.fetchone.return_value = types.SimpleNamespace(id=RUN_ID)
elif "SELECT status FROM scrape_runs WHERE id" in sql:
res.fetchone.return_value = types.SimpleNamespace(status=self.status)
elif "UPDATE scrape_runs" in sql and "SET status" in sql:
hit = self.status == "running"
if hit:
m = re.search(r"SET status = '(\w+)'", sql)
assert m is not None
self.status = m.group(1)
self.ban_kind = p.get("ban_kind")
self.error = p.get("error")
self.updates += 1
res.first.return_value = types.SimpleNamespace(id=RUN_ID) if hit else None
elif "SELECT source FROM scrape_runs" in sql:
res.fetchone.return_value = types.SimpleNamespace(source="avito_city_sweep")
elif "SELECT status, counters FROM scrape_runs" in sql:
res.fetchall.return_value = [
types.SimpleNamespace(status="failed", counters={})
] * CONSECUTIVE_FAILURE_ALERT_THRESHOLD
return res
def commit(self) -> None: ...
def rollback(self) -> None: ...
def close(self) -> None: ...
async def _dispatch_and_wait(job: Any, row: _Row) -> None:
ctx = SchedulerContext(
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
session_factory=lambda: row,
)
sched = {
"id": 1,
"source": "avito_city_sweep",
"window_start_hour": 6,
"window_end_hour": 7,
"default_params": {},
}
run_id = await _dispatch(Handler(job, "avito_city_sweep"), row, sched, ctx) # type: ignore[arg-type]
assert run_id == RUN_ID
await asyncio.gather(*ctx._inflight_tasks, return_exceptions=True)
# ── планировщик ─────────────────────────────────────────────────────────────────
async def test_no_proxy_crash_before_try_marks_run_banned_infra() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
raise NoProxyAvailableError("avito")
row = _Row()
await _dispatch_and_wait(_job, row)
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
assert row.error is not None and "no proxy available" in row.error
async def test_wrapped_no_proxy_is_still_infra() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
try:
raise NoProxyAvailableError("cian")
except NoProxyAvailableError as exc:
raise RuntimeError("sidecar fetch failed") from exc
row = _Row()
await _dispatch_and_wait(_job, row)
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)
async def test_other_crash_marks_run_failed() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
raise KeyError("anchors")
row = _Row()
await _dispatch_and_wait(_job, row)
assert row.status == "failed"
assert row.error is not None and "KeyError" in row.error
async def test_handler_own_final_status_survives() -> None:
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
kit_runs.mark_done(db, run_id, {"done_buckets": ["center"]})
raise RuntimeError("after mark_done")
row = _Row()
await _dispatch_and_wait(_job, row)
assert (row.status, row.updates) == ("done", 1)
async def test_pipeline_failure_is_alerted_once_not_twice() -> None:
"""Обычный путь пайплайна: mark_failed и raise. Повторная финализация не шлёт дубль.
Стрик после первой финализации стоит на вехе лестницы; no-op mark_failed из
планировщика позвал бы `_alert_on_run_id` ещё раз с тем же стриком.
"""
async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
kit_runs.mark_failed(db, run_id, "cian-sweep fatal", {})
raise RuntimeError("cian-sweep fatal")
row = _Row()
sentry = MagicMock()
with patch.object(kit_runs, "sentry_sdk", sentry):
await _dispatch_and_wait(_job, row)
assert (row.status, row.updates) == ("failed", 1)
assert sentry.capture_message.call_count == 1
# ── ручные запуски из админки (мимо _dispatch) ─────────────────────────────────
@pytest.mark.parametrize(
("path", "runner"),
[
("avito-city-sweep", "run_avito_city_sweep"),
("cian-city-sweep", "run_cian_city_sweep"),
("cian-full-load", "run_cian_full_load"),
("yandex-full-load", "run_yandex_full_load"),
("yandex-city-sweep", "run_yandex_city_sweep"),
],
)
def test_admin_manual_launch_crash_is_finalized(path: str, runner: str) -> None:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import admin as admin_module
from app.core.db import get_db
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
row = _Row()
app.dependency_overrides[get_db] = lambda: row
with (
patch.object(kit_runs, "sentry_sdk", None),
patch.object(admin_module, "has_running_run", return_value=False),
patch.object(admin_module, "SessionLocal", lambda: row),
patch.object(admin_module, runner, AsyncMock(side_effect=NoProxyAvailableError("avito"))),
):
r = TestClient(app).post(f"/api/v1/admin/scrape/{path}", json={})
assert r.status_code == 200, r.text
assert (row.status, row.ban_kind) == ("banned", BAN_KIND_INFRA)

View file

@ -0,0 +1,77 @@
"""#1970: калибровочные константы движка, вынесенные из Settings, — значения.
Поля estimate_* жили в Settings и формально переопределялись через ENV. При
переносе в константы estimator.py значения обязаны остаться боевыми. Числа ниже
сняты с прода 17.09.2026 (`settings.<поле>` в tradein-backend, tradein-scraper и
tradein-tgbot совпали, ENV-оверрайдов нет), а не скопированы из кода.
Зачем отдельный тест: замороженная фикстура бэктеста часть механизмов не
проходит вовсе (квартальный индекс в реплее применяется 0 раз из 1600 сделок),
а поведенческие тесты ловят не все пороги. Поменять число осознанно: здесь,
в estimator.py и с прогоном регрессионного гейта.
"""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import Settings
from app.services import estimator
# имя константы в estimator.py → (бывшее поле Settings, боевое значение 17.09.2026)
_PROD_VALUES: dict[str, tuple[str, float]] = {
# #2379, #764 квартальный индекс
"QUARTER_INDEX_MIN_N_DEALS": ("estimate_quarter_index_min_n_deals", 10),
"QUARTER_MATCH_SKIP_RATIO": ("estimate_quarter_match_skip_ratio", 0.6),
"QUARTER_INDEX_MAX_FOR_SMALL_N": ("estimate_quarter_index_max_for_small_n", 2.0),
"QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50),
"QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6),
"QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8),
# #2380, #1795 anti-inflation (CORRIDOR_CLAMP_MIN_N определён в app.core.config)
"CORRIDOR_CLAMP_MIN_N": ("estimate_corridor_clamp_min_n", 10),
"CORRIDOR_CLAMP_SLACK": ("estimate_corridor_clamp_slack", 0.40),
"RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8),
"OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15),
"OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0),
# #2381, #651/#652 same-building anchor + IMV-blend
"IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5),
"IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15),
"SB_MIN_COMPS": ("estimate_sb_min_comps", 4),
"SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18),
"SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6),
"SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25),
"SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05),
"SB_MAD_K": ("estimate_sb_mad_k", 3.5),
"SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5),
"SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10),
"ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5),
"FSD_K": ("estimate_fsd_k", 1.65),
"SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3),
"SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20),
}
# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены.
_REMOVED_SWITCHES = (
"estimate_dedup_analogs_enabled", # #2378
"estimate_expected_sold_le_asking", # #2380
)
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
def test_constant_keeps_prod_value(const: str) -> None:
assert getattr(estimator, const) == _PROD_VALUES[const][1]
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
def test_settings_field_is_gone(const: str) -> None:
# Поле вернулось в Settings → снова два источника одного числа (ENV и код).
assert _PROD_VALUES[const][0] not in Settings.model_fields
@pytest.mark.parametrize("field", _REMOVED_SWITCHES)
def test_switch_is_gone(field: str) -> None:
assert field not in Settings.model_fields

View file

@ -0,0 +1,248 @@
"""Крайние случаи city-свипов kit'а, не покрытые после удаления legacy-пайплайна (#2406).
Уже покрыто: avito таймаут якоря и дрейн после первого якоря (test_3319); дрейн ДО
первого якоря у yandex/cian/newbuilding (test_3333); отмена у domclick (test_3369).
Здесь остаток:
(а) таймаут якоря у cian и yandex; у domclick снятая/упавшая фаза не отмечает
корзины, строки которых не сохранены;
(б) SIGTERM-дрейн ПОСЛЕ первого якоря у cian и yandex;
(в) пользовательская отмена (runs.is_cancelled=True) после первого якоря у avito,
cian и yandex.
Проверяется строка прогона, а не вызовы: _FakeDb мержит counters всех записей по
порядку (как `counters || :counters` в runs.py) и запоминает финальный статус.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
import types
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from scraper_kit.orchestration import pipeline as pl
ANCHOR_A = (56.83, 60.60, "ekb-center")
ANCHOR_B = (56.79, 60.63, "ekb-south")
class _FakeDb:
def __init__(self) -> None:
self.counters: dict[str, Any] = {}
self.status = "running"
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
if params and "counters" in params:
self.counters.update(json.loads(params["counters"]))
for st in ("done", "failed", "banned"):
if f"SET status = '{st}'" in sql and self.status == "running":
self.status = st
return MagicMock()
def commit(self) -> None: ...
def rollback(self) -> None: ...
class _Scraper:
"""Двойник Cian/Yandex/AvitoScraper: помнит якоря, роняет заданный TimeoutError'ом."""
visited: list[float] = [] # noqa: RUF012
timeout_on: float | None = None
gate_fetch_attempts = state_extraction_attempts = 1
gate_fetch_failures = state_extraction_failures = 0
request_delay_sec = 0.0
def __init__(self, *_a: Any, **_kw: Any) -> None:
self._browser = None
self._cffi = None
async def __aenter__(self) -> _Scraper:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
def _visit(self, lat: float) -> None:
_Scraper.visited.append(lat)
if lat == _Scraper.timeout_on:
raise TimeoutError
async def fetch_around_multi_room(self, lat: float, *_a: Any, **kw: Any) -> list[Any]:
self._visit(lat)
if kw.get("on_combo") is not None: # yandex: единица чекпоинта — combo
kw["on_combo"](f"combo@{lat}", [])
return []
return [types.SimpleNamespace(listing_segment="vtorichka", house_ext_id=None)]
async def fetch_around(self, lat: float, *_a: Any, **_kw: Any) -> list[Any]:
self._visit(lat)
return []
def _config() -> types.SimpleNamespace:
return types.SimpleNamespace(
scraper_fetch_mode="cffi",
scraper_proxy_url=None,
use_proxy_pool_browser=False,
browser_http_endpoint=None,
environment="test",
avito_serp_ok_not_banned=True,
)
async def _sweep(
kind: str,
*,
timeout_on: float | None = None,
drain_after_first: bool = False,
cancel_after_first: bool = False,
) -> _FakeDb:
_Scraper.visited = []
_Scraper.timeout_on = timeout_on
db = _FakeDb()
common: dict[str, Any] = {
"run_id": 2406,
"config": _config(),
"matcher": MagicMock(),
"anchors": [ANCHOR_A, ANCHOR_B],
"request_delay_sec": 0.0,
"shutdown_requested": lambda: drain_after_first and bool(_Scraper.visited),
}
with (
patch.object(pl, "CianScraper", _Scraper),
patch.object(pl, "YandexRealtyScraper", _Scraper),
patch.object(pl, "AvitoScraper", _Scraper),
patch.object(pl, "AsyncSession", _Scraper),
patch.object(pl, "save_listings", lambda *_a, **_kw: (1, 0)),
patch.object(
pl.runs, "is_cancelled", lambda *_a: cancel_after_first and bool(_Scraper.visited)
),
):
if kind == "cian":
await pl.run_cian_city_sweep(
db, # type: ignore[arg-type]
enrich_houses=False,
detail_top_n=0,
**common,
)
elif kind == "yandex":
enrichment = MagicMock()
enrichment.record_yandex_price_history.return_value = 0
await pl.run_yandex_city_sweep(
db, # type: ignore[arg-type]
enrichment=enrichment,
enrich_address=False,
**common,
)
else:
await pl.run_avito_city_sweep(
db, # type: ignore[arg-type]
enrichment=MagicMock(),
enrich_houses=False,
enrich_imv=False,
detail_top_n=0,
**common,
)
return db
def _done_key(kind: str, anchor: tuple[float, float, str]) -> str:
return f"combo@{anchor[0]}" if kind == "yandex" else anchor[2]
# ── (а) таймаут якоря: якорь не засчитан, следующий обработан ──────────────────
@pytest.mark.parametrize("kind", ["cian", "yandex"])
async def test_anchor_timeout_skips_anchor_and_continues(kind: str) -> None:
db = await _sweep(kind, timeout_on=ANCHOR_A[0])
assert _Scraper.visited == [ANCHOR_A[0], ANCHOR_B[0]]
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_B)]
assert db.counters["errors_count"] >= 1
@pytest.mark.parametrize("broken", ["fetch_timeout", "save_failed"])
async def test_domclick_unsaved_buckets_are_not_checkpointed(broken: str) -> None:
"""Корзина без сохранённых строк не попадает в чекпоинт.
Лоты Домклика копятся в памяти и пишутся ОДНИМ save_listings после всех корзин.
Фаза, снятая watchdog'ом (или упавшая на save), не сохранила ничего — даже из
корзины 'st', которую скрейпер успел пройти. Отметить её пройденной значило бы,
что следующий прогон пропустит её через skip_buckets навсегда (миграция 308).
"""
class _Dc(_Scraper):
blocked = False
geo_filtered = fetch_errors = bucket_start_index = 0
buckets_completed, buckets_total = 1, 6
completed_buckets = ["st"] # noqa: RUF012
async def fetch_city(self, **_kw: Any) -> list[Any]:
if broken == "fetch_timeout":
raise TimeoutError
return [object()]
saved: list[int] = []
def _save(_db: Any, lots: list[Any], **_kw: Any) -> tuple[int, int]:
if broken == "save_failed":
raise RuntimeError("save_listings упал")
saved.append(len(lots))
return len(lots), 0
db = _FakeDb()
with (
patch.object(pl, "DomClickScraper", _Dc),
patch.object(pl, "save_listings", _save),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
counters = await pl.run_domclick_city_sweep(
db, # type: ignore[arg-type]
run_id=2406,
config=types.SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
pages=1,
request_delay_sec=0.0,
)
assert saved == [], "тест не о том: строки сохранились"
assert counters.errors_count >= 1
assert db.counters["done_buckets"] == [], (
f"корзины без единой сохранённой строки в чекпоинте: {db.counters['done_buckets']}"
)
assert db.status == "failed"
# ── (б) дрейн после первого якоря: interrupted=1, в чекпоинте ровно первый ──────
@pytest.mark.parametrize("kind", ["cian", "yandex"])
async def test_drain_after_first_anchor_is_partial_and_interrupted(kind: str) -> None:
db = await _sweep(kind, drain_after_first=True)
assert _Scraper.visited == [ANCHOR_A[0]]
assert db.counters["interrupted"] == 1
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_A)]
assert db.status == "done"
# ── (в) пользовательская отмена после первого якоря ────────────────────────────
@pytest.mark.parametrize("kind", ["avito", "cian", "yandex"])
async def test_user_cancel_after_first_anchor_stops_without_finalizing(kind: str) -> None:
db = await _sweep(kind, cancel_after_first=True)
assert _Scraper.visited == [ANCHOR_A[0]]
assert db.counters["done_buckets"] == [_done_key(kind, ANCHOR_A)]
assert "interrupted" not in db.counters
# Строку финализирует сам mark_cancelled (UI); свип только останавливается.
assert db.status == "running"

View file

@ -0,0 +1,28 @@
"""#3045: дефолт avito_detail_backfill_use_curl — браузер, а не curl.
curl_cffi не решает QRATOR PoW Авито (замер 21.08: ~6% обогащений против ~74% на
браузере). Пока дефолт был True, браузер включал только environment сервиса scraper,
а tradein-backend (ручка админки scrape_avito_detail_backfill исполняет добор в своём
процессе) шёл curl'ом. Проверка по значению поля Settings без env и без .env-файла.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.core.config import Settings
def test_detail_backfill_defaults_to_browser(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("AVITO_DETAIL_BACKFILL_USE_CURL", raising=False)
assert Settings(_env_file=None).avito_detail_backfill_use_curl is False
def test_env_still_switches_to_curl(monkeypatch: pytest.MonkeyPatch) -> None:
"""Контроль: переменная окружения по-прежнему переключает режим — поле живое."""
monkeypatch.setenv("AVITO_DETAIL_BACKFILL_USE_CURL", "true")
assert Settings(_env_file=None).avito_detail_backfill_use_curl is True

View file

@ -0,0 +1,45 @@
"""#3182: режим detail-добора Авито не зависит от того, какой контейнер взял задачу.
Добор исполняют два процесса образа backend: планировщик (tradein-scraper) и ручка
админки scrape_avito_detail_backfill (tradein-backend). Хостовый backend/.env.runtime
задаёт AVITO_DETAIL_BACKFILL_USE_CURL=true, а environment сервиса перекрывает env_file
до правки "false" стояло только у scraper, и printenv на проде давал scraper=false,
backend=true. Проверка по значению: разобранный compose, каждый сервис образа backend.
"""
import os
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
import yaml
from app.core.config import Settings
_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml"
def test_every_backend_image_service_pins_browser_transport() -> None:
services = yaml.safe_load(_COMPOSE.read_text())["services"]
backend_image = {
name: svc
for name, svc in services.items()
if "gendesign-tradein-backend" in svc.get("image", "")
}
# Контроль разбора: без него пустой словарь дал бы зелёный тест ни о чём.
assert {"backend", "scraper"} <= set(backend_image), sorted(backend_image)
values = {
name: (svc.get("environment") or {}).get("AVITO_DETAIL_BACKFILL_USE_CURL")
for name, svc in backend_image.items()
}
assert values == dict.fromkeys(backend_image, "false"), values
def test_leftover_yandex_cookies_env_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
"""YANDEX_COOKIES_FILE удалён из Settings (никто не читал); хостовый .env.runtime
его ещё несёт старт не должен падать, а поле не должно всплыть обратно."""
monkeypatch.setenv("YANDEX_COOKIES_FILE", "/app/yandex_cookies.json")
assert not hasattr(Settings(_env_file=None), "yandex_cookies_file")

View file

@ -1,8 +1,7 @@
"""#3197 (хвост) — две служебные ручки admin.py и пул прокси: одна чинится, вторая НЕТ. """#3197 (хвост) + #3410 — две служебные ручки admin.py подключают пул прокси.
`POST /admin/scrape/domclick/debug/detail-fetch` **настоящий фикс**. Он ходит через `POST /admin/scrape/domclick/debug/detail-fetch` ходит через `BrowserFetcher.fetch`, который
`BrowserFetcher.fetch`, а `fetch`/`fetch_json` единственные методы, которые кладут кладёт `payload["proxy"]` в тело POST /fetch сайдкара, а сайдкар этот override читает
`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает
(`_resolve_proxy_override` `_ensure_browser(provider, proxy_override=...)`). Прямая (`_resolve_proxy_override` `_ensure_browser(provider, proxy_override=...)`). Прямая
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
`proxy_provider`/`use_pool`/`environment` без них сайдкар брал свой env-узел `proxy_provider`/`use_pool`/`environment` без них сайдкар брал свой env-узел
@ -10,17 +9,11 @@
прод-отказ «пул пуст не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на прод-отказ «пул пуст не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs. `environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
`POST /admin/scrape/cian/auto-login` **намеренно без пула**, и это проверяется обратными `POST /admin/scrape/cian/auto-login` (#3410) — до правки намеренно без пула: `/login`
по значению утверждениями. Логин идёт не через `fetch`, а через `login` `_post_login`, сайдкара proxy-override не принимал, `_post_login` его не клал, и логин шёл с того же
который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler` мёртвого env-узла при любом теле. Теперь `/login` берёт proxy из тела, `login()` кладёт
(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и туда узел аренды, а ручка передаёт провайдер. Пустой пул на проде честный 503 «нет
`_ensure_browser(provider)` без override то есть **сайдкар на `/login` proxy-override не узла для cian», а не 502 «Browser login failed» и не заход на мёртвый env-узел.
принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы
холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а
при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST
и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во
время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся
ради endpoint/environment из одного места); пул для логина отдельная задача сайдкара.
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` — на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
@ -81,17 +74,20 @@ class _RealLeaseFetcher(BrowserFetcher):
""" """
captured: ClassVar[list[dict[str, Any]]] = [] captured: ClassVar[list[dict[str, Any]]] = []
login_proxy: ClassVar[list[str | None]] = []
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
_RealLeaseFetcher.captured.append(kwargs) _RealLeaseFetcher.captured.append(kwargs)
super().__init__(**kwargs) super().__init__(**kwargs)
async def login(self, **_kwargs: Any) -> dict[str, str]: async def login(self, **_kwargs: Any) -> dict[str, str]:
# Узел, который настоящий login() положил бы в тело /login (см. его код).
_RealLeaseFetcher.login_proxy.append(self._current_proxy()[0])
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES} return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
class _SpyProvider: class _SpyProvider:
"""Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None. """Спай пула: `acquire` пишет вызовы в ClassVar и отдаёт `lease` (None — пул пуст).
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам, ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
и «ни разу не позвали» должно покрывать в том числе «даже не создали». и «ни разу не позвали» должно покрывать в том числе «даже не создали».
@ -99,10 +95,11 @@ class _SpyProvider:
acquired: ClassVar[list[str]] = [] acquired: ClassVar[list[str]] = []
released: ClassVar[list[int]] = [] released: ClassVar[list[int]] = []
lease: ClassVar[ProxyLease | None] = None
def acquire(self, provider: str) -> ProxyLease | None: def acquire(self, provider: str) -> ProxyLease | None:
_SpyProvider.acquired.append(provider) _SpyProvider.acquired.append(provider)
return None return _SpyProvider.lease
def release(self, lease: ProxyLease) -> None: def release(self, lease: ProxyLease) -> None:
_SpyProvider.released.append(lease.id) _SpyProvider.released.append(lease.id)
@ -124,8 +121,10 @@ def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider) monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
_CapturingFetcher.captured = [] _CapturingFetcher.captured = []
_RealLeaseFetcher.captured = [] _RealLeaseFetcher.captured = []
_RealLeaseFetcher.login_proxy = []
_SpyProvider.acquired = [] _SpyProvider.acquired = []
_SpyProvider.released = [] _SpyProvider.released = []
_SpyProvider.lease = None
def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None: def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None:
@ -149,13 +148,11 @@ def _patched_cian_login(stack: ExitStack, fetcher_cls: type) -> None:
stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock())) stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock()))
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
@pytest.mark.usefixtures("_pool_on") @pytest.mark.usefixtures("_pool_on")
async def test_cian_auto_login_does_not_lease_from_pool( async def test_cian_auto_login_logs_in_through_pool_lease(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str """#3410: прод + узел в пуле → логин идёт через него, аренда отпускается."""
) -> None: _cian_login_settings(monkeypatch, use_pool=True, env="production")
"""Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг).""" _SpyProvider.lease = ProxyLease(id=13, url="http://u:p@pool:8080", kind="http")
_cian_login_settings(monkeypatch, use_pool=use_pool, env=environment)
with ExitStack() as stack: with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher) _patched_cian_login(stack, _RealLeaseFetcher)
@ -165,8 +162,12 @@ async def test_cian_auto_login_does_not_lease_from_pool(
assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер" assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
kwargs = _RealLeaseFetcher.captured[0] kwargs = _RealLeaseFetcher.captured[0]
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError. # .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен" assert kwargs.get("proxy_provider") is not None, "без провайдера логин идёт с env-узла"
assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)" assert kwargs.get("use_pool") is True
assert kwargs.get("environment") == "production"
assert _SpyProvider.acquired == ["cian"]
assert _RealLeaseFetcher.login_proxy == ["http://u:p@pool:8080"]
assert _SpyProvider.released == [13], "аренда логина не должна течь"
# endpoint из фабрики не должен потеряться (#2322: без него TypeError). # endpoint из фабрики не должен потеряться (#2322: без него TypeError).
from app.core.config import settings from app.core.config import settings
@ -174,28 +175,40 @@ async def test_cian_auto_login_does_not_lease_from_pool(
@pytest.mark.usefixtures("_pool_on") @pytest.mark.usefixtures("_pool_on")
async def test_cian_auto_login_survives_empty_pool_in_production( async def test_cian_auto_login_empty_pool_in_production_is_honest_503(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502). """Пустой пул на проде → 503 «нет узла для cian», логин не запускается.
Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при Не 502 «Browser login failed» (читается как сломанные селекторы/креды) и не заход на
`use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`, env-узел сайдкара (#2616: на проде он мёртв).
`cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` то
есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию,
хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает).
""" """
_cian_login_settings(monkeypatch, use_pool=True, env="production") _cian_login_settings(monkeypatch, use_pool=True, env="production")
with ExitStack() as stack: with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher) _patched_cian_login(stack, _RealLeaseFetcher)
try: with pytest.raises(HTTPException) as exc_info:
result = await admin.cian_auto_login(db=MagicMock(), body=None) await admin.cian_auto_login(db=MagicMock(), body=None)
except HTTPException as exc:
# pytest.fail, а не re-raise: красное должно называть статус и detail.
pytest.fail(f"пустой пул уронил ручку восстановления: {exc.status_code} {exc.detail}")
assert result["ok"] is True and result["userId"] == 7 assert (exc_info.value.status_code, "пул" in str(exc_info.value.detail)) == (503, True)
assert _SpyProvider.acquired == ["cian"]
assert _RealLeaseFetcher.login_proxy == [], "логин без узла запускаться не должен"
@pytest.mark.usefixtures("_pool_on")
async def test_cian_auto_login_dev_without_browser_pool_uses_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""dev + browser-пул выключен → аренды нет, логин без proxy в теле (env сайдкара)."""
_cian_login_settings(monkeypatch, use_pool=False, env="dev")
with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher)
result = await admin.cian_auto_login(db=MagicMock(), body=None)
assert result["ok"] is True
assert _SpyProvider.acquired == []
assert _RealLeaseFetcher.login_proxy == [None]
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")]) @pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])

View file

@ -0,0 +1,78 @@
"""#3234: порог и окно этажности Tier H берутся из settings, дефолты = прежние литералы.
Tier H в _fetch_analogs отдаёт «тот же класс дома» только при N комплов, иначе
уходит в Tier W без фильтра по классу. N и окно total_floors были литералами (5 и
×0.7×1.3); вынесены, чтобы бэктест сравнил варианты через env. Проверяем значения,
которые реально уходят в SQL, и тир, который реально возвращается.
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
import app.services.estimator as est
def _run(n_tier_h: int, total_floors: int) -> tuple[str, dict]:
"""_fetch_analogs на mock-БД: Tier H отдаёт n_tier_h строк, Tier W — одну."""
tier_h_params: dict = {}
def side_effect(stmt, params): # type: ignore[no-untyped-def]
result = MagicMock()
if "tf_min" in params:
tier_h_params.update(params)
rows = [{"source": "avito", "relevance_score": float(i)} for i in range(n_tier_h)]
else:
rows = [{"source": "cian", "relevance_score": 0.0}]
result.mappings.return_value.all.return_value = rows
return result
db = MagicMock()
db.execute.side_effect = side_effect
_, _, tier = est._fetch_analogs(
db,
lat=56.83,
lon=60.6,
rooms=2,
area=50.0,
radius_m=1000,
year_built=1975,
total_floors=total_floors,
)
return tier, tier_h_params
def test_defaults_keep_old_literals() -> None:
tier, params = _run(n_tier_h=5, total_floors=9)
assert (params["tf_min"], params["tf_max"]) == (6, 12) # floor(6.3), ceil(11.7)
assert tier == "H"
tier, params = _run(n_tier_h=4, total_floors=16)
assert (params["tf_min"], params["tf_max"]) == (11, 21) # floor(11.2), ceil(20.8)
assert tier == "W"
def test_min_comps_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(est.settings, "estimate_tier_h_min_comps", 4)
assert _run(n_tier_h=4, total_floors=9)[0] == "H"
assert _run(n_tier_h=3, total_floors=9)[0] == "W"
def test_floors_tol_from_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(est.settings, "estimate_tier_h_floors_tol", 0.5)
_, params = _run(n_tier_h=5, total_floors=9)
assert (params["tf_min"], params["tf_max"]) == (4, 14) # floor(4.5), ceil(13.5)
def test_env_reaches_settings(monkeypatch: pytest.MonkeyPatch) -> None:
# Бэктест вариантов идёт через `docker exec -e ESTIMATE_TIER_H_…` — env обязан доехать.
from app.core.config import Settings
monkeypatch.setenv("ESTIMATE_TIER_H_MIN_COMPS", "4")
monkeypatch.setenv("ESTIMATE_TIER_H_FLOORS_TOL", "0.5")
s = Settings()
assert (s.estimate_tier_h_min_comps, s.estimate_tier_h_floors_tol) == (4, 0.5)

View file

@ -0,0 +1,192 @@
"""#3252: три тихие дыры в полях карточки Домклика.
1. owners_count. egrnData отдаёт поля ЕГРН обёрткой ``{"status", "value"}``, а парсер
делал ``int()`` от словаря и молча получал None: на проде 0 из ~2970 карточек,
обогащённых с 29.08 (при 5075 из 6296 у ручного прогона 18.07, который обёртку
разворачивал).
2. Нулевые площади. ``livingArea: 0`` / ``kitchenArea: 0`` у Домклика значит «не
указано», а в колонку ложился 0.00 (303 кухни и 84 жилые площади).
3. Жилая площадь и балконы стирались переобходом выдачи: апсерт в scraper_kit.base
писал их сырым ``EXCLUDED``, а SERP Домклика и Авито этих полей не отдаёт. У
domklik, переобойдённых после обогащения, living_area_m2 = 0 из 5217.
Состояние карточек ниже урезанный ``__SSR_STATE__`` живых карточек 2074362051 и
2078257603, снятый 17.09 (значения как есть, лишние ветки выкинуты).
"""
from __future__ import annotations
import json
import os
import uuid
from decimal import Decimal
from typing import Any
from unittest.mock import MagicMock
import pytest
from scraper_kit.base import ScrapedLot, save_listings
from scraper_kit.providers.domclick.detail import parse_detail_html
from sqlalchemy import text
_LIVE_2074362051 = {
"productCard": {
"objectInfo": {
"area": 59.8,
"balconies": 1,
"kitchenArea": 14.9,
"livingArea": 25.8,
"renovation": "Косметический",
},
"egrnData": {
"area": {"status": "success", "value": 59.8},
"collateral": False,
"collateral_sber": False,
"floor": {"status": "success", "value": 14},
"owners_count": {"status": "error", "value": 6},
},
},
}
_LIVE_2078257603 = {
"productCard": {
"objectInfo": {
"area": 67.4,
"balconies": 0,
"kitchenArea": 0,
"livingArea": 0,
"renovation": "",
},
"egrnData": {
"area": {"status": "error", "value": 65.3},
"collateral": False,
"collateral_sber": False,
"floor": {"status": "success", "value": 21},
"owners_count": {"status": "success", "value": 1},
},
},
}
def _parse(state: dict, card_id: str):
html = f"<script>window.__SSR_STATE__ = {json.dumps(state, ensure_ascii=False)};</script>"
return parse_detail_html(html, f"https://ekaterinburg.domclick.ru/card/sale__flat__{card_id}")
# ── 1-2. Разбор живой формы ──────────────────────────────────────────────────
def test_owners_count_is_read_from_egrn_wrapper_whatever_the_status() -> None:
# status=error у owners_count = «много собственников», value — сам факт ЕГРН.
assert _parse(_LIVE_2074362051, "2074362051").owners_count == 6
assert _parse(_LIVE_2078257603, "2078257603").owners_count == 1
def test_egrn_area_keeps_wrapper_in_raw_payload() -> None:
e = _parse(_LIVE_2078257603, "2078257603")
assert e.raw_extra["egrn_area"] == {"status": "error", "value": 65.3}
def test_zero_areas_mean_unknown_not_zero() -> None:
zero = _parse(_LIVE_2078257603, "2078257603")
assert (zero.living_area_m2, zero.kitchen_area_m2) == (None, None)
real = _parse(_LIVE_2074362051, "2074362051")
assert (real.living_area_m2, real.kitchen_area_m2) == (25.8, 14.9)
# ── 3. Переобход выдачи не стирает то, что добыла карточка (живой Postgres) ──
def _live_session() -> Any | None:
try:
from sqlalchemy import create_engine
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)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
def _matcher() -> MagicMock:
m = MagicMock()
m.match_or_create_house.return_value = (None, 0.0, "no_address")
m.upsert_listing_source.return_value = None
return m
def _serp_lot(sid: str, **detail: Any) -> ScrapedLot:
return ScrapedLot(
source="domklik",
source_url=f"https://ekaterinburg.domclick.ru/card/sale__flat__t3252{sid}",
source_id=f"t3252-{sid}",
price_rub=7_000_000,
**detail,
)
def _row(db: Any, sid: str) -> Any:
return db.execute(
text(
"SELECT living_area_m2, balconies_count FROM listings "
"WHERE source='domklik' AND source_id = :sid"
),
{"sid": f"t3252-{sid}"},
).fetchone()
def _cleanup(db: Any) -> None:
try:
db.rollback()
ids = "(SELECT id FROM listings WHERE source='domklik' AND source_id LIKE 't3252-%')"
db.execute(text(f"DELETE FROM listings_snapshots WHERE listing_id IN {ids}"))
db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't3252-%'"))
db.execute(text("DELETE FROM listings WHERE source='domklik' AND source_id LIKE 't3252-%'"))
db.commit()
finally:
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_serp_rescrape_keeps_detail_living_area_and_balconies() -> None:
db = _live_session()
sid = uuid.uuid4().hex[:8]
try:
save_listings(
db,
[_serp_lot(sid, living_area_m2=25.8, balconies_count=1)],
matcher=_matcher(),
region_code=66,
)
save_listings(db, [_serp_lot(sid)], matcher=_matcher(), region_code=66) # выдача: полей нет
row = _row(db, sid)
assert (row.living_area_m2, row.balconies_count) == (Decimal("25.80"), 1)
finally:
_cleanup(db)
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_new_value_still_overwrites() -> None:
db = _live_session()
sid = uuid.uuid4().hex[:8]
try:
save_listings(
db,
[_serp_lot(sid, living_area_m2=25.8, balconies_count=1)],
matcher=_matcher(),
region_code=66,
)
save_listings(
db,
[_serp_lot(sid, living_area_m2=30.5, balconies_count=2)],
matcher=_matcher(),
region_code=66,
)
row = _row(db, sid)
assert (row.living_area_m2, row.balconies_count) == (Decimal("30.50"), 2)
finally:
_cleanup(db)

View file

@ -229,6 +229,25 @@ async def test_sidecar_run_walks_the_whole_snapshot_and_is_not_banned() -> None:
) )
@pytest.mark.asyncio
async def test_done_run_keeps_ban_kinds_in_counters() -> None:
"""#3288 п.5: диагноз доезжает в counters и у прогона, который не 'banned'.
Прогоны 7193/7264/7302 (1600 попыток, ~1599 отказов сайдкара, status='done') легли
в БД без ban_kinds: перепись писалась в counters только на ветке бана.
"""
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
fetches += [MagicMock() for _ in range(10)]
_, runs = await _run_avito(fetches, snapshot_size=30)
written = runs.mark_backfill_finished.call_args.args[2]
assert written.get("ban_kinds") == {BAN_KIND_INFRA: 20}, (
f"counters={written}: прогон без обрыва уходит в mark_done с этими counters — "
"без переписи диагноз отказов теряется"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_real_platform_bans_still_abort_by_ratio() -> None: async def test_real_platform_bans_still_abort_by_ratio() -> None:
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше.""" """Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
@ -371,3 +390,113 @@ def test_platform_dominant_run_is_still_banned() -> None:
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, ( assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'" f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
) )
# ── #3439: серия отказов НАШЕГО тракта рвёт прогон своим критерием ─────────────
#
# Прогоны 7193/7264/7302: по 1600 попыток, 1-2 карточки, ~1599 отказов сайдкара
# (InvalidIP-шторм на узле), status='done'. Infra ушёл из доли (#3288), а своего
# предохранителя не получил. Порог — max_consecutive_failures (дефолт 25): тот же
# разряд «отказ нашей стороны», что и отказы-не-блоки.
_INFRA_LIMIT = 25
def _infra_breaker() -> BlockRatioBreaker:
return BlockRatioBreaker(
window_size=_WINDOW,
ratio_threshold=_THRESHOLD,
safety_min=5,
snapshot_size=1600,
infra_streak_limit=_INFRA_LIMIT,
)
def test_infra_streak_aborts_exactly_at_limit() -> None:
breaker = _infra_breaker()
breaker.record_success()
for _ in range(_INFRA_LIMIT - 1):
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() is None, "24 отказа подряд — ещё не порог 25"
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() == "infra_streak", (
f"abort_reason={breaker.abort_reason()!r} на 25-м отказе сайдкара подряд"
)
@pytest.mark.parametrize("answer", ["success", "platform", "gone"])
def test_any_platform_answer_resets_infra_streak(answer: str) -> None:
"""Площадка ответила (успех / её блок / 404) — тракт жив, серия с нуля."""
breaker = _infra_breaker()
for _ in range(_INFRA_LIMIT - 1):
breaker.record_block(BAN_KIND_INFRA)
{
"success": breaker.record_success,
"platform": lambda: breaker.record_block(BAN_KIND_PLATFORM),
"gone": breaker.record_neutral,
}[answer]()
for _ in range(_INFRA_LIMIT - 1):
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() is None, (
f"abort_reason={breaker.abort_reason()!r}: 24 + ответ площадки ({answer}) + 24 — "
"серии в 25 подряд не было"
)
@pytest.mark.asyncio
async def test_sidecar_storm_stops_run_as_failed_not_done() -> None:
"""Шторм сайдкара: 1 успех, дальше только отказы — обрыв на 25-м, 'failed'."""
fetches: list[Any] = [MagicMock()]
fetches += [AvitoSidecarUnavailableError("browser unavailable 503") for _ in range(1599)]
counters, runs = await _run_avito(fetches, snapshot_size=1600)
_assert_identity(counters, expected_attempted=1 + _INFRA_LIMIT)
assert runs.mark_backfill_finished.call_args is None, (
"прогон ушёл в backfill-honest-status: там 1 карточка превращает шторм в 'done'"
)
failed = runs.mark_failed.call_args
assert failed is not None, "прогон не финализирован mark_failed"
written = failed.args[3]
assert (written["abort_reason"], written["ban_kinds"]) == (
"infra_streak",
{BAN_KIND_INFRA: _INFRA_LIMIT},
), f"counters={written}"
assert "тракт недоступен" in failed.args[2], f"причина={failed.args[2]!r}"
assert runs.mark_banned.call_args is None, "площадка не отказывала — не 'banned'"
@pytest.mark.asyncio
async def test_interleaved_platform_answers_do_not_trip_infra_stop() -> None:
"""Контроль: 24 отказа сайдкара + ответ площадки, трижды — снапшот пройден целиком."""
fetches: list[Any] = []
for answer in (AvitoBlockedError("firewall"), MagicMock(), MagicMock()):
fetches += [AvitoSidecarUnavailableError("503") for _ in range(_INFRA_LIMIT - 1)]
fetches.append(answer)
counters, runs = await _run_avito(fetches, snapshot_size=len(fetches))
_assert_identity(counters, expected_attempted=len(fetches))
finished = runs.mark_backfill_finished.call_args
assert finished is not None and finished.args[2].get("abort_reason") is None, (
f"прогон оборван: counters={finished and finished.args[2]}, "
f"mark_failed={runs.mark_failed.call_args}"
)
@pytest.mark.asyncio
async def test_quarter_platform_blocks_run_is_not_aborted() -> None:
"""Контроль: здоровый прогон с 25% настоящих блоков не рвётся ни одним критерием."""
fetches: list[Any] = []
for _ in range(10):
fetches += [MagicMock(), MagicMock(), MagicMock(), AvitoBlockedError("firewall")]
counters, runs = await _run_avito(fetches, snapshot_size=40)
_assert_identity(counters, expected_attempted=40)
finished = runs.mark_backfill_finished.call_args
assert finished is not None and finished.args[2].get("abort_reason") is None, (
f"counters={finished and finished.args[2]}: 10 блоков из 40 — рвать не по чему"
)

View file

@ -162,7 +162,7 @@ def test_empty_pool_in_production_degrades_without_imv(
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта # патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
# оставило бы тест зелёным против фантома. # оставило бы тест зелёным против фантома.
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider), patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider),
patch("curl_cffi.requests.AsyncSession", _no_http), patch("scraper_kit.providers._base.AsyncSession", _no_http),
): ):
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4") result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
@ -213,7 +213,7 @@ def _run_with_pool(
with ( with (
patch.object(estimator, "RealProxyProvider", lambda: provider), patch.object(estimator, "RealProxyProvider", lambda: provider),
patch.object(estimator, "save_imv_evaluation", return_value=1), patch.object(estimator, "save_imv_evaluation", return_value=1),
patch("curl_cffi.requests.AsyncSession", lambda *a, **kw: session), patch("scraper_kit.providers._base.AsyncSession", lambda *a, **kw: session),
# Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него. # Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()), patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
patch( patch(

View file

@ -0,0 +1,322 @@
"""Бакет уходит в чекпоинт только если ВСЕ его фазы что-то дали (#3415).
Прод-факт. `cian_city_sweep` 6179 (06.09) статус `failed` с причиной
«phase-honest-status: фаза 'houses' отказала полностью 30 из 30 попыток
неудачны, обогащено 0», а `counters.done_buckets` при этом несёт ВСЕ пять якорей
(«Академический», «Пионерский», «Уралмаш», «Центр», «ЮЗ»). Следующий прогон 6276
(07.09) резюмировался от него (`resume_from: 6179`, `resume_buckets: 5`),
пропустил все якоря и отчитался `done` с `errors_count=0`, `houses_attempted=0`,
`lots_fetched=0` зелёный по построению: фаза не исполнялась. Из-за этого фикс
#3396 (houses через пул прокси) простоял на проде с 06.09 и ни разу не отработал.
ИНВАРИАНТ. Отказ ЦЕЛОЙ фазы внутри якоря это «якорь пройден не был», ровно как
исключение или таймаут (#3074/#3319). Разница только в том, что отказ фазы виден
не потоком управления, а бухгалтерией счётчиков: `houses_attempted` попыток,
`houses_failed` отказов, обогащено ноль. Порог здесь одна попытка, а не три,
как у run-level `_phase_totally_failed` (#2700): тот СТАВИТ ДИАГНОЗ прогону и
обязан отсеивать шум, а этот решает «собирать ли якорь заново», и цена ошибок
несимметрична лишний повтор якоря стоит нескольких запросов, а ложная отметка
«пройден» теряет часть города навсегда и молча.
"""
from __future__ import annotations
import os
# Settings собирается автофикстурой conftest'а и требует database_url.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
import types
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
ANCHOR_A = (56.83, 60.60, "ekb-center")
ANCHOR_B = (56.79, 60.63, "ekb-south")
# Два дома на якорь — НИЖЕ порога run-level правила (_PHASE_MIN_ATTEMPTS = 3):
# гейт бакета обязан сработать на своей, более строгой границе.
HOUSE_ROWS = [
{"id": 101, "cian_zhk_url": "https://cian.ru/zhk/101"},
{"id": 102, "cian_zhk_url": "https://cian.ru/zhk/102"},
]
DETAIL_ROWS = [
{"id": 201, "source_url": "/ekb/kvartiry/201"},
{"id": 202, "source_url": "/ekb/kvartiry/202"},
]
class _FakeDb:
def __init__(
self,
prev_counters: dict[str, Any] | None = None,
house_rows: list[dict[str, Any]] | None = None,
) -> None:
self.prev_counters = prev_counters or {}
# [] — запрос домов падает (ветка «houses DB query failed»).
self.house_rows = HOUSE_ROWS if house_rows is None else house_rows
self.heartbeats: list[dict[str, Any]] = []
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
if params and "counters" in params:
self.heartbeats.append(json.loads(params["counters"]))
return MagicMock()
if params and "rid" in params:
return MagicMock(fetchone=lambda: types.SimpleNamespace(counters=self.prev_counters))
if params and "ids" in params:
# houses-фаза: дома, у которых есть cian_zhk_url.
if not self.house_rows:
raise RuntimeError("houses DB query failed")
return MagicMock(mappings=lambda: MagicMock(all=lambda: self.house_rows))
if params and "limit" in params:
# detail-фаза avito: карточки-кандидаты на обогащение.
return MagicMock(mappings=lambda: MagicMock(all=lambda: DETAIL_ROWS))
return MagicMock()
def commit(self) -> None: ...
def rollback(self) -> None: ...
def _lot() -> types.SimpleNamespace:
"""Новостроечный лот с привязкой к ЖК — только такой доходит до houses-фазы."""
return types.SimpleNamespace(
listing_segment="novostroyki",
house_source="cian_newbuilding",
house_ext_id="nb-1",
)
class _FakeScraper:
"""Двойник CianScraper: помнит, за какими якорями реально ходили."""
visited: list[str] = [] # noqa: RUF012 — тестовый сборник
def __init__(self, *_a: Any, **_kw: Any) -> None:
self.state_extraction_attempts = 1
self.state_extraction_failures = 0
self.request_delay_sec = 0.0
async def __aenter__(self) -> _FakeScraper:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
async def fetch_around_multi_room(
self, lat: float, lon: float, *_a: Any, **_kw: Any
) -> list[types.SimpleNamespace]:
_FakeScraper.visited.append(f"{lat},{lon}")
return [_lot()]
def _config() -> types.SimpleNamespace:
return types.SimpleNamespace(
scraper_proxy_url=None,
scraper_fetch_mode="cffi",
use_proxy_pool_browser=False,
browser_http_endpoint=None,
environment="test",
)
async def _run(
prev: dict[str, Any] | None,
*,
houses_ok: bool,
run_id: int = 8401,
house_rows: list[dict[str, Any]] | None = None,
failing_urls: frozenset[str] | None = None,
) -> tuple[_FakeDb, Any]:
from scraper_kit.orchestration import pipeline as pl
_FakeScraper.visited = []
db = _FakeDb(prev, house_rows)
async def _fake_newbuilding(zhk_url: str, *_a: Any, **_kw: Any) -> Any:
# None — ровно тот отказ, что был на проде 06.09: исключения нет,
# счётчик houses_failed растёт, фаза возвращается штатно.
if failing_urls is not None:
return None if zhk_url in failing_urls else object()
return object() if houses_ok else None
with (
patch.object(pl, "CianScraper", _FakeScraper),
patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)),
patch.object(pl, "fetch_newbuilding", _fake_newbuilding),
patch.object(pl, "save_newbuilding_enrichment", lambda *_a, **_kw: None),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
counters = await pl.run_cian_city_sweep(
db, # type: ignore[arg-type]
run_id=run_id,
config=_config(),
matcher=MagicMock(),
anchors=[ANCHOR_A, ANCHOR_B],
enrich_houses=True,
detail_top_n=0,
request_delay_sec=0.0,
resume_run_id=(run_id - 1) if prev is not None else None,
)
return db, counters
def _last_checkpoint(db: _FakeDb) -> list[str]:
with_ckpt = [hb for hb in db.heartbeats if "done_buckets" in hb]
assert with_ckpt, "ни один heartbeat не унёс done_buckets — чекпоинт не персистится"
return with_ckpt[-1]["done_buckets"]
@pytest.mark.asyncio
async def test_anchor_with_totally_failed_houses_is_not_checkpointed() -> None:
"""(а) Якорь, где houses отказала на ВСЕХ домах, не попадает в чекпоинт."""
db, counters = await _run(None, houses_ok=False)
assert counters.houses_attempted > 0, "houses-фаза вообще не исполнялась — тест не о том"
assert counters.houses_enriched == 0, "houses-фаза что-то обогатила — отказ не полный"
assert _last_checkpoint(db) == [], (
"якорь с полностью отказавшей houses-фазой помечен пройденным — "
f"чекпоинт {_last_checkpoint(db)}"
)
@pytest.mark.asyncio
async def test_next_run_re_enters_houses() -> None:
"""(б) Следующий прогон с этим чекпоинтом снова заходит в houses."""
db1, _ = await _run(None, houses_ok=False)
_db2, counters2 = await _run(
{"done_buckets": _last_checkpoint(db1)}, houses_ok=True, run_id=8402
)
assert counters2.houses_attempted > 0, (
"прогон-наследник пропустил houses-фазу: чекпоинт предшественника "
"объявил якоря пройденными, хотя фаза в них не дала ничего"
)
assert counters2.houses_enriched > 0, "houses-фаза исполнилась, но ничего не обогатила"
@pytest.mark.asyncio
async def test_healthy_anchor_is_still_checkpointed() -> None:
"""(в) Контроль: якорь, где все фазы отработали, помечается как раньше."""
db, counters = await _run(None, houses_ok=True)
assert counters.houses_enriched > 0
assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], (
"перестали помечать пройденные якоря вовсе — резюм (#3074) сломан"
)
@pytest.mark.asyncio
async def test_anchor_with_partially_failed_houses_is_still_checkpointed() -> None:
"""Контроль жадности: один отказавший дом из двух — якорь пройден.
Гейт про ПОЛНЫЙ отказ фазы. Жадный гейт (любой отказ) собирал бы такой якорь
заново каждым прогоном на проде houses почти всегда теряет пару домов.
"""
db, counters = await _run(
None, houses_ok=True, failing_urls=frozenset({HOUSE_ROWS[0]["cian_zhk_url"]})
)
assert (counters.houses_attempted, counters.houses_failed) == (4, 2)
assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], (
f"якорь с частичным отказом houses не помечен пройденным: {_last_checkpoint(db)}"
)
@pytest.mark.asyncio
async def test_single_failed_attempt_blocks_checkpoint() -> None:
"""Порог — одна попытка: единственный дом якоря отказал — якорь не пройден."""
db, counters = await _run(None, houses_ok=False, house_rows=HOUSE_ROWS[:1])
assert (counters.houses_attempted, counters.houses_enriched) == (2, 0)
assert _last_checkpoint(db) == [], (
f"якорь с 1 из 1 отказавшим домом помечен пройденным: {_last_checkpoint(db)}"
)
@pytest.mark.asyncio
async def test_houses_db_query_failure_blocks_checkpoint() -> None:
"""Упал запрос домов: попытки считаются вместе с отказами, якорь не пройден."""
db, counters = await _run(None, houses_ok=True, house_rows=[])
assert _last_checkpoint(db) == [], (
f"якорь с упавшим запросом домов помечен пройденным: {_last_checkpoint(db)}"
)
assert (counters.houses_attempted, counters.houses_failed) == (2, 2)
class _FakeAvitoScraper:
"""Двойник AvitoScraper: SERP отдаёт лоты, дальше работает detail-фаза."""
def __init__(self, *_a: Any, **_kw: Any) -> None:
self._browser = None
self._cffi = None
async def fetch_around(self, *_a: Any, **_kw: Any) -> list[types.SimpleNamespace]:
return [types.SimpleNamespace(house_url=None)]
class _FakeAsyncSession:
def __init__(self, *_a: Any, **_kw: Any) -> None: ...
async def __aenter__(self) -> _FakeAsyncSession:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
@pytest.mark.asyncio
async def test_avito_anchor_with_totally_failed_detail_is_not_checkpointed() -> None:
"""Сосед: у avito-свипа тот же гейт — фаза без единой удачи не даёт отметки.
Кода общего у свипов нет (две отдельные функции), общий только гейт. Прод-следа
у avito за 45 суток нет (0 прогонов против 4 у циана) тест сторожит вторую
копию проводки, а не найденный в данных дефект.
"""
from scraper_kit.orchestration import pipeline as pl
db = _FakeDb()
async def _boom(*_a: Any, **_kw: Any) -> Any:
raise RuntimeError("detail отдал 403")
with (
patch.object(pl, "AvitoScraper", _FakeAvitoScraper),
patch.object(pl, "AsyncSession", _FakeAsyncSession),
patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)),
patch.object(pl, "fetch_detail", _boom),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
counters = await pl.run_avito_city_sweep(
db, # type: ignore[arg-type]
run_id=8404,
config=types.SimpleNamespace(
scraper_fetch_mode="cffi",
scraper_proxy_url=None,
use_proxy_pool_browser=False,
browser_http_endpoint=None,
environment="test",
avito_serp_ok_not_banned=True,
),
matcher=MagicMock(),
enrichment=MagicMock(),
anchors=[ANCHOR_A],
enrich_houses=False,
enrich_imv=False,
detail_top_n=len(DETAIL_ROWS),
request_delay_sec=0.0,
)
assert counters.detail_attempted == len(DETAIL_ROWS)
assert counters.detail_enriched == 0
assert _last_checkpoint(db) == [], (
f"якорь с полностью отказавшей detail-фазой помечен пройденным: {_last_checkpoint(db)}"
)
@pytest.mark.asyncio
async def test_old_format_checkpoint_still_resumes() -> None:
"""(г) Чекпоинт СТАРОГО формата (плоский список имён якорей) читается как раньше."""
db, _ = await _run({"done_buckets": ["ekb-center"]}, houses_ok=True, run_id=8403)
assert f"{ANCHOR_A[0]},{ANCHOR_A[1]}" not in _FakeScraper.visited, (
"якорь из унаследованного чекпоинта всё-таки опрашивали"
)
assert _last_checkpoint(db) == ["ekb-center", "ekb-south"], (
"унаследованный якорь потерян — формат чекпоинта разъехался со старыми прогонами"
)

View file

@ -1,9 +1,10 @@
"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют. """#3452: зона n = 1..9 — коридор ДКП виден, но цену по нему не поправляют.
Два порога на одну выборку. Показ коридора открывается с трёх сделок Два порога на одну выборку. Показ коридора открывается с одной сделки (ниже
(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже city-wide widen), а обе ценовые страховки `DKP_CORRIDOR_CITY_WIDE_MIN_N` вне Екатеринбурга пробуется city-wide widen, но
уличная выборка из 1-2 сделок отдаётся и без него, #3466), а обе ценовые страховки
по коридору soft-кламп headline сверху и radius-floor снизу гейтятся по коридору soft-кламп headline сверху и radius-floor снизу гейтятся
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор `CORRIDOR_CLAMP_MIN_N` (10). Между ними лежит зона, где коридор
существует, показывается и участвует в fallback-путях, а цену не держит; на существует, показывается и участвует в fallback-путях, а цену не держит; на
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 3 и 72 8 `d.rooms`) переносит в эту зону реальных клиентов: замерено 248 3 и 72 8
@ -34,8 +35,8 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import settings from app.core.config import CORRIDOR_CLAMP_MIN_N
from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N from app.services.estimator import CORRIDOR_CLAMP_SLACK, DKP_CORRIDOR_CITY_WIDE_MIN_N
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он # Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
# включён порогом, ОБЯЗАН сработать и прижать headline к cap. # включён порогом, ОБЯЗАН сработать и прижать headline к cap.
@ -46,7 +47,7 @@ _MARKER = "corridor_advisory_zone"
def _cap_ppm2() -> float: def _cap_ppm2() -> float:
"""Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp.""" """Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp."""
return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack) return _CORRIDOR_HIGH_PPM2 * (1.0 + CORRIDOR_CLAMP_SLACK)
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]: def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
@ -157,7 +158,7 @@ def test_zone_exists_at_all() -> None:
Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо, Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо,
а не оставит два теста ниже молча проверять пустое множество. а не оставит два теста ниже молча проверять пустое множество.
""" """
assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, ( assert CORRIDOR_CLAMP_MIN_N > DKP_CORRIDOR_CITY_WIDE_MIN_N, (
"зона 3..9 схлопнулась — пороги показа и клампа сравнялись, " "зона 3..9 схлопнулась — пороги показа и клампа сравнялись, "
"advisory-only решение #3452 больше не описывает реальность" "advisory-only решение #3452 больше не описывает реальность"
) )
@ -167,15 +168,14 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан.""" """n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан."""
n = settings.estimate_corridor_clamp_min_n - 1 n = CORRIDOR_CLAMP_MIN_N - 1
with caplog.at_level(logging.INFO, logger="app.services.estimator"): with caplog.at_level(logging.INFO, logger="app.services.estimator"):
est = _run_estimate(_corridor(n)) est = _run_estimate(_corridor(n))
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже" assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
assert est.dkp_corridor.count == n assert est.dkp_corridor.count == n
assert est.dkp_corridor.advisory_only is True, ( assert est.dkp_corridor.advisory_only is True, (
f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, " f"n={n} < порога {CORRIDOR_CLAMP_MIN_N} — коридор справочный, ответ обязан это называть"
"ответ обязан это называть"
) )
# ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал. # ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал.
assert est.median_price_per_m2 > _cap_ppm2(), ( assert est.median_price_per_m2 > _cap_ppm2(), (
@ -185,7 +185,7 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
hits = [r for r in caplog.records if _MARKER in r.getMessage()] hits = [r for r in caplog.records if _MARKER in r.getMessage()]
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}" assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
msg = hits[0].getMessage() msg = hits[0].getMessage()
assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg assert f"n={n}" in msg and f"min_n={CORRIDOR_CLAMP_MIN_N}" in msg, msg
assert "scope=street" in msg, msg assert "scope=street" in msg, msg
@ -193,13 +193,13 @@ def test_corridor_above_threshold_is_not_flagged_and_clamps(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""n выше порога: метки нет, headline прижат к потолку коридора, лога нет.""" """n выше порога: метки нет, headline прижат к потолку коридора, лога нет."""
n = settings.estimate_corridor_clamp_min_n + 2 n = CORRIDOR_CLAMP_MIN_N + 2
with caplog.at_level(logging.INFO, logger="app.services.estimator"): with caplog.at_level(logging.INFO, logger="app.services.estimator"):
est = _run_estimate(_corridor(n)) est = _run_estimate(_corridor(n))
assert est.dkp_corridor is not None assert est.dkp_corridor is not None
assert est.dkp_corridor.advisory_only is False, ( assert est.dkp_corridor.advisory_only is False, (
f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, " f"n={n} >= порога {CORRIDOR_CLAMP_MIN_N} — коридор в цене участвует, "
"справочным его называть нельзя" "справочным его называть нельзя"
) )
assert est.median_price_per_m2 <= round(_cap_ppm2()), ( assert est.median_price_per_m2 <= round(_cap_ppm2()), (

View file

@ -0,0 +1,175 @@
"""#3466: при якоре Tier A коридор ДКП в цену не входит — ответ обязан это нести.
`DkpCorridor.advisory_only` (#3452) считается из размера выборки, а кламп headline
выключается ещё и якорем того же дома (`_apply_corridor_clamp`: Tier A exempt;
radius-floor требует `anchor_tier is None`). Ревьюер PR #3462 воспроизвёл: n=20,
advisory_only=False, headline 202 100 /м² против потолка 140 000 поле молчит, а
коридор цену не тронул.
Признак `analog_tier == "same_building"`, уже структурный в POST-ответе. На
GET-rehydrate колонки под него нет, тир восстанавливается из подписи якорного
блока (`analog_tier_from_explanation`). Тесты ПО ЗНАЧЕНИЮ:
* цена Tier A не зависит от коридора вовсе снятие exempt в клампе роняет тест;
* тот же коридор на радиусном пути цену прижимает иначе первый тест пуст;
* подпись, которую пишет якорный блок, читается обратно в тот же тир, а соседняя
подпись радиусного тира S («аналоги из того же дома») нет;
* полоса счёта маркера `corridor_advisory_zone` n = 1..9, а не 3..9: уличный
коридор отдаётся с одной сделки (widen только вне Екатеринбурга).
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services.geocoder import GeocodeResult
# Коридор выше порога доверия, потолок заведомо ниже цены якоря: кламп, если ему
# позволить, ОБЯЗАН прижать headline к cap = 100 000 × (1 + slack).
_CORRIDOR: dict[str, Any] = {
"count": 20,
"low_ppm2": 80_000,
"median_ppm2": 90_000,
"high_ppm2": 100_000,
"period_months": 12,
"latest_deal_date": None,
"scope": "street",
}
_ANCHOR_PPM2 = (190_000, 195_000, 200_000, 205_000, 210_000)
def _cap() -> float:
return _CORRIDOR["high_ppm2"] * (1.0 + estimator.CORRIDOR_CLAMP_SLACK)
def _call(
*,
listings_ppm2: tuple[float, ...],
anchor_tier_fetched: str | None,
dkp_raw: dict[str, Any] | None,
analog_tier: str = "W",
) -> estimator.PricingResult:
return estimator._price_from_inputs(
listings=[
{"price_per_m2": p, "address": f"ул. Тестовая, {i}", "source": "avito"}
for i, p in enumerate(listings_ppm2)
],
area_m2=50.0,
rooms=2,
repair_state=None,
floor=5,
total_floors=10,
target_year=None,
analog_tier=analog_tier,
fallback_used=False,
area_widened=False,
anchor_comps=(
[{"price_per_m2": p, "area_m2": 50.0, "rooms": 2} for p in _ANCHOR_PPM2]
if anchor_tier_fetched
else []
),
anchor_tier_fetched=anchor_tier_fetched,
dkp_raw=dkp_raw,
imv_anchor=None,
imv_eval=None,
yandex_val_present=False,
cian_val_present=False,
ratio_resolver=lambda _appm2: (None, None),
quarter_index_lookup=lambda _q: None,
quarter_indexes_lookup=lambda _qs: {},
target_house_cadnum=None,
dadata_coarse=False,
geo=GeocodeResult(
lat=56.838, lon=60.597, full_address="ул. Тестовая, 1", provider="nominatim"
),
dadata_qc_geo=None,
)
def _price(pr: estimator.PricingResult) -> tuple[float, int, int, int]:
return (pr.median_ppm2, pr.median_price, pr.range_low, pr.range_high)
def test_tier_a_price_does_not_depend_on_corridor() -> None:
"""Ядро признака: при same_building коридор не двигает НИ ОДНО ценовое число."""
listings = (100_000.0,) * 6
with_corridor = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=_CORRIDOR)
without = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=None)
assert with_corridor.anchor_tier == "A", "якорь не сработал — тест проверял бы радиусный путь"
# Предпосылка — на цене БЕЗ коридора: она выше потолка, т.е. клампу было что резать.
assert without.median_ppm2 > _cap(), (
f"цена якоря {without.median_ppm2:.0f} не выше cap {_cap():.0f}"
"кламп не понадобился бы и без exempt, случай #3466 не воспроизведён"
)
assert _price(with_corridor) == _price(without), (
"коридор из 20 сделок изменил цену Tier A — значит он в цену вошёл, и признак "
f"same_building на витрине врёт: {_price(with_corridor)} против {_price(without)}"
)
def test_same_corridor_clamps_radius_path() -> None:
"""Контроль: тот же коридор без якоря прижимает цену — первый тест не пустой."""
pr = _call(
listings_ppm2=(195_000.0, 200_000.0, 200_000.0, 205_000.0, 210_000.0, 190_000.0),
anchor_tier_fetched=None,
dkp_raw=_CORRIDOR,
)
assert pr.anchor_tier is None
assert round(pr.median_ppm2) == round(_cap()), (
f"без якоря коридор n=20 обязан прижать headline к cap={_cap():.0f}, "
f"получено {pr.median_ppm2:.0f}"
)
def test_anchor_sentence_reads_back_to_tier() -> None:
"""Подпись якорного блока → тот же enum, что POST кладёт в analog_tier."""
tier_a = _call(listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched="A", dkp_raw=None)
tier_c = _call(listings_ppm2=(200_000.0,) * 6, anchor_tier_fetched="C", dkp_raw=None)
assert (tier_a.anchor_tier, tier_c.anchor_tier) == ("A", "C")
assert estimator.analog_tier_from_explanation(tier_a.explanation) == "same_building"
assert estimator.analog_tier_from_explanation(tier_c.explanation) == "micro_radius"
# #conf-cap ставит свою фразу ПЕРЕД подписью — тир обязан читаться и тогда.
capped = "Оценка построена с расширенными параметрами подбора — точность снижена. "
assert estimator.analog_tier_from_explanation(capped + tier_a.explanation) == "same_building"
def test_radius_same_house_note_is_not_anchor() -> None:
"""Радиусный тир S пишет «(аналоги из того же дома)», но коридор там клампит."""
pr = _call(
listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched=None, dkp_raw=None, analog_tier="S"
)
assert pr.anchor_tier is None
assert "из того же дома" in pr.explanation, pr.explanation
assert estimator.analog_tier_from_explanation(pr.explanation) is None
assert estimator.analog_tier_from_explanation(None) is None
def test_street_corridor_is_returned_below_three_deals() -> None:
"""Полоса маркера #3452 — n = 1..9: улица Екатеринбурга не расширяется до города."""
rows = [
{"price_per_m2": 150_000, "deal_date": None},
{"price_per_m2": 160_000, "deal_date": None},
]
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
with patch.object(estimator, "_load_sber_index_series", return_value={}):
raw = estimator._fetch_dkp_corridor(
db,
address="Екатеринбург, ул. Учителей, 18",
rooms=2,
area=50.0,
city="екатеринбург",
)
assert raw is not None and raw["count"] == 2, raw
assert raw["scope"] == "street"
assert raw["count"] < estimator.DKP_CORRIDOR_CITY_WIDE_MIN_N

View file

@ -0,0 +1,90 @@
"""Проверка отмены не оставляет транзакцию открытой на сетевую фазу свипа (#3480).
Прод 17.09 07:48 UTC, pg_stat_activity БД tradein: pid 1933154 из tradein-scraper,
'idle in transaction' 1 ч 35 мин, последний запрос `SELECT status FROM scrape_runs
WHERE id = $1` (runs.is_cancelled), xact_start через 30 мс после старта
domclick_city_sweep_moskva 7344. За 14 суток 10 из 12 окон «транзакция > 1 ч»
совпали со свипами ДомКлика.
Сессия настоящая (SQLAlchemy + SQLite): значение `in_transaction()` то же, что видит
Postgres как 'idle in transaction'.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import pipeline as pl
from scraper_kit.orchestration import runs
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
def _session() -> Session:
db = Session(create_engine("sqlite://"))
db.execute(text("CREATE TABLE scrape_runs (id INTEGER, status TEXT, counters TEXT)"))
db.execute(
text(
"INSERT INTO scrape_runs VALUES "
"(7333, 'done', NULL), (7344, 'running', NULL), (7345, 'cancelled', NULL)"
)
)
db.commit()
return db
def test_is_cancelled_closes_its_transaction() -> None:
db = _session()
assert runs.is_cancelled(db, 7344) is False
assert db.in_transaction() is False
assert runs.is_cancelled(db, 7345) is True
assert db.in_transaction() is False
async def test_domclick_sweep_http_phase_runs_outside_transaction() -> None:
db = _session()
seen: list[bool] = []
class _Scraper:
blocked = False
geo_filtered = fetch_errors = buckets_completed = buckets_total = 0
bucket_start_index = 0
completed_buckets: list[str] = [] # noqa: RUF012
def __init__(self, *_a: Any, **_kw: Any) -> None: ...
async def __aenter__(self) -> _Scraper:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
async def fetch_city(self, **_kw: Any) -> list[Any]:
seen.append(db.in_transaction()) # момент сетевого обхода
return []
with (
patch.object(pl, "DomClickScraper", _Scraper),
patch.object(runs, "update_heartbeat", MagicMock()),
patch.object(runs, "mark_done", MagicMock()),
patch.object(runs, "mark_failed", MagicMock()),
patch.object(runs, "mark_banned", MagicMock()),
):
await pl.run_domclick_city_sweep(
db,
run_id=7344,
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
city_id=4,
pages=1,
request_delay_sec=0.0,
resume_run_id=7333, # резюм-SELECT тоже открывает транзакцию
)
assert seen == [False]

View file

@ -297,3 +297,17 @@ def test_load_fixture_plain_and_gzip_roundtrip() -> None:
m_plain = bt.replay_fixture(loaded_plain) m_plain = bt.replay_fixture(loaded_plain)
m_gz = bt.replay_fixture(loaded_gz) m_gz = bt.replay_fixture(loaded_gz)
assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True) assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True)
def test_write_fixture_records_engine_constants(tmp_path: Path) -> None:
"""#1970: пороги движка — константы estimator.py, снимок фикстуры обязан их нести."""
est = bt._import_estimator_full()
out = tmp_path / "fixture.json"
bt._write_fixture(str(out), capture=[], since="2026-09-01", est=est)
snap = json.loads(out.read_text(encoding="utf-8"))["settings_at_capture"]
assert {
k: snap.get(k)
for k in ("CORRIDOR_CLAMP_MIN_N", "CORRIDOR_CLAMP_SLACK", "QUARTER_INDEX_MIN_N_DEALS")
} == {"CORRIDOR_CLAMP_MIN_N": 10, "CORRIDOR_CLAMP_SLACK": 0.4, "QUARTER_INDEX_MIN_N_DEALS": 10}
assert snap.get("SB_ROOMS_MATCH_BOOST") == 1.6
assert "estimate_pi_low_mult" in snap # поля Settings по-прежнему в снимке

View file

@ -25,11 +25,8 @@ import math
import os import os
from pathlib import Path from pathlib import Path
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from scripts.backtest_estimator import load_fixture, replay_fixture from scripts.backtest_estimator import load_fixture, replay_fixture
_FIXTURES = Path(__file__).parent / "fixtures" _FIXTURES = Path(__file__).parent / "fixtures"
@ -76,16 +73,7 @@ def test_fixture_and_baseline_committed() -> None:
assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}" assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}"
def test_backtest_regression_gate(monkeypatch: pytest.MonkeyPatch) -> None: def test_backtest_regression_gate() -> None:
# The frozen fixture records the injected-callback control flow captured with
# cross-source physical dedup OFF (#2087 H4 was a no-op default at capture time).
# Dedup is now default ON (#2173), but this gate is a byte-identical REPLAY of a
# frozen OFF capture — with dedup active _dedup_cross_source would trim listings
# before quarter_indexes_lookup and the recorded call sequence would diverge. Pin
# the flag OFF so the replay follows the captured control flow. (Accuracy-neutral:
# backtest #1966 OFF vs ON is identical; dedup only trims user-visible n_analogs,
# so the OFF baseline stays the valid spine regression reference.)
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
fixture = load_fixture(_FIXTURE_PATH) fixture = load_fixture(_FIXTURE_PATH)
baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8")) baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8"))
# Round-trip the replay output through JSON before comparing: the committed # Round-trip the replay output through JSON before comparing: the committed

View file

@ -28,6 +28,8 @@ import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402 from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402 from fastapi.testclient import TestClient # noqa: E402
from app.services.estimator import analog_tier_from_explanation # noqa: E402
_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111" _ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
@ -170,6 +172,9 @@ def _stub_precision_and_pdf():
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
# поведение (см. tests/test_estimator_search_radius_2632.py). # поведение (см. tests/test_estimator_search_radius_2632.py).
rehydrate_search_radius_m=lambda *a, **k: None, rehydrate_search_radius_m=lambda *a, **k: None,
# #3466: якорный тир из подписи в confidence_explanation. У фикстуры там
# «ok» → None и есть настоящее поведение; положительный случай — ниже.
analog_tier_from_explanation=lambda _e: None,
) )
real_estimator = sys.modules.get("app.services.estimator") real_estimator = sys.modules.get("app.services.estimator")
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
@ -273,6 +278,26 @@ def test_get_estimate_rehydrates_post_only_fields(trade_in_app: FastAPI) -> None
assert body["last_scraped_at"] is not None assert body["last_scraped_at"] is not None
def test_get_estimate_restores_anchor_tier(trade_in_app: FastAPI) -> None:
"""#3466: ссылка ?id= на оценку Tier A отдаёт analog_tier=same_building.
Без него витрина не отличает коридор, который цену не трогал (якорь того же
дома), от работающего. Разбор настоящий, из estimator, по подписи, которую
пишет якорный блок.
"""
est = sys.modules["app.services.estimator"]
est.analog_tier_from_explanation = analog_tier_from_explanation # type: ignore[attr-defined]
row = _make_estimate_row(created_by="kopylov")
row.confidence_explanation = "Оценка построена по 20 аналогам из того же дома."
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["analog_tier"] == "same_building"
def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None: def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None:
"""No X-Authenticated-User header → 401.""" """No X-Authenticated-User header → 401."""
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov")) db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))

View file

@ -277,6 +277,8 @@ def _estimator_stub():
# этой фикстуры analogs пусты → None и есть настоящее поведение # этой фикстуры analogs пусты → None и есть настоящее поведение
# (см. tests/test_estimator_search_radius_2632.py). # (см. tests/test_estimator_search_radius_2632.py).
rehydrate_search_radius_m=lambda *a, **k: None, rehydrate_search_radius_m=lambda *a, **k: None,
# #3466: подписи якоря у строк фикстуры нет → None (см. test_estimate_idor.py).
analog_tier_from_explanation=lambda _e: None,
estimate_quality=_default_estimate_quality, estimate_quality=_default_estimate_quality,
) )
sys.modules["app.services.estimator"] = stub # type: ignore[assignment] sys.modules["app.services.estimator"] = stub # type: ignore[assignment]

View file

@ -172,7 +172,6 @@ def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару — # sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
# иначе тест проверял бы не то, что заявлено в docstring. # иначе тест проверял бы не то, что заявлено в docstring.
assert estimator.settings.estimate_dedup_analogs_enabled is True
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b]) deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит" assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"

View file

@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp(
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту # 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают # (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. # SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
_OTHER_COMPS = [ _OTHER_COMPS = [
_comp( _comp(
source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5 source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5
@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke
После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_ После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_
anchor est.analogs содержит физлот ровно ОДИН раз (n=1). anchor est.analogs содержит физлот ровно ОДИН раз (n=1).
""" """
from app.core.config import settings from app.services import estimator
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr(settings, "estimate_sb_min_comps", 1) m.setattr(estimator, "SB_MIN_COMPS", 1)
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя
@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat
(постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2 (постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2
при одном реальном физлоте (дубль посчитан дважды) самосогласованно, но при одном реальном физлоте (дубль посчитан дважды) самосогласованно, но
нечестно. После фикса n_analogs=1, синхронно с analogs.""" нечестно. После фикса n_analogs=1, синхронно с analogs."""
from app.core.config import settings from app.services import estimator
with monkeypatch.context() as m: with monkeypatch.context() as m:
m.setattr(settings, "estimate_sb_min_comps", 1) m.setattr(estimator, "SB_MIN_COMPS", 1)
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
assert est.n_analogs == len(est.analogs) == 1 assert est.n_analogs == len(est.analogs) == 1

View file

@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None:
) )
if anchor is None: if anchor is None:
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим") pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
from app.core.config import settings from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N
# Проверяем условие gate_thin напрямую # Проверяем условие gate_thin напрямую
gate_thin = ( gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
# n=2 < 3 — должен сработать если FSD тоже высокий # n=2 < 3 — должен сработать если FSD тоже высокий
assert anchor["n"] == 2 assert anchor["n"] == 2
# FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3; # FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3;
# tier_penalty=0.05 (C); CV = std/mean для 2 элементов # tier_penalty=0.05 (C); CV = std/mean для 2 элементов
# Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20 # Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20
if anchor["fsd"] > settings.estimate_sb_gate_max_fsd: if anchor["fsd"] > SB_GATE_MAX_FSD:
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD" assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)] comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
with patch("app.services.estimator.settings") as mock_settings: with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_sb_mad_k_small_n = 2.5
mock_settings.estimate_sb_small_n_threshold = 10
mock_settings.avito_imv_thin_market_threshold = 10 mock_settings.avito_imv_thin_market_threshold = 10
mock_settings.sber_index_max_age_days = 35 mock_settings.sber_index_max_age_days = 35

View file

@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = {
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]: def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
"""N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for """N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for
Tier A to actually FIRE see _fetch_anchor_comps `len(comps) >= min_comps`). Tier A to actually FIRE see _fetch_anchor_comps `len(comps) >= min_comps`).
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265, Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
street+floor+area+price physical key) treats them as distinct units, not street+floor+area+price physical key) treats them as distinct units, not
@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
"period_months": 12, "period_months": 12,
} }
db = MagicMock() db = MagicMock()
# >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active # >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active
# listings are EKB) and necessary for Tier A to actually fire pre-fix. # listings are EKB) and necessary for Tier A to actually fire pre-fix.
db.execute.side_effect = _fake_anchor_sql_execute( db.execute.side_effect = _fake_anchor_sql_execute(
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]), _ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),

View file

@ -3,7 +3,7 @@
Проверяет: Проверяет:
- композиты физического ключа (_lot_dedup_components) что образуют / не образуют; - композиты физического ключа (_lot_dedup_components) что образуют / не образуют;
- _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя - _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя
(свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично; (свежайший scraped_at), не-дубли не трогаются;
- выбор представителя детерминирован (freshest scraped_at). - выбор представителя детерминирован (freshest scraped_at).
No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только
@ -111,45 +111,28 @@ def test_phys_key_area_bucket_tolerates_rounding() -> None:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Флаг: default ON (#2173) vs явный OFF (byte-identical) # Дедуп без выключателя (#2378)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_dedup_default_is_on() -> None: def test_dedup_default_is_on() -> None:
# #2173: default флага теперь ON (в проде дедуп активен без ENV-override). # #2378: выключателя больше нет — дедуп работает всегда и схлопывает
# Без monkeypatch settings отражает config-default → дедуп схлопывает # кросс-пост-тройку одного физлота.
# кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно
# поведение по умолчанию.
assert estimator.settings.estimate_dedup_analogs_enabled is True
lots = [ lots = [
_lot(source="avito"), _lot(source="avito"),
_lot(source="cian"), _lot(source="cian"),
_lot(source="domklik"), _lot(source="domklik"),
] ]
out = estimator._dedup_cross_source(lots) out = estimator._dedup_cross_source(lots)
assert len(out) == 1 # дедуп активен по умолчанию assert len(out) == 1
def test_dedup_flag_off_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
# OFF-путь проверяем ЯВНЫМ override (не полагаясь на default, который теперь ON):
# при флаге OFF проход байт-идентичен — возвращается тот же объект.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
lots = [
_lot(source="avito"),
_lot(source="cian"),
_lot(source="domklik"),
]
out = estimator._dedup_cross_source(lots)
assert out is lots # тот же объект → нулевое влияние на выборку
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# _dedup_cross_source — флаг ON # _dedup_cross_source
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_cross_source_triple() -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
# Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik. # Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik.
lots = [ lots = [
_lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)), _lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
@ -162,8 +145,7 @@ def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) ->
assert out[0]["source"] == "cian" assert out[0]["source"] == "cian"
def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_keeps_distinct_units() -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
# Разные физлоты: разный этаж / площадь / цена / здание → не дубли. # Разные физлоты: разный этаж / площадь / цена / здание → не дубли.
lots = [ lots = [
_lot(source="avito", floor=4, area=67.0, price=21_995_000), _lot(source="avito", floor=4, area=67.0, price=21_995_000),
@ -176,8 +158,7 @@ def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
assert len(out) == 5 # ничего не схлопнулось assert len(out) == 5 # ничего не схлопнулось
def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_preserves_first_seen_order() -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
# relevance-порядок из _fetch_analogs должен сохраниться: группа занимает # relevance-порядок из _fetch_analogs должен сохраниться: группа занимает
# позицию первого появления. # позицию первого появления.
lots = [ lots = [
@ -191,8 +172,7 @@ def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> No
assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"] assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"]
def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_lots_without_key_stay_unique() -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
# Нет площади → ключ None → не дедупим, даже если оба «пустые». # Нет площади → ключ None → не дедупим, даже если оба «пустые».
lots = [ lots = [
_lot(source="avito", area=0), _lot(source="avito", area=0),
@ -225,10 +205,9 @@ def test_extract_street_token_strips_house_and_noise() -> None:
assert _street_token(None) == "" assert _street_token(None) == ""
def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_three_source_formats() -> None:
# Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан # Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан
# как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах. # как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="cian", source="cian",
@ -259,10 +238,9 @@ def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -
assert len(out) == 1 # один физлот, не три assert len(out) == 1 # один физлот, не три
def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_merges_cadnum_asymmetry_same_street() -> None:
# У одного cadnum есть, у другого нет, адрес совпадает → сливаются по # У одного cadnum есть, у другого нет, адрес совпадает → сливаются по
# street-композиту (#2265: union по любому совпавшему ключу). # street-композиту (#2265: union по любому совпавшему ключу).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="cian", source="cian",
@ -285,12 +263,9 @@ def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPat
assert len(out) == 1 assert len(out) == 1
def test_dedup_distinct_cadnums_same_metrics_do_not_merge( def test_dedup_distinct_cadnums_same_metrics_do_not_merge() -> None:
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ # Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
# кадастрами — авторитетный сигнал разных зданий → НЕ сливаем. # кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="cian", source="cian",
@ -313,12 +288,9 @@ def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
assert len(out) == 2 assert len(out) == 2
def test_dedup_same_street_different_metrics_stay_distinct( def test_dedup_same_street_different_metrics_stay_distinct() -> None:
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Разные лоты на одной улице с разной ценой/площадью/этажом → остаются # Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
# разными (защита от over-merge при отсутствии cadnum). # разными (защита от over-merge при отсутствии cadnum).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3), _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
_lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8), _lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8),
@ -350,9 +322,8 @@ def test_extract_street_token_numbered_not_degraded() -> None:
assert _street_token("ул. Белинского 86") == "белинского" assert _street_token("ул. Белинского 86") == "белинского"
def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_numbered_street_three_formats() -> None:
# «8 Марта» в трёх source-форматах, cadnum пуст → один физлот. # «8 Марта» в трёх source-форматах, cadnum пуст → один физлот.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="cian", source="cian",
@ -383,10 +354,9 @@ def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.Monke
assert len(out) == 1 assert len(out) == 1
def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_house_no_guard_blocks_street_union() -> None:
# Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера # Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера
# дома → house-guard не даёт слить (both-present-different). # дома → house-guard не даёт слить (both-present-different).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3), _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
_lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3), _lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3),
@ -395,10 +365,9 @@ def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatc
assert len(out) == 2 assert len(out) == 2
def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_house_no_guard_order_stable() -> None:
# Устойчивость к порядку: домклик без номера сливается с одним из номеров, # Устойчивость к порядку: домклик без номера сливается с одним из номеров,
# два разных номера остаются раздельными независимо от позиции «пустого». # два разных номера остаются раздельными независимо от позиции «пустого».
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
base = dict(area=33.6, price=4_000_000, floor=3, cad=None) base = dict(area=33.6, price=4_000_000, floor=3, cad=None)
lots = [ lots = [
_lot(source="domklik", address="улица Сыромолотова", **base), # house "" _lot(source="domklik", address="улица Сыромолотова", **base), # house ""
@ -410,10 +379,9 @@ def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> N
assert len(out) == 2 assert len(out) == 2
def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_empty_cadnum_and_street_stay_separate() -> None:
# Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при # Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
# идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален). # идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None), _lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
_lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None), _lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
@ -485,7 +453,7 @@ def test_extract_street_token_extended_same_token_across_formats() -> None:
assert domklik == avito == cian == "рассветная" assert domklik == avito == cian == "рассветная"
def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_domklik_cian_name_order() -> None:
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1» # Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М), # + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен # cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
@ -493,7 +461,6 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
# адреса используют одну и ту же глухую («к1») family корпуса, и после # адреса используют одну и ту же глухую («к1») family корпуса, и после
# #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера # #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
# независимо от пробела перед «к» (см. _normalize_house_no). # независимо от пробела перед «к» (см. _normalize_house_no).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="domklik", source="domklik",
@ -519,7 +486,7 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
assert out[0]["source"] == "cian" # представитель — свежайший scraped_at assert out[0]["source"] == "cian" # представитель — свежайший scraped_at
def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_domklik_cian_area_straddle() -> None:
# #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в # #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному), # СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
# 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не # 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
@ -527,7 +494,6 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
# должен склеить их в один физлот. Оба адреса — глухая («к1») family # должен склеить их в один физлот. Оба адреса — глухая («к1») family
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см. # корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
# _normalize_house_no), house-guard не блокирует merge независимо от пробела. # _normalize_house_no), house-guard не блокирует merge независимо от пробела.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="domklik", source="domklik",
@ -552,11 +518,10 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0 assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_avito_glued_suffix() -> None:
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная, # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
# 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1» # 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
# (см. #2436 _normalize_house_no) → guard видит равные номера → склейка. # (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13), _lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
_lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13), _lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13),
@ -565,11 +530,10 @@ def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) ->
assert len(out) == 1 assert len(out) == 1
def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_mkr_fallback_does_not_cross_with_street() -> None:
# Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного: # Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного:
# «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не # «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
# сливаются даже при совпадении метрик. # сливаются даже при совпадении метрик.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6), _lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6),
_lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6), _lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6),
@ -622,11 +586,10 @@ def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str)
assert estimator._parse_street_house(addr) == ("", "") assert estimator._parse_street_house(addr) == ("", "")
def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_domclick_bare_street_mkr_misfire() -> None:
# Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше # Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше
# ключевался по району и никогда не сливался с avito/cian, где «готвальда» # ключевался по району и никогда не сливался с avito/cian, где «готвальда»
# распознаётся явной веткой (тип-слово «улица»). # распознаётся явной веткой (тип-слово «улица»).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="domclick", source="domclick",
@ -660,10 +623,9 @@ def test_parse_street_house_yo_ye_fold() -> None:
) )
def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None: def test_dedup_collapses_yo_ye_variant() -> None:
# Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах — # Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах —
# «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один. # «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4), _lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4),
_lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4), _lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4),
@ -753,13 +715,10 @@ def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500") assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")
def test_dedup_collapses_avito_glued_metro_suffix_house_match( def test_dedup_collapses_avito_glued_metro_suffix_house_match() -> None:
monkeypatch: pytest.MonkeyPatch,
) -> None:
# e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся, # e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
# извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard # извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
# больше не блокирует street-union (оба извлекают «2») → один физлот. # больше не блокирует street-union (оба извлекают «2») → один физлот.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="avito", source="avito",
@ -782,14 +741,11 @@ def test_dedup_collapses_avito_glued_metro_suffix_house_match(
assert len(out) == 1 assert len(out) == 1
def test_dedup_glued_metro_noise_does_not_cause_over_merge( def test_dedup_glued_metro_noise_does_not_cause_over_merge() -> None:
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Анти-регрессия: фикс не должен сделать guard_house снисходительнее для # Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
# адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома # адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
# («24» vs «26»), каждый одет в приклеенное название станции разной длины, # («24» vs «26»), каждый одет в приклеенное название станции разной длины,
# ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different). # ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="avito", source="avito",
@ -863,15 +819,12 @@ def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None:
assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4") assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4")
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus( def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus() -> None:
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Точный репортированный баг: avito-подобный адрес («...65к4...», глухая # Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
# нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же # нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
# физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ # физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
# схлопнуться в один. union() зависит от порядка (кто станет root) — # схлопнуться в один. union() зависит от порядка (кто станет root) —
# проверяем ОБА порядка следования лотов. # проверяем ОБА порядка следования лотов.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def _pair(first_source: str, second_source: str) -> list[dict]: def _pair(first_source: str, second_source: str) -> list[dict]:
by_source = { by_source = {
@ -903,14 +856,11 @@ def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
assert len(out_domklik_first) == 1 assert len(out_domklik_first) == 1
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus( def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus() -> None:
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за # Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
# пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и # пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
# «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не # «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
# должно. # должно.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="avito", source="avito",
@ -933,13 +883,10 @@ def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
assert len(out) == 2 assert len(out) == 2
def test_dedup_house_no_guard_blocks_different_corpus_numbers( def test_dedup_house_no_guard_blocks_different_corpus_numbers() -> None:
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или # Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
# «65к4» vs «65к5») — genuinely разные здания, guard_house обязан # «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
# по-прежнему их разделять после нормализации. # по-прежнему их разделять после нормализации.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
source="domklik", source="domklik",
@ -977,3 +924,128 @@ def test_parse_street_house_dangling_corpus_letter_no_digit_is_liter() -> None:
# сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ # сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ
# схлопывается ни во что и не отбрасывается. # схлопывается ни во что и не отбрасывается.
assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к") assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к")
# --------------------------------------------------------------------------- #
# #2291 класс 4 — нумерованные улицы с дефисом («40-летия Комсомола»)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("addr", "expected"),
[
# Живые форматы источников с прода (tradein-postgres, 2026-09-17).
("ул. 40-летия Комсомола,32Г", ("40-летия комсомола", "32г")), # avito
(
"Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г",
("40-летия комсомола", "32г"),
), # yandex
("р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")),
("Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г", ("40-летия комсомола", "32г")),
("Москва, 60-летия Октября проспект, 18 к3", ("60-летия октября", "18/3")),
("улица 22-го Партсъезда, 10", ("22-го партсъезда", "10")),
# Регрессии: пробельная нумерованная, мкр-шум при уличном типе, bare-street, mkr.
("улица 8 Марта, 12", ("8 марта", "12")),
("мкр. ЖБИ, улица Сыромолотова, 5", ("сыромолотова", "5")),
("Заречный м-н, Готвальда, 24", ("готвальда", "24")),
("мкр Пионерский, 5", ("mkr пионерский", "5")),
],
)
def test_parse_street_house_hyphen_numbered_street(addr: str, expected: tuple[str, str]) -> None:
assert estimator._parse_street_house(addr) == expected
def test_dedup_collapses_hyphen_numbered_street_four_sources() -> None:
# Один физлот в четырёх форматах: раньше avito/yandex давали ('', ''), cian —
# ('mkr жби', '40'), и слиться не мог никто. Флаг дедупа не трогаем: на main он
# ON по умолчанию (test_dedup_default_is_on), а #3556 удаляет его из Settings —
# setattr по несуществующему полю уронил бы тест при любом порядке мержа.
addrs = [
("avito", "ул. 40-летия Комсомола,32Г"),
("yandex", "Россия, Свердловская область, Екатеринбург, улица 40-летия Комсомола, 32Г"),
("cian", "р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, 32Г"),
("domklik", "Екатеринбург, ЖБИ м-н, 40-летия Комсомола, 32Г"),
]
lots = [
_lot(source=s, address=a, area=44.0, price=5_900_000, floor=7, cad=None) for s, a in addrs
]
assert len(estimator._dedup_cross_source(lots)) == 1
def test_dedup_hyphen_numbered_street_different_houses_stay_distinct() -> None:
# Раньше cian ключевал все дома улицы как ('mkr жби', '40'): дома 8А и 35 с
# одинаковыми этажом/площадью/ценой сливались в один лот.
lots = [
_lot(
source="cian",
address=f"р-н Кировский, мкр. ЖБИ, улица 40-летия Комсомола, {h}",
area=44.0,
price=5_900_000,
floor=7,
cad=None,
)
for h in ("8А", "35")
]
assert len(estimator._dedup_cross_source(lots)) == 2
# --------------------------------------------------------------------------- #
# #2291 класс 4 — однобуквенные порядковые («2-я Новосибирская», «3-й Конный»)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("addr", "expected"),
[
# Три формата одной улицы (живые адреса с прода, tradein-postgres, 17.09):
# cian «тип номер имя» раньше давал ('', ''), avito/yandex «номер тип имя» и
# domklik «номер имя тип» теряли номер ('синичкина').
("2-я ул. Синичкина,22", ("2-я синичкина", "22")), # avito
("Москва, 2-я Синичкина улица, 22", ("2-я синичкина", "22")), # domklik
("Россия, Москва, 2-я улица Синичкина, 22", ("2-я синичкина", "22")), # yandex
("улица 2-я Новосибирская, 5", ("2-я новосибирская", "5")),
("пер. 3-й Конный, 7", ("3-й конный", "7")),
("ул. 1-й Пятилетки, 3", ("1-й пятилетки", "3")),
("12-й пр. Марьиной Рощи,8с2", ("12-й марьиной рощи", "8")),
# Раньше мкр-ветка: ('mkr перловка', '1') — номер улицы вместо дома.
("мкр. Перловка, улица 1-я Крестьянская, 33к2", ("1-я крестьянская", "33/2")),
# Порядковый перед нумерованным именем: без него было бы ('', '').
("2-й пер. 1 Мая,13", ("2-й 1 мая", "13")),
(
"Россия, Московская область, Павловский Посад, 2-й переулок 1 Мая, 11",
("2-й 1 мая", "11"),
),
# Регрессии: номер дома с дефисом остаётся номером дома.
("ул. Ленина, 12-а", ("ленина", "12")),
("ул. Щорса, 7-б", ("щорса", "7")),
],
)
def test_parse_street_house_single_letter_ordinal(addr: str, expected: tuple[str, str]) -> None:
assert estimator._parse_street_house(addr) == expected
def test_dedup_collapses_single_letter_ordinal_three_formats() -> None:
addrs = [
("avito", "2-я ул. Синичкина,22"),
("domklik", "Москва, 2-я Синичкина улица, 22"),
("yandex", "Россия, Москва, 2-я улица Синичкина, 22"),
("cian", "ЮВАО, р-н Лефортово, улица 2-я Синичкина, 22"),
]
lots = [
_lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None) for s, a in addrs
]
assert len(estimator._dedup_cross_source(lots)) == 1
def test_dedup_different_ordinal_streets_stay_distinct() -> None:
# Раньше domklik терял номер: «9-я Парковая» и «12-я Парковая» обе давали
# ('парковая', '5'), и квартиры с одинаковыми этажом/площадью/ценой в двух
# разных домах Измайлова сливались в одну.
lots = [
_lot(source=s, address=a, area=44.0, price=9_900_000, floor=3, cad=None)
for s, a in (
("domklik", "Москва, 9-я Парковая улица, 5"),
("domklik", "Москва, 12-я Парковая улица, 5"),
)
]
assert len(estimator._dedup_cross_source(lots)) == 2

View file

@ -80,8 +80,6 @@ def _make_payload(rooms: int = 3):
def _run_estimate( def _run_estimate(
ratio_tuple: tuple[float | None, str | None], ratio_tuple: tuple[float | None, str | None],
*,
clamp_enabled: bool = True,
) -> Any: ) -> Any:
from app.services.estimator import estimate_quality from app.services.estimator import estimate_quality
@ -90,7 +88,6 @@ def _run_estimate(
async def _run() -> Any: async def _run() -> Any:
with ( with (
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
# #2002: these tests assert the clamp/ratio math exactly. Hold the # #2002: these tests assert the clamp/ratio math exactly. Hold the
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold). # orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
@ -131,7 +128,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
) -> None: ) -> None:
ratio = 1.15 ratio = 1.15
with caplog.at_level(logging.INFO, logger="app.services.estimator"): with caplog.at_level(logging.INFO, logger="app.services.estimator"):
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) est = _run_estimate((ratio, "per_rooms"))
assert est.median_price_rub > 0, "headline должен быть задан аналогами" assert est.median_price_rub > 0, "headline должен быть задан аналогами"
assert est.expected_sold_price_rub is not None assert est.expected_sold_price_rub is not None
assert est.expected_sold_price_rub <= est.median_price_rub, ( assert est.expected_sold_price_rub <= est.median_price_rub, (
@ -167,7 +164,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
def test_expected_sold_not_clamped_when_ratio_below_1() -> None: def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
ratio = 0.8 ratio = 0.8
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) est = _run_estimate((ratio, "per_rooms"))
assert est.median_price_rub > 0 assert est.median_price_rub > 0
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
@ -182,22 +179,10 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
def test_expected_sold_none_when_ratio_none() -> None: def test_expected_sold_none_when_ratio_none() -> None:
est = _run_estimate((None, None), clamp_enabled=True) est = _run_estimate((None, None))
assert est.expected_sold_price_rub is None assert est.expected_sold_price_rub is None
assert est.expected_sold_per_m2 is None assert est.expected_sold_per_m2 is None
assert est.expected_sold_range_low_rub is None assert est.expected_sold_range_low_rub is None
assert est.expected_sold_range_high_rub is None assert est.expected_sold_range_high_rub is None
assert est.asking_to_sold_ratio is None assert est.asking_to_sold_ratio is None
assert est.median_price_rub > 0 assert est.median_price_rub > 0
def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None:
ratio = 1.15
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False)
assert est.median_price_rub > 0
assert est.expected_sold_price_rub is not None
assert est.expected_sold_price_rub > est.median_price_rub, (
f"Ожидалось expected_sold > asking при clamp=False, "
f"но {est.expected_sold_price_rub} <= {est.median_price_rub}"
)
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)

View file

@ -6,7 +6,7 @@ via ``_price_from_inputs`` (hermetic — no DB, no network). Verifies:
* both clamp boundaries ( factor_min via huge area, factor_max via small+new); * both clamp boundaries ( factor_min via huge area, factor_max via small+new);
* the neutral year term when ``target_year`` is None ( year 2000); * the neutral year term when ``target_year`` is None ( year 2000);
* the le_asking invariant the corrected expected_sold never exceeds the asking * the le_asking invariant the corrected expected_sold never exceeds the asking
headline (median) when ``estimate_expected_sold_le_asking`` is on. headline (median) the expected_sold asking clamp has no switch (#2380).
NOTE: importing app.services.estimator pulls app.core.config.Settings which NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules. requires DATABASE_URL. Set it BEFORE importing app modules.
@ -151,8 +151,9 @@ def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) ->
def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None: def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None:
"""Small area + new year → raw factor > factor_max → clamped to the ceiling. """Small area + new year → raw factor > factor_max → clamped to the ceiling.
le_asking is held OFF so the raw ceiling factor is observable on the point ratio 0.70 keeps 0.70 × factor_max (1.30) = 0.91 < 1.0, so the always-on
(otherwise the re-clamp would cap it at the asking headline). expected_sold asking re-clamp does not bind and the raw ceiling factor is
observable on the point (#2380: the clamp no longer has an OFF switch).
#3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про #3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не
@ -161,11 +162,10 @@ def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146) monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603) monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220) monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220)
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
off = _price(area_m2=15.0, target_year=2025, ratio=0.85) off = _price(area_m2=15.0, target_year=2025, ratio=0.70)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
on = _price(area_m2=15.0, target_year=2025, ratio=0.85) on = _price(area_m2=15.0, target_year=2025, ratio=0.70)
factor = _expected_factor(15.0, 2025) factor = _expected_factor(15.0, 2025)
assert factor == estimator.settings.estimate_hedonic_factor_max assert factor == estimator.settings.estimate_hedonic_factor_max
@ -187,9 +187,8 @@ def test_target_year_none_is_neutral(monkeypatch: pytest.MonkeyPatch) -> None:
def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None: def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None:
"""With le_asking on, the hedonic-corrected expected_sold never exceeds asking.""" """The hedonic-corrected expected_sold never exceeds asking."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", True)
# small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 → # small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 →
# uncorrected the point would exceed the asking headline; the re-clamp must bind. # uncorrected the point would exceed the asking headline; the re-clamp must bind.
res = _price(area_m2=15.0, target_year=2025, ratio=0.95) res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
@ -202,17 +201,6 @@ def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch
assert res.expected_sold_price == res.median_price assert res.expected_sold_price == res.median_price
def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPatch) -> None:
"""Control: with le_asking OFF, the hedonic uplift may exceed asking (no clamp)."""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
assert res.expected_sold_price is not None
# 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline.
assert res.expected_sold_price > res.median_price
def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None: def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None:
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3. """floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.

View file

@ -15,7 +15,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
from types import SimpleNamespace
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
@ -33,20 +32,6 @@ ELITE_REASON = (
) )
def _settings(
*,
high_value: int = 20_000_000,
wide_ratio: float = 1.9,
elite_ppm2: int = 250_000,
) -> SimpleNamespace:
"""Duck-typed заглушка Settings — helper читает только эти поля."""
return SimpleNamespace(
estimate_manual_review_high_value_rub=high_value,
estimate_manual_review_wide_range_ratio=wide_ratio,
estimate_manual_review_elite_ppm2=elite_ppm2,
)
# ── Каждый триггер в изоляции ──────────────────────────────────────────────── # ── Каждый триггер в изоляции ────────────────────────────────────────────────
@ -57,7 +42,6 @@ def test_premium_building_alone() -> None:
confidence="high", confidence="high",
range_low=7_500_000, range_low=7_500_000,
range_high=8_500_000, range_high=8_500_000,
settings=_settings(),
) )
assert rec is True assert rec is True
assert reasons == [PREMIUM_REASON] assert reasons == [PREMIUM_REASON]
@ -70,7 +54,6 @@ def test_high_value_alone() -> None:
confidence="high", confidence="high",
range_low=24_000_000, range_low=24_000_000,
range_high=26_000_000, range_high=26_000_000,
settings=_settings(),
) )
assert rec is True assert rec is True
assert reasons == [HIGH_VALUE_REASON] assert reasons == [HIGH_VALUE_REASON]
@ -83,7 +66,6 @@ def test_low_confidence_alone() -> None:
confidence="low", confidence="low",
range_low=4_500_000, range_low=4_500_000,
range_high=5_500_000, range_high=5_500_000,
settings=_settings(),
) )
assert rec is True assert rec is True
assert reasons == [LOW_CONF_REASON] assert reasons == [LOW_CONF_REASON]
@ -96,7 +78,6 @@ def test_wide_range_alone() -> None:
confidence="high", confidence="high",
range_low=5_000_000, range_low=5_000_000,
range_high=10_000_000, # ratio 2.0 ≥ 1.9 range_high=10_000_000, # ratio 2.0 ≥ 1.9
settings=_settings(),
) )
assert rec is True assert rec is True
assert reasons == [WIDE_RANGE_REASON] assert reasons == [WIDE_RANGE_REASON]
@ -114,7 +95,6 @@ def test_elite_ppm2_alone() -> None:
confidence="high", confidence="high",
range_low=11_000_000, range_low=11_000_000,
range_high=13_000_000, # ratio ~1.18 < 1.9 range_high=13_000_000, # ratio ~1.18 < 1.9
settings=_settings(),
asking_ppm2=300_000, # ≥ 250_000 asking_ppm2=300_000, # ≥ 250_000
) )
assert rec is True assert rec is True
@ -129,7 +109,6 @@ def test_elite_ppm2_boundary_inclusive() -> None:
confidence="high", confidence="high",
range_low=11_000_000, range_low=11_000_000,
range_high=13_000_000, range_high=13_000_000,
settings=_settings(),
asking_ppm2=250_000, asking_ppm2=250_000,
) )
assert rec is True assert rec is True
@ -144,7 +123,6 @@ def test_elite_ppm2_just_below_threshold() -> None:
confidence="high", confidence="high",
range_low=11_000_000, range_low=11_000_000,
range_high=13_000_000, range_high=13_000_000,
settings=_settings(),
asking_ppm2=249_999, asking_ppm2=249_999,
) )
assert rec is False assert rec is False
@ -159,7 +137,6 @@ def test_elite_ppm2_none_safe() -> None:
confidence="high", confidence="high",
range_low=11_000_000, range_low=11_000_000,
range_high=13_000_000, range_high=13_000_000,
settings=_settings(),
asking_ppm2=None, asking_ppm2=None,
) )
assert rec is False assert rec is False
@ -175,7 +152,6 @@ def test_elite_ppm2_zero_safe() -> None:
confidence="high", confidence="high",
range_low=11_000_000, range_low=11_000_000,
range_high=13_000_000, range_high=13_000_000,
settings=_settings(),
asking_ppm2=0, asking_ppm2=0,
) )
assert rec is False assert rec is False
@ -190,7 +166,6 @@ def test_elite_ppm2_combined_with_other_triggers() -> None:
confidence="low", confidence="low",
range_low=10_000_000, range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9 range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
asking_ppm2=400_000, asking_ppm2=400_000,
) )
assert rec is True assert rec is True
@ -214,7 +189,6 @@ def test_multiple_reasons_combined() -> None:
confidence="low", confidence="low",
range_low=10_000_000, range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9 range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
) )
assert rec is True assert rec is True
# порядок детерминирован: premium → high_value → low_conf → wide_range # порядок детерминирован: premium → high_value → low_conf → wide_range
@ -228,7 +202,6 @@ def test_all_clear_no_reasons() -> None:
confidence="medium", confidence="medium",
range_low=9_000_000, range_low=9_000_000,
range_high=11_000_000, # ratio ~1.22 < 1.9 range_high=11_000_000, # ratio ~1.22 < 1.9
settings=_settings(),
) )
assert rec is False assert rec is False
assert reasons == [] assert reasons == []
@ -245,7 +218,6 @@ def test_high_value_boundary_inclusive() -> None:
confidence="medium", confidence="medium",
range_low=19_000_000, range_low=19_000_000,
range_high=21_000_000, range_high=21_000_000,
settings=_settings(),
) )
assert rec is True assert rec is True
assert reasons == [HIGH_VALUE_REASON] assert reasons == [HIGH_VALUE_REASON]
@ -258,7 +230,6 @@ def test_high_value_just_below_threshold() -> None:
confidence="medium", confidence="medium",
range_low=19_000_000, range_low=19_000_000,
range_high=21_000_000, range_high=21_000_000,
settings=_settings(),
) )
assert rec is False assert rec is False
assert reasons == [] assert reasons == []
@ -272,7 +243,6 @@ def test_wide_range_boundary_inclusive() -> None:
confidence="high", confidence="high",
range_low=10_000_000, range_low=10_000_000,
range_high=19_000_000, # ratio == 1.9 range_high=19_000_000, # ratio == 1.9
settings=_settings(),
) )
assert rec is True assert rec is True
assert reasons == [WIDE_RANGE_REASON] assert reasons == [WIDE_RANGE_REASON]
@ -285,7 +255,6 @@ def test_wide_range_just_below_threshold() -> None:
confidence="high", confidence="high",
range_low=10_000_000, range_low=10_000_000,
range_high=18_900_000, # ratio 1.89 < 1.9 range_high=18_900_000, # ratio 1.89 < 1.9
settings=_settings(),
) )
assert rec is False assert rec is False
assert reasons == [] assert reasons == []
@ -311,7 +280,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
confidence="high", confidence="high",
range_low=range_low, range_low=range_low,
range_high=range_high, range_high=range_high,
settings=_settings(),
) )
assert rec is False assert rec is False
assert WIDE_RANGE_REASON not in reasons assert WIDE_RANGE_REASON not in reasons
@ -332,7 +300,6 @@ def test_recommended_equals_bool_reasons_invariant() -> None:
confidence=conf, confidence=conf,
range_low=9_000_000, range_low=9_000_000,
range_high=11_000_000, range_high=11_000_000,
settings=_settings(),
) )
assert rec is bool(reasons) assert rec is bool(reasons)

View file

@ -26,12 +26,9 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3). # Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import estimator
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный # Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
# estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None # estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None
@ -43,16 +40,6 @@ _h = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_h) _spec.loader.exec_module(_h)
@pytest.fixture(autouse=True)
def _pin_dedup_off(monkeypatch: pytest.MonkeyPatch) -> None:
# Эти тесты проверяют инвариант #2 «n_analogs = число priced-аналогов»,
# ортогональный кросс-source физдедупу (#2087 H4, default ON с #2173).
# Синтетические аналоги делят один адрес/этаж/площадь → дедуп схлопнул бы
# близкие по цене как кросс-посты и исказил счётчик. Пиним флаг OFF, чтобы
# мерить именно #2 в изоляции (не полагаясь на прод-default, который теперь ON).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]: def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]:
"""Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м². """Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м².
@ -103,8 +90,10 @@ def test_n_analogs_counts_only_priced_radius_analogs() -> None:
_listing(price_per_m2=210_000.0), _listing(price_per_m2=210_000.0),
_listing(price_per_m2=215_000.0), _listing(price_per_m2=215_000.0),
_listing(price_per_m2=225_000.0), _listing(price_per_m2=225_000.0),
_listing(price_per_m2=None), # Разная площадь → разные физлоты: кросс-source дедуп (#2087 H4, всегда ON
_listing(price_per_m2=None), # с #2378) не схлопывает два безценовых листинга в один.
_listing(price_per_m2=None, area_m2=61.0),
_listing(price_per_m2=None, area_m2=62.0),
] ]
est = _h._run_estimate( est = _h._run_estimate(
anchor_comps=[], anchor_comps=[],
@ -141,7 +130,8 @@ def test_n_analogs_all_priceless_yields_zero_and_low() -> None:
def test_n_analogs_all_priced_unchanged() -> None: def test_n_analogs_all_priced_unchanged() -> None:
"""Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется, """Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется,
зеркало test_radius_path_n_analogs_unchanged для priced-only выборки).""" зеркало test_radius_path_n_analogs_unchanged для priced-only выборки)."""
radius = [_listing(price_per_m2=200_000.0 + i * 1_000) for i in range(5)] # Разная площадь → 5 разных физлотов, кросс-source дедуп их не схлопывает.
radius = [_listing(price_per_m2=200_000.0 + i * 1_000, area_m2=60.0 + i) for i in range(5)]
est = _h._run_estimate( est = _h._run_estimate(
anchor_comps=[], anchor_comps=[],
anchor_tier=None, anchor_tier=None,

View file

@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None:
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше: Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` ``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched``
дальше по коду он читается как «headline построил якорь». дальше по коду он читается как «headline построил якорь».
""" """
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4 comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4
radius_median_price = int(100_000 * 50.0) radius_median_price = int(100_000 * 50.0)
pr = _call( pr = _call(
listings=_lots(100_000, n=5), listings=_lots(100_000, n=5),

View file

@ -389,6 +389,7 @@ def _run_estimate_qi(
anchor_tier_override: str | None = None, anchor_tier_override: str | None = None,
analog_indexes: dict[str, float] | None = None, analog_indexes: dict[str, float] | None = None,
imv_anchor: dict[str, Any] | None = None, imv_anchor: dict[str, Any] | None = None,
seen_min_n: list[int] | None = None,
): ):
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate. """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
@ -396,6 +397,9 @@ def _run_estimate_qi(
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0. батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
Кварталы вне карты не попадают в ответ ровно как в FDW при n_deals < порога. Кварталы вне карты не попадают в ответ ровно как в FDW при n_deals < порога.
seen_min_n сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог
фильтрует строки в SQL, и заглушка его иначе теряет.
""" """
from app.services.estimator import estimate_quality from app.services.estimator import estimate_quality
@ -406,7 +410,14 @@ def _run_estimate_qi(
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс, # Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict. # что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
if seen_min_n is not None:
seen_min_n.append(min_n_deals)
return qi_lookup_result
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
if seen_min_n is not None:
seen_min_n.append(min_n_deals)
if analog_indexes is not None: if analog_indexes is not None:
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes} return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
if qi_lookup_result is None: if qi_lookup_result is None:
@ -451,7 +462,7 @@ def _run_estimate_qi(
# Stub singular target-quarter lookup # Stub singular target-quarter lookup
patch( patch(
"app.services.estimator._lookup_quarter_index", "app.services.estimator._lookup_quarter_index",
return_value=qi_lookup_result, side_effect=_fake_lookup_index,
), ),
# Stub batched analog-quarter lookup # Stub batched analog-quarter lookup
patch( patch(
@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
analogs=analogs, analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350", dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
# #2379: уровень аналогов 1.0 — без гарда было бы ×1.5. Без явной карты стаб
# отдаёт аналогам индекс цели, factor=1.0, и тест зеленел при снятом гарде.
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
) )
# Медиана НЕ должна изменяться # Медиана НЕ должна изменяться
assert est.median_price_rub == base_median assert est.median_price_rub == base_median
def test_guard2_ratio_exactly_0_6_still_corrects() -> None:
"""#2379: граница Guard-2 строгая — 3 из 5 аналогов (0.6) в квартале цели не skip."""
analogs = [
_make_listing_qi(
price_per_m2=_BASE_PPM2,
building_cadastral_number=f"{_TARGET_QUARTER if i < 3 else _OTHER_QUARTER}:100",
floor=1 + i,
)
for i in range(5)
]
est = _run_estimate_qi(
analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.5, 30),
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
)
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
def test_quarter_lookups_filter_by_ten_deals() -> None:
"""#2379: оба lookup'а (квартал цели и кварталы аналогов) просят индекс от 10 сделок.
Порог уходит параметром в SQL (`n_deals >= :min_n`), в заглушке его не видно:
подмена числа на месте вызова проходила весь сьют.
"""
seen: list[int] = []
est = _run_estimate_qi(
analogs=_ANALOGS_OTHER_QUARTER,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
analog_indexes={_OTHER_QUARTER: 1.0},
seen_min_n=seen,
)
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.2) # оба lookup'а вызваны
assert seen == [10, 10]
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Sparse fallback: нет строки → no-op # Sparse fallback: нет строки → no-op
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -659,19 +710,27 @@ def test_c3_one_indexed_analog_is_enough_to_correct() -> None:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def test_bimodal_guard_skips_high_index_small_n() -> None: @pytest.mark.parametrize(
"""Bimodal guard: price_index=3.5, n_deals=20 → no-op.""" ("target_qi", "expected_factor"),
analogs_no_cadnum = [ [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) (3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op
for i in range(5) (2.01, 1.0),
] (2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8
base_median = round(_BASE_PPM2 * _AREA) ],
)
def test_bimodal_guard_skips_high_index_small_n(target_qi: float, expected_factor: float) -> None:
"""Bimodal guard: price_index > 2.0 при n_deals=20 → no-op.
#2379: аналоги с ИЗВЕСТНЫМ уровнем 1.0. Раньше здесь были аналоги без кадастра —
поправку на них гасит и Guard-5 (#2583), и тест зеленел при снятом bimodal-гарде.
"""
est = _run_estimate_qi( est = _run_estimate_qi(
analogs=analogs_no_cadnum, analogs=_ANALOGS_OTHER_QUARTER,
dadata_cadnum=f"{_TARGET_QUARTER}:350", dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard qi_lookup_result=(target_qi, 20),
analog_indexes={_OTHER_QUARTER: 1.0},
) )
assert est.median_price_rub == base_median assert est.median_price_rub == round(_BASE_PPM2 * _AREA * expected_factor)
def test_bimodal_guard_allows_high_index_large_n() -> None: def test_bimodal_guard_allows_high_index_large_n() -> None:

View file

@ -8,6 +8,8 @@
- radius median выше dkp_low × factor no-op (медиана не изменена) - radius median выше dkp_low × factor no-op (медиана не изменена)
- dkp_raw is None no-op (нет базы для floor) - dkp_raw is None no-op (нет базы для floor)
- anchor-путь (anchor_tier != None) не затронут floor'ом - anchor-путь (anchor_tier != None) не затронут floor'ом
- коридор ниже CORRIDOR_CLAMP_MIN_N floor не применяется (#3466)
- коридор ровно CORRIDOR_CLAMP_MIN_N floor применяется (#3466)
""" """
from __future__ import annotations from __future__ import annotations
@ -73,10 +75,7 @@ def _make_payload():
def _run_estimate( def _run_estimate(
analogs: list[dict[str, Any]], analogs: list[dict[str, Any]],
dkp_raw: dict[str, Any] | None, dkp_raw: dict[str, Any] | None,
*,
radius_floor_factor: float = 0.8,
) -> Any: ) -> Any:
from app.core.config import settings
from app.services.estimator import estimate_quality from app.services.estimator import estimate_quality
db = MagicMock() db = MagicMock()
@ -107,24 +106,31 @@ def _run_estimate(
), ),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
): ):
return await estimate_quality(payload, db) return await estimate_quality(payload, db)
return anyio.run(_run) return anyio.run(_run)
def _six(center: float) -> list[dict[str, Any]]:
"""Шесть лотов вокруг center — выше HEADLINE_LISTINGS_MIN_N (5).
Три лота (как было до #3466) уводили оценку в #oblast-E: headline уступался
медиане коридора (180k), и floor не исполнялся вовсе тесты 1 и 2 зеленели на
deals-fallback, а снятие гейта min_n у floor ничего не роняло.
"""
return [
_make_listing(price_per_m2=center + d)
for d in (-5_000.0, -2_500.0, 0.0, 0.0, 2_500.0, 5_000.0)
]
# ── тест 1: radius median ниже floor → поднята ─────────────────────────────── # ── тест 1: radius median ниже floor → поднята ───────────────────────────────
def test_radius_median_below_dkp_floor_is_lifted() -> None: def test_radius_median_below_dkp_floor_is_lifted() -> None:
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята.""" """Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
# analogs с median ~80k analogs = _six(80_000.0)
analogs = [
_make_listing(price_per_m2=75_000.0),
_make_listing(price_per_m2=80_000.0),
_make_listing(price_per_m2=85_000.0),
]
dkp_raw = { dkp_raw = {
"count": 15, "count": 15,
"low_ppm2": 150_000, "low_ppm2": 150_000,
@ -132,11 +138,12 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
"high_ppm2": 220_000, "high_ppm2": 220_000,
"period_months": 12, "period_months": 12,
} }
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) est = _run_estimate(analogs, dkp_raw)
floor_ppm2 = 150_000 * 0.8 # 120_000 floor_ppm2 = 150_000 * 0.8 # 120_000
assert est.median_price_per_m2 >= floor_ppm2, ( # Ровно floor, а не «не ниже»: медиана коридора (180k) тоже «не ниже».
f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" assert est.median_price_per_m2 == floor_ppm2, (
f"median_ppm2={est.median_price_per_m2} должна быть поднята ровно до floor={floor_ppm2}"
) )
@ -145,11 +152,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
def test_radius_median_above_dkp_floor_unchanged() -> None: def test_radius_median_above_dkp_floor_unchanged() -> None:
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op.""" """Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
analogs = [ analogs = _six(200_000.0)
_make_listing(price_per_m2=190_000.0),
_make_listing(price_per_m2=200_000.0),
_make_listing(price_per_m2=210_000.0),
]
dkp_raw = { dkp_raw = {
"count": 15, "count": 15,
"low_ppm2": 150_000, "low_ppm2": 150_000,
@ -157,15 +160,15 @@ def test_radius_median_above_dkp_floor_unchanged() -> None:
"high_ppm2": 220_000, "high_ppm2": 220_000,
"period_months": 12, "period_months": 12,
} }
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) est = _run_estimate(analogs, dkp_raw)
# floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется # floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
floor_ppm2 = 150_000 * 0.8 # 120_000 floor_ppm2 = 150_000 * 0.8 # 120_000
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
# Медиана соответствует аналогам (~200k), а не floor # Медиана — ровно медиана аналогов, а не floor и не медиана коридора (180k).
assert 180_000 <= est.median_price_per_m2 <= 220_000, ( assert est.median_price_per_m2 == 200_000, (
f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" f"median_ppm2={est.median_price_per_m2} должна остаться медианой аналогов (no-op)"
) )
@ -179,9 +182,69 @@ def test_no_dkp_raw_no_floor() -> None:
_make_listing(price_per_m2=80_000.0), _make_listing(price_per_m2=80_000.0),
_make_listing(price_per_m2=85_000.0), _make_listing(price_per_m2=85_000.0),
] ]
est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8) est = _run_estimate(analogs, dkp_raw=None)
# median ~80k, без dkp_raw floor не поднимает # median ~80k, без dkp_raw floor не поднимает
assert est.median_price_per_m2 < 100_000, ( assert est.median_price_per_m2 < 100_000, (
f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
) )
# ── тест 4: коридор ниже порога доверия → floor не поднимает (#3466) ─────────
def test_floor_not_applied_below_clamp_min_n() -> None:
"""n = CORRIDOR_CLAMP_MIN_N 1: тот же floor, что в тесте 1, но выключен.
`DkpCorridor.advisory_only` (#3452) обосновывает себя общим порогом у клампа
headline И у radius-floor. Половина про кламп стережётся test_3452_*, эта
здесь: без гейта по count медиана 80k поднялась бы до 120k.
"""
from app.core.config import CORRIDOR_CLAMP_MIN_N
analogs = _six(80_000.0)
dkp_raw = {
"count": CORRIDOR_CLAMP_MIN_N - 1,
"low_ppm2": 150_000,
"median_ppm2": 180_000,
"high_ppm2": 220_000,
"period_months": 12,
}
est = _run_estimate(analogs, dkp_raw)
assert est.median_price_per_m2 < 100_000, (
f"median_ppm2={est.median_price_per_m2}: коридор из "
f"{dkp_raw['count']} сделок ниже порога не должен поднимать цену до floor=120000"
)
# ── тест 5: ровно на пороге floor уже работает (#3466) ──────────────────────
def test_floor_applied_at_exactly_clamp_min_n() -> None:
"""n = CORRIDOR_CLAMP_MIN_N: advisory_only=False → floor обязан поднять.
Тест 4 стережёт «ниже порога нет», тест 1 «15 сделок да». Граница между
ними (`>=` против `>`) не стереглась: при n == min_n витрина не пишет подпись
«справочно», значит коридор обязан войти в цену.
"""
from app.core.config import CORRIDOR_CLAMP_MIN_N
from app.schemas.trade_in import DkpCorridor
analogs = _six(80_000.0)
dkp_raw = {
"count": CORRIDOR_CLAMP_MIN_N,
"low_ppm2": 150_000,
"median_ppm2": 180_000,
"high_ppm2": 220_000,
"period_months": 12,
}
# Та же граница с другой стороны: на ней коридор уже не справочный.
assert DkpCorridor(**dkp_raw).advisory_only is False
est = _run_estimate(analogs, dkp_raw)
assert est.median_price_per_m2 == 150_000 * 0.8, (
f"median_ppm2={est.median_price_per_m2}: коридор из {dkp_raw['count']} сделок "
"(ровно порог) должен поднять цену ровно до floor=120000"
)

View file

@ -34,6 +34,7 @@ from fastapi.testclient import TestClient # noqa: E402
from app.services.estimator import ( # noqa: E402 from app.services.estimator import ( # noqa: E402
DEFAULT_RADIUS_M, DEFAULT_RADIUS_M,
_compute_confidence, _compute_confidence,
analog_tier_from_explanation,
radius_from_relaxations, radius_from_relaxations,
radius_relaxation_label, radius_relaxation_label,
rehydrate_search_radius_m, rehydrate_search_radius_m,
@ -193,6 +194,8 @@ def _light_estimator_stub():
_source_counts=lambda *a, **k: {}, _source_counts=lambda *a, **k: {},
_canonical_sources=lambda *a, **k: ["cian"], _canonical_sources=lambda *a, **k: ["cian"],
rehydrate_search_radius_m=rehydrate_search_radius_m, rehydrate_search_radius_m=rehydrate_search_radius_m,
# #3466: радиусная строка («Найдено 42 аналогов») → None, разбор настоящий.
analog_tier_from_explanation=analog_tier_from_explanation,
) )
sys.modules["app.services.estimator"] = stub # type: ignore[assignment] sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
yield stub yield stub

View file

@ -22,8 +22,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
import pytest import pytest
from app.core.config import settings from app.services.estimator import WIDE_CORRIDOR_THRESHOLD, _downgrade_confidence
from app.services.estimator import _downgrade_confidence
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный # Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
# estimate путь со всеми I/O застабленными). # estimate путь со всеми I/O застабленными).
@ -48,7 +47,7 @@ def _payload(): # type: ignore[no-untyped-def]
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def] def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
with patch.object(settings, "estimate_wide_corridor_threshold", threshold): with patch("app.services.estimator.WIDE_CORRIDOR_THRESHOLD", threshold):
return _h._run_estimate( return _h._run_estimate(
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps, anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
anchor_tier=anchor_tier, anchor_tier=anchor_tier,
@ -67,7 +66,7 @@ _DISCLOSURE_MARKER = "разбит на секции"
def test_threshold_default_1_2() -> None: def test_threshold_default_1_2() -> None:
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом); # 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост. # genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
assert settings.estimate_wide_corridor_threshold == 1.2 assert WIDE_CORRIDOR_THRESHOLD == 1.2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -15,6 +15,7 @@ import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import asyncio import asyncio
import time
from typing import Any, ClassVar from typing import Any, ClassVar
import pytest import pytest
@ -46,12 +47,13 @@ class _FakeTelegramClient:
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети. """Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети.
`responses` очередь: каждый вызов `send_message` берёт следующий элемент `responses` очередь: каждый вызов `send_message` берёт следующий элемент
(dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не (dict = успех, Exception = отказ, float = зависнуть на столько секунд, потом
удалась, повторная фоном прошла». успех), позволяя смоделировать «первая попытка не удалась, повторная фоном
прошла».
""" """
calls: ClassVar[list[dict[str, Any]]] = [] calls: ClassVar[list[dict[str, Any]]] = []
responses: ClassVar[list[dict[str, Any] | Exception]] = [] responses: ClassVar[list[dict[str, Any] | Exception | float]] = []
def __init__(self, _token: str = "fake-token") -> None: def __init__(self, _token: str = "fake-token") -> None:
pass pass
@ -61,6 +63,9 @@ class _FakeTelegramClient:
outcome = _FakeTelegramClient.responses.pop(0) outcome = _FakeTelegramClient.responses.pop(0)
if isinstance(outcome, Exception): if isinstance(outcome, Exception):
raise outcome raise outcome
if isinstance(outcome, float):
await asyncio.sleep(outcome)
return {"message_id": 1}
return outcome return outcome
@ -109,6 +114,28 @@ def test_sync_failure_queues_background_retry_and_still_returns_502(
assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"] assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"]
def test_slow_telegram_answers_502_before_sender_gives_up(
client: TestClient, _fake_telegram_client: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
"""#3157, прод 16.09.2026 16:42 UTC: отказ Telegram был медленным (relay 8 с +
напрямую 5 с + пауза 2 с), GlitchTip оборвал запрос на 10-й секунде, 502 и
фоновой доставки не было. Медленный отказ обязан идти тем же путём, что быстрый."""
# GlitchTip 6.1.6 apps/alerts/webhooks.py send_webhook: ClientTimeout(total=10).
assert glitchtip_module._INTERACTIVE_SEND_DEADLINE_S <= 8.0
monkeypatch.setattr(glitchtip_module, "_INTERACTIVE_SEND_DEADLINE_S", 0.2)
_fake_telegram_client.responses = [5.0, {"message_id": 2}]
started = time.monotonic()
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
elapsed = time.monotonic() - started
assert r.status_code == 502
assert elapsed < 2.0
# Фон отработал: зависшая синхронная попытка + доставка фоном тем же текстом.
assert len(_fake_telegram_client.calls) == 2
assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"]
def test_sync_success_does_not_queue_background_retry( def test_sync_success_does_not_queue_background_retry(
client: TestClient, _fake_telegram_client: Any client: TestClient, _fake_telegram_client: Any
) -> None: ) -> None:

View file

@ -640,3 +640,36 @@ async def test_report_ban_does_not_release_or_rotate_lease() -> None:
assert provider.released == [] assert provider.released == []
assert bf._lease is not None assert bf._lease is not None
assert bf._lease.id == 5 assert bf._lease.id == 5
# ── #3410: /login несёт узел аренды, как /fetch ────────────────────────────────
@pytest.mark.parametrize("use_pool", [True, False])
async def test_login_body_carries_lease_proxy(use_pool: bool) -> None:
"""Аренда есть → в теле /login тот же proxy/proxy_kind, что ушёл бы в /fetch; нет → без."""
provider = _FakeProxyProvider(ProxyLease(id=13, url="http://u:p@pool:8080", kind="http"))
client = _mock_client({"cookies": [{"name": "DMIR_AUTH", "value": "v"}]})
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=use_pool)
cookies = await bf.login(
url="https://www.cian.ru/authenticate/",
email="a@b.c",
password="pw",
email_selector="#e",
password_selector="#p",
submit_selector="#s",
success_cookie="DMIR_AUTH",
)
await bf.__aexit__(None, None, None)
assert cookies == {"DMIR_AUTH": "v"}
url_called = client.post.call_args.args[0]
body = client.post.call_args.kwargs["json"]
assert url_called == "http://browser:3000/login"
if use_pool:
assert (body.get("proxy"), body.get("proxy_kind")) == ("http://u:p@pool:8080", "http")
assert provider.released == [13]
else:
assert "proxy" not in body and "proxy_kind" not in body
assert provider.acquired == []

View file

@ -24,9 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import anyio import anyio
import pytest
from app.services import estimator
from app.services.estimator import ( from app.services.estimator import (
_compute_same_building_anchor, _compute_same_building_anchor,
_geocode_is_coarse, _geocode_is_coarse,
@ -395,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [
] ]
# Same-building комплы Хохрякова 48 (флагман 684k внутри). # Same-building комплы Хохрякова 48 (флагман 684k внутри).
# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755). # 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755).
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [ _SB_COMPS_PREMIUM: list[dict[str, Any]] = [
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3}, {"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3}, {"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
@ -503,6 +501,29 @@ def test_estimate_premium_lift_real_in_range() -> None:
assert est.range_high_rub >= int(real * 0.9) assert est.range_high_rub >= int(real * 0.9)
def test_estimate_anchor_gets_prod_weights() -> None:
"""#2381: веса якоря доходят до расчёта боевыми (прод 17.09.2026).
Тест значений констант держит только число в estimator.py. Подмена
rooms_boost/floor_sigma на месте вызова (например, 1.0) проходила весь сьют:
веса сдвигают headline внутри тех же допусков, что проверяют тесты выше.
"""
with patch(
"app.services.estimator._compute_same_building_anchor",
wraps=_compute_same_building_anchor,
) as spy:
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
assert est.median_price_per_m2 >= 450_000 # якорь сработал, а не радиус
kw = spy.call_args.kwargs
assert {k: kw[k] for k in ("sigma", "rooms_boost", "floor_sigma", "min_comps", "mad_k")} == {
"sigma": 0.18,
"rooms_boost": 1.6,
"floor_sigma": 0.25,
"min_comps": 4,
"mad_k": 3.5,
}
def test_estimate_anchor_fires_without_radius_analogs() -> None: def test_estimate_anchor_fires_without_radius_analogs() -> None:
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но """#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
same-building комплы есть якорь обязан сработать и дать median>0. same-building комплы есть якорь обязан сработать и дать median>0.
@ -529,7 +550,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
def test_estimate_economy_no_regression() -> None: def test_estimate_economy_no_regression() -> None:
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов. """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
4 компла satisfies estimate_sb_min_comps=4 (#755). 4 компла satisfies SB_MIN_COMPS=4 (#755).
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
(все отклонения << 3.5×MAD при tight spread).""" (все отклонения << 3.5×MAD при tight spread)."""
eco_comps = [ eco_comps = [
@ -598,7 +619,7 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None:
def test_estimate_range_covers_same_building_comp_spread() -> None: def test_estimate_range_covers_same_building_comp_spread() -> None:
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не """range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
вылетает за диапазон зеркало 8 Марта 204Г view-кейса). вылетает за диапазон зеркало 8 Марта 204Г view-кейса).
4 компла satisfies estimate_sb_min_comps=4 (#755).""" 4 компла satisfies SB_MIN_COMPS=4 (#755)."""
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой. # comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
view_comps = [ view_comps = [
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2}, {"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
@ -665,7 +686,7 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
def test_estimate_analogs_pass_through_display_fields() -> None: def test_estimate_analogs_pass_through_display_fields() -> None:
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают """#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area). их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
4 компла satisfies estimate_sb_min_comps=4 (#755).""" 4 компла satisfies SB_MIN_COMPS=4 (#755)."""
comps_with_display = [ comps_with_display = [
{ {
"price_per_m2": 399_478, "price_per_m2": 399_478,
@ -1040,16 +1061,14 @@ def test_anchor_n_analogs_syncs_to_anchor_count() -> None:
assert "по 4 аналогам" in (est.confidence_explanation or "") assert "по 4 аналогам" in (est.confidence_explanation or "")
def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: def test_radius_path_n_analogs_unchanged() -> None:
"""#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется). """#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).
Проверяет radius-passthrough vs anchor-override ортогонально кросс-source Проверяет radius-passthrough vs anchor-override ортогонально кросс-source
дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно дедупу (#2087 H4, всегда ON с #2378). Площади разные, поэтому это 5 разных
патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF, физлотов: 5 ИДЕНТИЧНЫХ листингов дедуп честно схлопнул бы в 1.
чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON).
""" """
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) radius = [_make_listing(price_per_m2=200_000.0, area_m2=60.0 + i) for i in range(5)]
radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)]
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius) est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius)
assert est.n_analogs == 5 assert est.n_analogs == 5

View file

@ -23,6 +23,10 @@ through `build_curl_cffi_session` — so a missing `config=` does NOT raise like
(non-proxied) connection (`cian_proxy_url` dropped). That is much harder to notice in (non-proxied) connection (`cian_proxy_url` dropped). That is much harder to notice in
prod (no crash, just quietly unproxied traffic that anti-bot can throttle/block), so a prod (no crash, just quietly unproxied traffic that anti-bot can throttle/block), so a
caller that forgets `config=` here gets no signal at all. caller that forgets `config=` here gets no signal at all.
#2385: `config` стал обязательным во всех четырёх точках входа. Прежние кейсы
`*_without_config` (ждали endpoint=None / proxy_url=None) заменены одним тестом
«без config TypeError, фетчер и сессия не строятся».
""" """
from __future__ import annotations from __future__ import annotations
@ -42,6 +46,7 @@ from scraper_kit.providers.yandex.newbuilding import (
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch" _TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
_TEST_PROXY = "http://mobile-proxy.example:8080" _TEST_PROXY = "http://mobile-proxy.example:8080"
_CONFIG = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
def _spy_browser_fetcher(html: str) -> MagicMock: def _spy_browser_fetcher(html: str) -> MagicMock:
@ -77,21 +82,6 @@ async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
assert kwargs.get("endpoint") == _TEST_ENDPOINT assert kwargs.get("endpoint") == _TEST_ENDPOINT
@pytest.mark.asyncio
async def test_cian_fetch_newbuilding_endpoint_none_without_config(monkeypatch):
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом — сигнатура
больше не роняет TypeError на отсутствующем обязательном параметре конструктора."""
spy = _spy_browser_fetcher("<html></html>")
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy)
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
spy.assert_called_once()
_, kwargs = spy.call_args
assert kwargs.get("source") == "cian"
assert kwargs.get("endpoint") is None
# ── yandex.newbuilding.YandexNewbuildingScraper.fetch_jk ───────────────────── # ── yandex.newbuilding.YandexNewbuildingScraper.fetch_jk ─────────────────────
@ -115,21 +105,6 @@ async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch):
assert kwargs.get("endpoint") == _TEST_ENDPOINT assert kwargs.get("endpoint") == _TEST_ENDPOINT
@pytest.mark.asyncio
async def test_yandex_fetch_jk_endpoint_none_without_config(monkeypatch):
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом."""
spy = _spy_browser_fetcher("<html></html>")
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
scraper = YandexNewbuildingScraper()
await scraper.fetch_jk(jk_slug="tatlin", jk_id="1592987")
spy.assert_called_once()
_, kwargs = spy.call_args
assert kwargs.get("source") == "yandex"
assert kwargs.get("endpoint") is None
# ── yandex.newbuilding.resolve_yandex_jk_slug ──────────────────────────────── # ── yandex.newbuilding.resolve_yandex_jk_slug ────────────────────────────────
@ -151,20 +126,6 @@ async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch):
assert kwargs.get("endpoint") == _TEST_ENDPOINT assert kwargs.get("endpoint") == _TEST_ENDPOINT
@pytest.mark.asyncio
async def test_resolve_yandex_jk_slug_endpoint_none_without_config(monkeypatch):
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом."""
spy = _spy_browser_fetcher("<html></html>")
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
await resolve_yandex_jk_slug("1592987")
spy.assert_called_once()
_, kwargs = spy.call_args
assert kwargs.get("source") == "yandex"
assert kwargs.get("endpoint") is None
# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ──────────────── # ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
# #
# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом # Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом
@ -195,9 +156,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat
Это и есть суть правки: адрес запроса, а не разбор ответа. Это и есть суть правки: адрес запроса, а не разбор ответа.
""" """
spy = _spy_browser_fetcher(_JK_PAGE_HTML) spy = _spy_browser_fetcher(_JK_PAGE_HTML)
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
await resolve_yandex_jk_slug("286394") await resolve_yandex_jk_slug("286394", config=_CONFIG)
url = spy.return_value.fetch.call_args[0][0] url = spy.return_value.fetch.call_args[0][0]
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}" assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}"
@ -208,9 +169,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat
async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch): async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch):
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL.""" """Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
spy = _spy_browser_fetcher(_JK_PAGE_HTML) spy = _spy_browser_fetcher(_JK_PAGE_HTML)
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
assert await resolve_yandex_jk_slug("286394") == "uspenskij" assert await resolve_yandex_jk_slug("286394", config=_CONFIG) == "uspenskij"
@pytest.mark.asyncio @pytest.mark.asyncio
@ -221,9 +182,9 @@ async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch):
Без привязки к id функция вернула бы «shishkinn» для чужого дома. Без привязки к id функция вернула бы «shishkinn» для чужого дома.
""" """
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML) spy = _spy_browser_fetcher(_GENERAL_LIST_HTML)
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
assert await resolve_yandex_jk_slug("286394") is None assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None
@pytest.mark.asyncio @pytest.mark.asyncio
@ -232,9 +193,9 @@ async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch):
spy = _spy_browser_fetcher( spy = _spy_browser_fetcher(
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>' '<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
) )
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
assert await resolve_yandex_jk_slug("286394") is None assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ───────────────────────── # ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
@ -276,17 +237,33 @@ async def test_resolve_cian_zhk_url_via_search_passes_proxy_from_config(monkeypa
assert kwargs.get("proxy_url") == _TEST_PROXY assert kwargs.get("proxy_url") == _TEST_PROXY
# ── #2385: config обязателен — без него ни фетчера, ни сессии ────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_cian_zhk_url_via_search_proxy_none_without_config(monkeypatch): @pytest.mark.parametrize(
"""Без config= (SILENT footgun): вызов НЕ падает (в отличие от BrowserFetcher "call",
assertion выше в этом файле), но proxy_url молча становится None резолвер уходит [
на прямое (непроксированное) соединение вместо настроенного мобильного прокси.""" lambda: fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/"),
spy = _spy_curl_session(_SERP_HTML) lambda: YandexNewbuildingScraper().fetch_jk(jk_slug="tatlin", jk_id="1592987"),
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", spy) lambda: resolve_yandex_jk_slug("1592987"),
lambda: resolve_cian_zhk_url_via_search(48853),
],
ids=["cian_fetch_newbuilding", "yandex_fetch_jk", "resolve_yandex_slug", "resolve_cian_url"],
)
async def test_newbuilding_entrypoints_refuse_to_run_without_config(monkeypatch, call):
"""Вызов без config= падает TypeError ДО сети: браузерный фетчер и curl-сессия не
строятся. Раньше тот же вызов молча уходил на env-узел сайдкара мимо пула (#2767,
#3197) — отказ по сигнатуре единственное, что ловит забывчивого вызывающего."""
browser_spy = _spy_browser_fetcher("<html></html>")
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", browser_spy)
session_spy = _spy_curl_session(_SERP_HTML)
monkeypatch.setattr(
"scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", session_spy
)
url = await resolve_cian_zhk_url_via_search(48853) with pytest.raises(TypeError, match="config"):
await call()
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" # "works" — silently unproxied assert browser_spy.call_count == 0
spy.assert_called_once() assert session_spy.call_count == 0
_, kwargs = spy.call_args
assert kwargs.get("proxy_url") is None

View file

@ -40,10 +40,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
def _mock_settings( def _mock_settings(scraper_proxy_url: str | None) -> SimpleNamespace:
scraper_proxy_url: str | None,
yandex_cookies_file: str | None = None,
) -> SimpleNamespace:
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__.""" """Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
return SimpleNamespace( return SimpleNamespace(
scraper_proxy_url=scraper_proxy_url, scraper_proxy_url=scraper_proxy_url,
@ -52,7 +49,6 @@ def _mock_settings(
# removed — dead mobileproxy accounts). Mirrored here for test compat. # removed — dead mobileproxy accounts). Mirrored here for test compat.
cian_proxy_url=scraper_proxy_url, cian_proxy_url=scraper_proxy_url,
yandex_proxy_url=scraper_proxy_url, yandex_proxy_url=scraper_proxy_url,
yandex_cookies_file=yandex_cookies_file,
) )
@ -199,6 +195,10 @@ async def test_yandex_valuation_session_no_proxies_when_none():
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox), # После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера, # а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся. # а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
# config обязателен (#2385): фетчер строит фабрика _base.build_browser_fetcher.
_NB_CONFIG = SimpleNamespace(
browser_http_endpoint="http://tradein-browser:3000", use_proxy_pool_browser=False
)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -222,8 +222,8 @@ async def test_cian_newbuilding_own_session_receives_proxies():
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
mock_fetcher.__aexit__ = AsyncMock(return_value=None) mock_fetcher.__aexit__ = AsyncMock(return_value=None)
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(zhk_url) await cian_newbuilding.fetch_newbuilding(zhk_url, config=_NB_CONFIG)
# BrowserFetcher.fetch вызван с ЖК-url # BrowserFetcher.fetch вызван с ЖК-url
assert fetch_calls == [zhk_url] assert fetch_calls == [zhk_url]
@ -253,8 +253,10 @@ async def test_cian_newbuilding_shared_session_not_recreated():
sentinel_session = MagicMock(name="external_session") sentinel_session = MagicMock(name="external_session")
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session) await cian_newbuilding.fetch_newbuilding(
zhk_url, config=_NB_CONFIG, session=sentinel_session
)
# BrowserFetcher.fetch вызван — именно через него тянется страница # BrowserFetcher.fetch вызван — именно через него тянется страница
assert fetch_calls == [zhk_url] assert fetch_calls == [zhk_url]

View file

@ -121,7 +121,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
_row(source="cian", source_id="c2", listing_segment="novostroyki"), _row(source="cian", source_id="c2", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
_row(source="cian", source_id="c5", listing_segment="novostroyki"), _row(source="cian", source_id="c5", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db) comps, tier = _fetch(db)
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
assert tier is None assert tier is None
@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
_row(source="cian", source_id="c4", listing_segment="novostroyki"), _row(source="cian", source_id="c4", listing_segment="novostroyki"),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db) comps, tier = _fetch(db)
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
assert tier is None assert tier is None
@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
), ),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# source_id-primary схлопывает несмотря на разные url → 5 comps. # source_id-primary схлопывает несмотря на разные url → 5 comps.
@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0), _row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
] ]
db = _db_mock(rows) db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): with patch.object(est_mod, "SB_MIN_COMPS", 4):
comps, tier = _fetch(db, lat=_LAT, lon=_LON) comps, tier = _fetch(db, lat=_LAT, lon=_LON)
assert tier == "A" assert tier == "A"
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.

View file

@ -31,7 +31,7 @@ def test_yandex_detail_picks_up_delay():
def test_yandex_newbuilding_picks_up_delay(): def test_yandex_newbuilding_picks_up_delay():
"""Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name.""" """Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name."""
s = YandexNewbuildingScraper(delay_provider=lambda _name: 9.5) s = YandexNewbuildingScraper(config=_KIT_CONFIG, delay_provider=lambda _name: 9.5)
assert s.request_delay_sec == 9.5 assert s.request_delay_sec == 9.5

View file

@ -107,10 +107,12 @@ Per-provider модель (#1793):
поиск yandex.ru, а не голым goto (#3251). Ручная сессия поиск yandex.ru, а не голым goto (#3251). Ручная сессия
29.08.2026 показала эталонный человеческий путь: 29.08.2026 показала эталонный человеческий путь:
yandex.ru клик по результату выдача с Referer yandex.ru клик по результату выдача с Referer
yandex.ru. Дефолт "domclick" авито/циан/яндекс yandex.ru. Дефолт пусто, выключено везде (#3263):
проверяются отдельно в #3251, не включать здесь. выдача Яндекса капчит конкретные выходные узлы пула,
Пустая строка = выключено везде (полный откат к и путь срабатывал ~2 раза из 9. Якорь поднимается
прежнему поведению). Провайдер без соответствующего прямым goto на выдачу площадки, Referer карточке даёт
переход с неё. Включать только явным env и только
после замера на узлах пула. Провайдер без соответствующего
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не
делает см. ниже. делает см. ниже.
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поисковый запрос на yandex.ru для BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поисковый запрос на yandex.ru для
@ -143,6 +145,7 @@ import logging
import os import os
import random import random
import re import re
import time
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from typing import NamedTuple from typing import NamedTuple
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
@ -329,12 +332,15 @@ BROWSER_PAGE_INTERVAL_JITTER_S: float = float(
) )
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный # Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются # поиск yandex.ru, а не голым goto. Дефолт — пусто (#3263): капча на выдаче Яндекса —
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env = # свойство выходного узла (замер владельца 29.08: узлы 1/10/11 — капча, 9 и домашний
# выключено везде — полный откат на поведение до #3251. # IP — выдача), в проде путь срабатывал ~2 из 9, а в логах 15-16.09 все 7 капч
# закончились «якорная вкладка не поднялась (Error)» через 0,2 с после отката — якоря
# не было вовсе. Без поиска якорь сразу открывает выдачу площадки, карточка идёт с
# referer=origin — переход внутри площадки. Включать только явным env после замера.
_ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset( _ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset(
p.strip().lower() p.strip().lower()
for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "domclick").split(",") for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "").split(",")
if p.strip() if p.strip()
) )
@ -832,6 +838,12 @@ _last_goto_at: dict[str, float] = {} # provider → loop-time последн
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой # _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет. # протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
_last_response_status: dict[str, int | None] = {} _last_response_status: dict[str, int | None] = {}
# #3419: provider → длительность ЦЕЛЕВОЙ page.goto последнего /fetch в мс (None — до неё не
# дошли: упал прогрев origin, fetch_mode не navigate). Пишется в _fetch_once и на успехе,
# и на исключении goto; читается fetch_handler'ом под тем же локом для строки «fetch error»,
# а «fetch OK» печатает её сам. Нужна, чтобы порог BROWSER_NAV_TIMEOUT_MS сравнивать с
# распределением времени навигации, а не подбирать вслепую.
_last_nav_ms: dict[str, int | None] = {}
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический # #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной # из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
@ -895,10 +907,16 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
"block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK), "block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK),
# Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон. # Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон.
"block_webrtc": True, "block_webrtc": True,
# Форсим таймзону +3 МСК. geoip берёт tz одного IP на старте, а БэкКоннект # Таймзону НЕ задаём (#3187): её ставит geoip=True по exit-IP на запуске —
# ротирует IP на каждый запрос (видели Ульяновск UTC+4 при выходе СПб/МСК) → # из той же GeoIP-записи, что и координаты geolocation. Жёсткий
# геолик tz↔IP. Жёсткий Europe/Moscow убирает рассинхрон для РФ-мобильного. # Europe/Moscow в config (было) camoufox 0.5.5 применял поверх geoip
"config": {"timezone": "Europe/Moscow"}, # (timezone — setdefault), а координаты брал из IP: для узлов вне UTC+3
# браузер заявлял МСК при новосибирских координатах и адресе (узлы 13/15 —
# T2 NSK, UTC+7; 14 — МегаФон Самара, UTC+4 по RIPE, 17.09.2026) и писал
# LeakWarning «Please use the geoip parameter» на каждом запуске. Ротация
# узла пула меняет IP внутри той же подсети оператора (health-логи 48 ч:
# 176.59.137.x/176.59.144.x у 15, 178.176.78-79.x у 14), а смена узла
# релончит браузер (_launched_proxy) — зона, взятая на запуске, не протухает.
} }
if proxy is not None: if proxy is not None:
kwargs["proxy"] = proxy kwargs["proxy"] = proxy
@ -1454,8 +1472,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
status = _last_response_status.get(provider) status = _last_response_status.get(provider)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s", "tradein-browser[%s]: fetch error nav_ms=%s url=%r: %s: %s",
provider, provider,
_last_nav_ms.get(provider),
url, url,
type(exc).__name__, type(exc).__name__,
exc, exc,
@ -2349,6 +2368,7 @@ async def _fetch_once(
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен # Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
# уехать статус предыдущей страницы этого же провайдера (#3196). # уехать статус предыдущей страницы этого же провайдера (#3196).
_last_response_status[provider] = None _last_response_status[provider] = None
_last_nav_ms[provider] = None
if reset_context: if reset_context:
await _close_reusable_context(provider) await _close_reusable_context(provider)
@ -2423,7 +2443,11 @@ async def _fetch_once(
} }
if referer: if referer:
goto_kwargs["referer"] = referer goto_kwargs["referer"] = referer
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined] nav_started = time.monotonic()
try:
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
finally:
_last_nav_ms[provider] = int((time.monotonic() - nav_started) * 1000)
_last_response_status[provider] = _status_of(response) _last_response_status[provider] = _status_of(response)
if BROWSER_WAIT_MS > 0: if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
@ -2510,9 +2534,12 @@ async def _fetch_once(
await page.close() # type: ignore[attr-defined] await page.close() # type: ignore[attr-defined]
_page_counters[provider] = _page_counters.get(provider, 0) + 1 _page_counters[provider] = _page_counters.get(provider, 0) + 1
logger.debug( # INFO, а не DEBUG (#3419): без строк успеха распределение времени навигации не
"tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d", # снять, а сравнивать его с таймаутами и есть цель.
logger.info(
"tradein-browser[%s]: fetch OK nav_ms=%s url=%r pages_since_launch=%d",
provider, provider,
_last_nav_ms.get(provider),
url, url,
_page_counters[provider], _page_counters[provider],
) )
@ -2811,12 +2838,16 @@ async def login_handler(request: web.Request) -> web.Response:
return web.json_response({"error": f"missing required fields: {missing}"}, status=400) return web.json_response({"error": f"missing required fields: {missing}"}, status=400)
provider = _resolve_provider(body, body["url"]) provider = _resolve_provider(body, body["url"])
# #3410: прокси из пула в теле — как у /fetch. Без него логин шёл только с env-узла
# сайдкара (на проде — выключенный узел, InvalidIP), и ручка восстановления сессии
# не работала при живом пуле. relaunch при смене прокси — внутри _ensure_browser.
proxy_override = _resolve_proxy_override(body, provider)
# #2616 шаг 1: см. fetch_handler — прод + нет env-прокси (login override не берёт) → # #2616 шаг 1: см. fetch_handler — прод + нет НИ override, НИ env-прокси →
# отказ, не direct-IP. # отказ, не direct-IP.
if IS_PROD and _no_live_proxy(provider, None): if IS_PROD and _no_live_proxy(provider, proxy_override):
logger.warning( logger.warning(
"tradein-browser[%s]: /login отказ — нет env-прокси (prod) — " "tradein-browser[%s]: /login отказ — нет прокси в теле и нет env-прокси (prod) — "
"не подключаемся напрямую с IP сервера (#2616)", "не подключаемся напрямую с IP сервера (#2616)",
provider, provider,
) )
@ -2828,7 +2859,7 @@ async def login_handler(request: web.Request) -> web.Response:
async with lock: async with lock:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал). # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка, иначе 503 — без прокси не логинимся. # Lazy-попытка, иначе 503 — без прокси не логинимся.
if not await _ensure_browser(provider): if not await _ensure_browser(provider, proxy_override=proxy_override):
logger.warning( logger.warning(
"tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)", "tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)",
provider, provider,

View file

@ -34,9 +34,12 @@ _HOSTNAME = "ekaterinburg.domclick.ru"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_state() -> None: def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
server._contexts = {} server._contexts = {}
server._anchor_pages = {} server._anchor_pages = {}
# Путь через поиск выключен по умолчанию (#3263) — тесты ниже проверяют его
# поведение при ЯВНОМ включении env'ом BROWSER_ANCHOR_VIA_SEARCH=domclick.
monkeypatch.setattr(server, "_ANCHOR_VIA_SEARCH_PROVIDERS", frozenset({"domclick"}))
class _FakeLink: class _FakeLink:
@ -158,7 +161,7 @@ def _run_ensure_anchor(provider: str, origin: str, page: _FakeAnchorPage) -> boo
def test_anchor_via_search_visits_yandex_and_clicks_result() -> None: def test_anchor_via_search_visits_yandex_and_clicks_result() -> None:
"""domclick включён по умолчанию: заход на yandex.ru → клик по ссылке на хост.""" """domclick включён env'ом: заход на yandex.ru → клик по ссылке на хост."""
page = _FakeAnchorPage(link_host=_HOSTNAME) page = _FakeAnchorPage(link_host=_HOSTNAME)
anchored = _run_ensure_anchor("domclick", _ORIGIN, page) anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
@ -202,7 +205,7 @@ def test_no_link_in_serp_falls_back_without_referer() -> None:
def test_provider_not_in_allowlist_skips_search_entirely() -> None: def test_provider_not_in_allowlist_skips_search_entirely() -> None:
"""avito не в BROWSER_ANCHOR_VIA_SEARCH (дефолт только domclick) — поведение прежнее.""" """avito не в BROWSER_ANCHOR_VIA_SEARCH (включён только domclick) — поведение прежнее."""
origin = "https://www.avito.ru/ekaterinburg/kvartiry" origin = "https://www.avito.ru/ekaterinburg/kvartiry"
page = _FakeAnchorPage(link_host="www.avito.ru") page = _FakeAnchorPage(link_host="www.avito.ru")
@ -255,3 +258,28 @@ def test_new_tab_wrong_host_falls_back_without_referer() -> None:
assert origin_calls[0]["referer"] is None assert origin_calls[0]["referer"] is None
assert page.opened_popup is not None assert page.opened_popup is not None
assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт" assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт"
def test_default_env_domclick_anchor_goes_straight_to_origin(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""#3263: без env якорь Домклика открывает свою выдачу прямым goto, yandex.ru не трогает.
Модуль грузится заново с удалённым env проверяется именно код-дефолт, а не
значение, подставленное фикстурой. Капча на выдаче Яндекса включена в подделке:
если путь через поиск снова станет дефолтом, goto на yandex.ru попадёт в
goto_calls и тест покраснеет.
"""
monkeypatch.delenv("BROWSER_ANCHOR_VIA_SEARCH", raising=False)
spec = importlib.util.spec_from_file_location("tradein_browser_server_anchor_default", _SERVER_PATH)
assert spec is not None and spec.loader is not None
fresh = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fresh)
page = _FakeAnchorPage(link_host=_HOSTNAME, captcha=True)
fresh._contexts["domclick"] = _FakeAnchorContext(page)
anchored = asyncio.run(fresh._ensure_anchor_page("domclick", _ORIGIN))
assert anchored is True
assert fresh._anchor_pages["domclick"] is page
assert page.goto_calls == [{"url": _ORIGIN, "referer": None}]

View file

@ -0,0 +1,156 @@
"""test_server_nav_timing.py — длительность целевой навигации в логах /fetch (#3419).
Порог BROWSER_NAV_TIMEOUT_MS=60000 не с чем было сравнить: «fetch OK» писался на DEBUG
и без длительности, «fetch error» тоже без неё. Теперь обе строки несут nav_ms
время ЦЕЛЕВОЙ page.goto (без прогрева origin, пейсинга и BROWSER_WAIT_MS).
Часы подменяются только в модуле сервера (server.time), event loop их не видит.
camoufox НЕ запускается: browser/page поддельные.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_nav_timing.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import logging
import re
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from aiohttp.test_utils import make_mocked_request
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server_nav_timing", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
_ORIGIN = "https://www.cian.ru/"
_CARD = "https://ekb.cian.ru/sale/flat/1/"
class _Clock:
def __init__(self) -> None:
self.now = 1000.0
def monotonic(self) -> float:
return self.now
class _Page:
"""goto двигает поддельные часы на заданное время; на origin или по флагу — падает."""
def __init__(self, clock: _Clock, nav_s: float, *, fail_target: bool, fail_origin: bool):
self._clock = clock
self._nav_s = nav_s
self._fail_target = fail_target
self._fail_origin = fail_origin
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
if url == _ORIGIN:
if self._fail_origin:
raise TimeoutError("origin goto timeout")
return None
self._clock.now += self._nav_s
if self._fail_target:
raise TimeoutError("Page.goto: Timeout 60000ms exceeded.")
return None
async def wait_for_timeout(self, ms: int) -> None:
return None
async def content(self) -> str:
return "<html><body>карточка</body></html>"
async def close(self) -> None:
return None
class _Browser:
def __init__(self, page: _Page) -> None:
self._page = page
async def new_page(self) -> _Page:
return self._page
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> _Clock:
for name in ("_browsers", "_page_counters", "_locks", "_last_goto_at",
"_last_response_status", "_last_nav_ms", "_launched_proxy"):
monkeypatch.setattr(server, name, {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
monkeypatch.setattr(server, "IS_PROD", False)
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
monkeypatch.setattr(server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000))
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
clock = _Clock()
monkeypatch.setattr(server, "time", SimpleNamespace(monotonic=clock.monotonic))
return clock
async def _coro(value: Any) -> Any:
return value
def _fetch(page: _Page, body: dict[str, Any]) -> int:
server._browsers["cian"] = _Browser(page)
request = make_mocked_request("POST", "/fetch")
request.json = lambda: _coro(body) # type: ignore[method-assign]
return asyncio.run(server.fetch_handler(request)).status
def _nav_ms(caplog: pytest.LogCaptureFixture, marker: str) -> list[int | None]:
"""nav_ms из строк лога с маркером, по порядку: число или None."""
values: list[int | None] = []
for record in caplog.records:
message = record.getMessage()
if marker in message:
match = re.search(r"nav_ms=(\d+|None)\b", message)
assert match is not None, f"в строке нет nav_ms: {message}"
values.append(None if match.group(1) == "None" else int(match.group(1)))
return values
def test_success_logs_target_navigation_ms_at_info(
_reset_state: _Clock, caplog: pytest.LogCaptureFixture
) -> None:
page = _Page(_reset_state, 12.345, fail_target=False, fail_origin=False)
with caplog.at_level(logging.INFO, logger=server.logger.name):
status = _fetch(page, {"url": _CARD, "origin": _ORIGIN})
assert status == 200
assert _nav_ms(caplog, "[cian]: fetch OK") == [12345]
def test_timeout_logs_navigation_ms_then_prenav_failure_logs_none(
_reset_state: _Clock, caplog: pytest.LogCaptureFixture
) -> None:
"""Таймаут цели несёт своё время; следующий отказ ДО цели не наследует прошлое число."""
with caplog.at_level(logging.INFO, logger=server.logger.name):
first = _fetch(
_Page(_reset_state, 60.0007, fail_target=True, fail_origin=False),
{"url": _CARD, "origin": _ORIGIN},
)
second = _fetch(
_Page(_reset_state, 5.0, fail_target=False, fail_origin=True),
{"url": _CARD, "origin": _ORIGIN},
)
assert (first, second) == (500, 500)
assert _nav_ms(caplog, "[cian]: fetch error") == [60000, None]

View file

@ -267,6 +267,52 @@ def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.Mo
assert calls == [("avito", "http://pool:8080")] assert calls == [("avito", "http://pool:8080")]
_LOGIN_BODY: dict[str, Any] = {
"url": "https://www.cian.ru/login",
"email": "a@b.ru",
"password": "x",
"email_selector": "#e",
"password_selector": "#p",
"submit_selector": "#s",
"success_cookie": "sid",
}
@pytest.mark.parametrize(
("extra", "expected_override"),
[({"proxy": "http://pool:8080", "proxy_kind": "http"}, "http://pool:8080"), ({}, None)],
)
def test_login_handler_prod_uses_body_proxy(
monkeypatch: pytest.MonkeyPatch, extra: dict[str, Any], expected_override: str | None
) -> None:
"""#3410: /login берёт прокси из тела, как /fetch; без него — прежний env-путь.
С прокси в теле и БЕЗ env-прокси прод-guard не отказывает (узел пула есть), а
браузер поднимается ровно с этим узлом. Без прокси в теле, но с env override None.
"""
monkeypatch.setattr(server, "IS_PROD", True)
if not extra:
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
calls: list[tuple[str, str | None]] = []
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
calls.append((provider, proxy_override))
return True
async def _fake_do_login(provider: str, params: dict[str, Any]) -> list[dict[str, Any]]:
return [{"name": "sid", "value": "1"}]
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_login", _fake_do_login)
response = asyncio.run(
server.login_handler(_make_request("/login", {**_LOGIN_BODY, **extra}))
)
assert response.status == 200
assert calls == [("cian", expected_override)]
# ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ──────── # ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ────────

View file

@ -149,3 +149,46 @@ def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch
asyncio.run(_run()) asyncio.run(_run())
assert calls == [("avito", None), ("avito", "http://pool:8080")] assert calls == [("avito", None), ("avito", "http://pool:8080")]
# ── #3187: гео-параметры запуска camoufox ────────────────────────────────────────
def test_launch_leaves_timezone_to_geoip(monkeypatch: pytest.MonkeyPatch) -> None:
"""Зона и координаты — только от geoip по exit-IP, вручную в config не задаются.
camoufox 0.5.5 (utils.launch_options): geoip кладёт в config координаты
безусловно, а timezone через setdefault, то есть ручной config["timezone"]
перебивает зону из IP, и координаты с зоной расходятся (узел в Новосибирске
с заявленной МСК). Ключи 'timezone' и 'geolocation:*' в config ровно те,
на которые библиотека пишет LeakWarning (warn_manual_config).
"""
import sys
import types
captured: dict[str, object] = {}
class _FakeCamoufox:
def __init__(self, **kwargs: object) -> None:
captured.update(kwargs)
async def __aenter__(self) -> object:
return object()
fake_mod = types.ModuleType("camoufox.async_api")
fake_mod.AsyncCamoufox = _FakeCamoufox # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
asyncio.run(server._launch_browser("avito", proxy_override="http://u:p@176.59.137.239:8080"))
assert captured["geoip"] is True
assert captured["proxy"] == {
"server": "http://176.59.137.239:8080",
"username": "u",
"password": "p",
}
config = captured.get("config", {})
assert isinstance(config, dict)
manual_geo = sorted(k for k in config if k == "timezone" or k.startswith("geolocation:"))
assert manual_geo == []

View file

@ -340,6 +340,11 @@ services:
# деградация. У scraper те же значения с #2126/#2160. # деградация. У scraper те же значения с #2126/#2160.
USE_PROXY_POOL_CURL: "true" USE_PROXY_POOL_CURL: "true"
USE_PROXY_POOL_BROWSER: "true" USE_PROXY_POOL_BROWSER: "true"
# #3182: ручка админки scrape_avito_detail_backfill исполняет добор В ЭТОМ
# процессе. Хостовый backend/.env.runtime задаёт true (curl не проходит QRATOR
# PoW, см. scraper ниже) — environment перекрывает env_file. Значение одно на
# все сервисы образа backend, чтобы режим не зависел от того, кто взял задачу.
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
depends_on: depends_on:
browser: browser:
condition: service_started condition: service_started
@ -453,6 +458,8 @@ services:
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth, # Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
# аналогично backend). tgbot_main.py не должен зависеть от этого значения. # аналогично backend). tgbot_main.py не должен зависеть от этого значения.
SCHEDULER_ENABLE: "false" SCHEDULER_ENABLE: "false"
# #3182: то же значение, что у backend/scraper (см. комментарий у backend).
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View file

@ -207,7 +207,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
// с числами обязан стоять размер выборки, иначе коридор читается как опора // с числами обязан стоять размер выборки, иначе коридор читается как опора
// расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по // расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по
// объявлениям (та же причина, что у analogsCaption ниже) → подпись другая. // объявлениям (та же причина, что у analogsCaption ниже) → подпись другая.
const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0); const dkpAdvisory = corridorAdvisoryNote(
dkp,
estimate.n_analogs === 0,
estimate.analog_tier,
);
// Progressive enrichment state // Progressive enrichment state
const needsHouseType = estimate.house_type === null; const needsHouseType = estimate.house_type === null;

View file

@ -0,0 +1,53 @@
/**
* #3466 подпись «цена по дому, коридор её не ограничивает» на корневой /trade-in.
*
* corridorAdvisoryNote проверена сама по себе и через v2 mapResultPanel
* (v2/__tests__/corridorAdvisoryNote.test.ts). Вызов в v1 HeroSummary отдельный:
* без аргумента estimate.analog_tier функция молча вернёт null при n порога,
* и vitest этого не видел (ревью #3554, мутация F2). Здесь проверяется то, что
* реально стоит на экране hero.
*/
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { HeroSummary } from "../HeroSummary";
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture";
import type { AggregatedEstimate, AnalogTier } from "@/types/trade-in";
// Как в thinMarketBadge.test.tsx: бренд и блок прозрачности ходят в сеть.
vi.mock("@/lib/useBrand", () => ({
useActiveBrandSlug: () => null,
useBrand: () => ({ data: null }),
}));
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
const SAME_BUILDING =
"справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает";
function heroText(analog_tier: AnalogTier | null): string {
const estimate: AggregatedEstimate = {
...FIXTURE_ESTIMATE,
analog_tier,
// n=20 ≥ порога: advisory_only=false, подпись может дать только тир.
dkp_corridor: { ...FIXTURE_ESTIMATE.dkp_corridor!, count: 20, advisory_only: false },
};
const { container } = render(
<HeroSummary estimate={estimate} input={FIXTURE_INPUT} onResubmit={() => {}} />,
);
return container.textContent ?? "";
}
describe("#3466 подпись коридора в v1 HeroSummary", () => {
it("same_building + n=20 → подпись про дом стоит в hero", () => {
const text = heroText("same_building");
// Коридор отрисован (20 ДКП) и рядом — подпись, а не просто число.
expect(text).toContain("20 сд.");
expect(text).toContain(SAME_BUILDING);
});
it("micro_radius + n=20 → подписи нет: коридор цену держит", () => {
const text = heroText("micro_radius");
expect(text).toContain("20 сд.");
expect(text).not.toContain("коридор её не ограничивает");
});
});

View file

@ -0,0 +1,113 @@
// #3452 / #3466 — подпись под коридором ДКП: когда он цену НЕ держит, экран
// обязан это сказать, а молчание — значить только «держит».
//
// Две разные причины выключенной страховки:
// * сделок меньше порога (advisory_only, бэкенд считает порог сам) — говорим
// про РАЗМЕР выборки: гейт Tier C держит цену и по пяти сделкам;
// * цена посчитана по аналогам того же дома (analog_tier "same_building") —
// кламп этот тир не трогает при любом count, ревьюер #3462 воспроизвёл
// n=20, headline 202 100 против потолка 140 000 и пустую подпись.
//
// Образец — dealsAsOf.test.ts: проверяется и сама функция, и то, что подпись
// доезжает до плитки v2 через mapResultPanel.
import { describe, expect, it } from "vitest";
import { mapResultPanel } from "../mappers";
import { corridorAdvisoryNote } from "@/lib/rosreestr";
import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
import type { AggregatedEstimate, DkpCorridor } from "@/types/trade-in";
const SAME_BUILDING =
"справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает";
function corridor(count: number, advisory_only?: boolean): DkpCorridor {
const c: DkpCorridor = {
count,
low_ppm2: 80_000,
median_ppm2: 90_000,
high_ppm2: 100_000,
period_months: 12,
latest_deal_date: "2026-01-01",
};
if (advisory_only !== undefined) c.advisory_only = advisory_only;
return c;
}
describe("corridorAdvisoryNote", () => {
it("мало сделок — подпись про размер выборки", () => {
expect(corridorAdvisoryNote(corridor(5, true), false)).toBe(
"справочно: сделок мало (5) — коридор ориентировочный",
);
});
it("мало сделок и цена из них же — подпись прямее", () => {
expect(corridorAdvisoryNote(corridor(5, true), true)).toBe(
"оценка построена на этих сделках — их всего 5",
);
});
it("сделок достаточно, радиусный путь — молчим: коридор цену держит", () => {
expect(corridorAdvisoryNote(corridor(20, false), false, "district")).toBeNull();
expect(corridorAdvisoryNote(corridor(20, false), false, "micro_radius")).toBeNull();
});
it("#3466: сделок много, но цена по дому — подпись есть", () => {
// Красный на origin/main: advisory_only=false → null, при том что кламп
// Tier A exempt и коридор цену не тронул.
expect(corridorAdvisoryNote(corridor(20, false), false, "same_building")).toBe(
SAME_BUILDING,
);
});
it("#3466: дом и мало сделок — говорим про дом, это причина сильнее", () => {
expect(corridorAdvisoryNote(corridor(5, true), false, "same_building")).toBe(
SAME_BUILDING,
);
});
it("оценка старше деплоя (полей нет) — молчим, как раньше", () => {
expect(corridorAdvisoryNote(corridor(20), false)).toBeNull();
expect(corridorAdvisoryNote(corridor(20), false, null)).toBeNull();
});
it("коридора нет — подписи нет и у дома", () => {
expect(corridorAdvisoryNote(null, false, "same_building")).toBeNull();
expect(corridorAdvisoryNote(undefined, false)).toBeNull();
});
});
/** Подпись плитки «ДКП · РОСРЕЕСТР» — ветка коридора (street-deals нет). */
function dkpNote(over: Partial<AggregatedEstimate>): string | undefined {
const e: AggregatedEstimate = {
...FIXTURE_ESTIMATE,
actual_deals: [],
area_m2: 60,
...over,
};
const card = mapResultPanel(e, null).cards.find((c) =>
c.title.join(" ").includes("ДКП"),
);
if (!card) throw new Error("плитка ДКП пропала из панели результата");
return card.note;
}
describe("#3466 подпись доезжает до плитки v2", () => {
it("same_building + n=20 → подпись про дом", () => {
expect(
dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "same_building" }),
).toBe(SAME_BUILDING);
});
it("district + n=20 → без подписи", () => {
expect(
dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "district" }),
).toBeUndefined();
});
it("district + n=9 → подпись про выборку", () => {
expect(
dkpNote({ dkp_corridor: corridor(9, true), analog_tier: "district" }),
).toBe("справочно: сделок мало (9) — коридор ориентировочный");
});
});

View file

@ -696,7 +696,7 @@ function resolveDealTier(
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"), asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
// n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice // n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice
// ниже по файлу разбирает тот же случай) — подпись тогда прямее. // ниже по файлу разбирает тот же случай) — подпись тогда прямее.
advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0), advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0, e.analog_tier),
}; };
} }
if (e.actual_deals.length > 0) { if (e.actual_deals.length > 0) {

View file

@ -1,4 +1,4 @@
import type { DkpCorridor } from "@/types/trade-in"; import type { AnalogTier, DkpCorridor } from "@/types/trade-in";
export const ROSREESTR_REQUEST_URL = export const ROSREESTR_REQUEST_URL =
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/"; "https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
@ -53,13 +53,23 @@ export function dealsAsOfLabel(
* min_n коридор из пяти сделок там способен уронить headline на треть. * min_n коридор из пяти сделок там способен уронить headline на треть.
* Утверждение про размер выборки истинно во всех достижимых состояниях. * Утверждение про размер выборки истинно во всех достижимых состояниях.
* *
* #3466: сделок может быть и много, а коридор цену всё равно не держит когда
* она посчитана по аналогам того же дома (analog_tier "same_building"): кламп
* этот тир не трогает, radius-floor тоже. Здесь утверждение про алгоритм уже
* истинно при любом count (гейт Tier C к этому тиру не относится) его и говорим.
*
* null (флага нет оценка старше деплоя, либо сделок хватает) подписи нет. * null (флага нет оценка старше деплоя, либо сделок хватает) подписи нет.
*/ */
export function corridorAdvisoryNote( export function corridorAdvisoryNote(
dkp: DkpCorridor | null | undefined, dkp: DkpCorridor | null | undefined,
headlineFromDeals: boolean, headlineFromDeals: boolean,
analogTier?: AnalogTier | null,
): string | null { ): string | null {
if (dkp?.advisory_only !== true) return null; if (!dkp) return null;
if (analogTier === "same_building") {
return "справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает";
}
if (dkp.advisory_only !== true) return null;
// headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 — // headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 —
// похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и // похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и
// есть цена, и клиент обязан знать, на скольких сделках она стоит. // есть цена, и клиент обязан знать, на скольких сделках она стоит.

View file

@ -170,7 +170,8 @@ export interface DkpCorridor {
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам, // #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
// estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ // estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего // поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
// коридора не про это — при count >= порога он в цену как раз идёт. // коридора не про это — при count >= порога он в цену как раз идёт, кроме
// analog_tier === "same_building" (#3466: якорь того же дома, кламп exempt).
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат. // optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
advisory_only?: boolean; advisory_only?: boolean;
} }

View file

@ -587,9 +587,18 @@ def save_listings(
price_rub = EXCLUDED.price_rub, price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2, price_per_m2 = EXCLUDED.price_per_m2,
-- Cian-specific: обновляем при каждом re-scrape -- Cian-specific: обновляем при каждом re-scrape
living_area_m2 = EXCLUDED.living_area_m2, -- #3252: жилая площадь и балконы — COALESCE по той же причине, что
-- #3063 ниже: их добывает detail (avito, domklik), а SERP этих
-- источников отдаёт NULL. Прод 17.09: у domklik, переобойдённых
-- после обогащения, living_area_m2 = 0 из 5217 (без переобхода
-- 2990 из 4058), у avito 1 из 8639 (4122 из 6505).
living_area_m2 = COALESCE(
EXCLUDED.living_area_m2, listings.living_area_m2
),
bedrooms_count = EXCLUDED.bedrooms_count, bedrooms_count = EXCLUDED.bedrooms_count,
balconies_count = EXCLUDED.balconies_count, balconies_count = COALESCE(
EXCLUDED.balconies_count, listings.balconies_count
),
loggias_count = EXCLUDED.loggias_count, loggias_count = EXCLUDED.loggias_count,
description_minhash = EXCLUDED.description_minhash, description_minhash = EXCLUDED.description_minhash,
cadastral_number = EXCLUDED.cadastral_number, cadastral_number = EXCLUDED.cadastral_number,
@ -742,8 +751,11 @@ def save_listings(
listings.price_previous_rub, listings.newbuilding_id, listings.price_previous_rub, listings.newbuilding_id,
listings.newbuilding_url, listings.card_hash, listings.is_active listings.newbuilding_url, listings.card_hash, listings.is_active
) IS DISTINCT FROM ( ) IS DISTINCT FROM (
EXCLUDED.price_rub, EXCLUDED.price_per_m2, EXCLUDED.living_area_m2, EXCLUDED.price_rub, EXCLUDED.price_per_m2,
EXCLUDED.bedrooms_count, EXCLUDED.balconies_count, EXCLUDED.loggias_count, COALESCE(EXCLUDED.living_area_m2, listings.living_area_m2),
EXCLUDED.bedrooms_count,
COALESCE(EXCLUDED.balconies_count, listings.balconies_count),
EXCLUDED.loggias_count,
EXCLUDED.description_minhash, EXCLUDED.cadastral_number, EXCLUDED.description_minhash, EXCLUDED.cadastral_number,
EXCLUDED.building_cadastral_number, EXCLUDED.building_cadastral_number,
-- #3063: те же COALESCE, что в SET выше. Правая часть гейта ОБЯЗАНА -- #3063: те же COALESCE, что в SET выше. Правая часть гейта ОБЯЗАНА
@ -823,9 +835,9 @@ def save_listings(
is_active = true, is_active = true,
price_rub = :price_rub, price_rub = :price_rub,
price_per_m2 = :ppm2, price_per_m2 = :ppm2,
living_area_m2 = :living_area_m2, living_area_m2 = COALESCE(:living_area_m2, living_area_m2),
bedrooms_count = :bedrooms_count, bedrooms_count = :bedrooms_count,
balconies_count = :balconies_count, balconies_count = COALESCE(:balconies_count, balconies_count),
loggias_count = :loggias_count, loggias_count = :loggias_count,
description_minhash = :description_minhash, description_minhash = :description_minhash,
cadastral_number = :cadastral_number, cadastral_number = :cadastral_number,
@ -940,7 +952,7 @@ def save_listings(
skip_snapshot = today_row is not None skip_snapshot = today_row is not None
if skip_snapshot: if skip_snapshot:
logger.debug( logger.debug(
"save_listings:snapshot_skipped (card unchanged) " "source=%s listing_id=%s", "save_listings:snapshot_skipped (card unchanged) source=%s listing_id=%s",
lot.source, lot.source,
listing_id, listing_id,
) )

View file

@ -656,6 +656,13 @@ class BrowserFetcher:
} }
if wait_ms is not None: if wait_ms is not None:
body["wait_ms"] = wait_ms body["wait_ms"] = wait_ms
# #3410: узел аренды — в тело /login, как в _post_fetch. Без него сайдкар логинился
# с env-узла при любой аренде.
proxy_url, proxy_kind = self._current_proxy()
if proxy_url:
body["proxy"] = proxy_url
if proxy_kind:
body["proxy_kind"] = proxy_kind
try: try:
return await self._post_login(body) return await self._post_login(body)

View file

@ -36,7 +36,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import random import random
from collections.abc import Callable from collections.abc import Callable, Mapping
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from dataclasses import dataclass, field, fields from dataclasses import dataclass, field, fields
from datetime import date, timedelta from datetime import date, timedelta
@ -1346,6 +1346,43 @@ async def run_avito_pipeline(
await browser_fetcher.__aexit__(None, None, None) await browser_fetcher.__aexit__(None, None, None)
def _bucket_phase_totally_failed(before: Mapping[str, int], after: Mapping[str, int]) -> str | None:
"""Фаза бакета, где отказала КАЖДАЯ попытка (#3415). Имя фазы или None.
Считает ПРИРОСТ счётчиков за один бакет (якорь): counters у свипа run-level,
так что «фаза этого якоря» существует только как разница до/после.
Прод-факт, ради которого гейт заведён: `cian_city_sweep` 6179 (06.09)
`houses_attempted=30, houses_failed=30, houses_enriched=0`, статус `failed` по
run-level правилу (`_phase_totally_failed`, #2700), а в `done_buckets` записаны
все пять якорей. Прогон 6276 (07.09) унаследовал точку, пропустил все якоря и
отчитался `done` с `houses_attempted=0` зелёный по построению, потому что фаза
не исполнялась. Фикс #3396 (houses через пул прокси) простоял на проде сутки, ни
разу не отработав.
Пары ищутся В САМИХ counters (ключ `X_attempted` со спутником `X_failed`) тот же
приём и та же причина, что у `runs._phase_totally_failed`: зашитый список фаз это
ровно то место, куда забывают дописать новую. Фазы без счётчика попыток гейт НЕ
видит у avito houses роль attempted играет `unique_houses` (нет `houses_attempted`),
и притягивать её сюда значило бы блокировать отметку якоря из-за ОДНОГО упавшего
дома из десяти.
Порог одна попытка, а не три, как у run-level правила: тот ставит прогону
диагноз и обязан отсеивать шум, а этот решает «собирать ли якорь заново». Цена
ошибок несимметрична: лишний повтор якоря стоит нескольких запросов, ложная
отметка «пройден» теряет часть города навсегда и молча (прогон-то `done`).
"""
for key in sorted(after):
if not key.endswith("_attempted"):
continue
phase = key[: -len("_attempted")]
attempted = after[key] - before.get(key, 0)
failed = after.get(f"{phase}_failed", 0) - before.get(f"{phase}_failed", 0)
if attempted >= 1 and failed >= attempted:
return phase
return None
@dataclass @dataclass
class CitySweepCounters: class CitySweepCounters:
"""Aggregate counters для full city sweep run.""" """Aggregate counters для full city sweep run."""
@ -1629,6 +1666,12 @@ async def run_avito_city_sweep(
# #3074: сбрасывается на КАЖДОЙ итерации — иначе один упавший якорь # #3074: сбрасывается на КАЖДОЙ итерации — иначе один упавший якорь
# заразил бы все последующие, и чекпоинт не пополнялся бы вовсе. # заразил бы все последующие, и чекпоинт не пополнялся бы вовсе.
_anchor_ok = True _anchor_ok = True
# #3415: снимок счётчиков ДО якоря — по разнице видно, дала ли
# каждая фаза ЭТОГО якоря хоть одну удачную попытку (см.
# _bucket_phase_totally_failed). Тот же дефект, что у cian-свипа:
# detail-фаза, отказавшая на всех карточках без исключения, доходила
# до отметки «якорь пройден» наравне с целым якорем.
_before_anchor = counters.to_dict()
# Capture loop variables in default args (B023): prevents stale binding # Capture loop variables in default args (B023): prevents stale binding
# if the coroutine is scheduled after the loop variable changes. # if the coroutine is scheduled after the loop variable changes.
@ -2137,6 +2180,22 @@ async def run_avito_city_sweep(
# его как пройденный значило бы, что следующий прогон его пропустит и # его как пройденный значило бы, что следующий прогон его пропустит и
# объявления оттуда не соберутся НИКОГДА, причём молча: прогон # объявления оттуда не соберутся НИКОГДА, причём молча: прогон
# завершится штатно. Тот же инвариант, что у combo в yandex-свипе. # завершится штатно. Тот же инвариант, что у combo в yandex-свипе.
#
# #3415: флага мало — фаза отказывает и БЕЗ исключения (каждая
# detail-карточка отдала 403, счётчик вырос, корутина завершилась
# штатно). Замер на проде за 45 суток: у avito-свипов такой якорь
# пока не встречался (0 прогонов), у cian — 4; гейт стоит здесь
# потому, что дефект один и тот же, а не по следу в данных.
_failed_phase = _bucket_phase_totally_failed(_before_anchor, counters.to_dict())
if _failed_phase is not None:
logger.warning(
"city-sweep run_id=%d: якорь %s НЕ помечен пройденным — фаза '%s' "
"не дала ни одной удачной попытки; следующий прогон соберёт его заново",
run_id,
name,
_failed_phase,
)
_anchor_ok = False
if _anchor_ok: if _anchor_ok:
_done_anchors.add(name) _done_anchors.add(name)
runs.update_heartbeat(db, run_id, _ckpt()) runs.update_heartbeat(db, run_id, _ckpt())
@ -3313,6 +3372,10 @@ async def run_cian_city_sweep(
) )
# ── Per-anchor phases под watchdog-таймаутом ──────────────────── # ── Per-anchor phases под watchdog-таймаутом ────────────────────
# #3415: counters у свипа run-level — снимок ДО якоря даёт бухгалтерию
# его собственных фаз (разницей), по которой ниже решается, считать ли
# якорь пройденным.
_before_anchor = counters.to_dict()
anchor_lots: list[ScrapedLot] = [] anchor_lots: list[ScrapedLot] = []
_c_lat, _c_lon, _c_name = lat, lon, name _c_lat, _c_lon, _c_name = lat, lon, name
@ -3529,6 +3592,12 @@ async def run_cian_city_sweep(
len(nb_id_list), len(nb_id_list),
exc, exc,
) )
# #3415: попытки считаем ВМЕСТЕ с отказами. Раньше здесь рос
# только `houses_failed`, и пара получалась несуществующей
# (attempted=0, failed=N): и run-level `_phase_totally_failed`
# (#2700), и гейт бакета сверяют `failed` с `attempted`, так что
# полный отказ фазы на этой ветке не видел ни один из них.
counters.houses_attempted += len(nb_id_list)
counters.houses_failed += len(nb_id_list) counters.houses_failed += len(nb_id_list)
try: try:
db.rollback() db.rollback()
@ -3720,7 +3789,28 @@ async def run_cian_city_sweep(
# Записать упавший якорь пройденным значило бы, что следующий прогон # Записать упавший якорь пройденным значило бы, что следующий прогон
# пропустит его навсегда — молча, потому что прогон завершится # пропустит его навсегда — молча, потому что прогон завершится
# штатно, просто часть города не соберётся. # штатно, просто часть города не соберётся.
_done_anchors.add(name) #
# #3415: потока управления мало. Фаза внутри якоря отказывает БЕЗ
# исключения — `fetch_newbuilding` возвращает None, счётчик растёт,
# `_cian_anchor_phases` завершается штатно, — и такой якорь доходил
# сюда наравне с целым. Дальше ровно тот же исход, что и у записанного
# упавшего: следующий прогон пропускает якорь и рапортует `done` с
# нулевой фазой. Форма отметки прежняя (плоский список имён): чекпоинт
# читают ещё три свипа и `scheduler._resume_decision`, а ключ
# `bucket:phase` потребовал бы новых читателей ради того же решения.
_failed_phase = _bucket_phase_totally_failed(_before_anchor, counters.to_dict())
if _failed_phase is None:
_done_anchors.add(name)
else:
logger.warning(
"cian-sweep run_id=%d: якорь %s НЕ помечен пройденным — фаза '%s' "
"не дала ни одной удачной попытки; следующий прогон соберёт его заново",
run_id,
name,
_failed_phase,
)
# Heartbeat пишем в любом случае: без него reap_zombies посчитает живой
# прогон мёртвым, а jsonb-мерж сохранит унаследованную часть точки.
runs.update_heartbeat( runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)} db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)}
) )
@ -4902,10 +4992,13 @@ async def run_domclick_city_sweep(
) )
lots: list[ScrapedLot] = [] lots: list[ScrapedLot] = []
# #2406: собранное сохраняется ОДНИМ save_listings после всех корзин, поэтому
# корзина считается пройденной только если фаза дошла до конца (см. чекпоинт ниже).
_saved = False
async def _domclick_phase() -> None: async def _domclick_phase() -> None:
"""Единственная citywide-фаза: fetch_city + save.""" """Единственная citywide-фаза: fetch_city + save."""
nonlocal lots nonlocal lots, _saved
async with DomClickScraper( async with DomClickScraper(
config, config,
proxy_provider=proxy_provider, proxy_provider=proxy_provider,
@ -4951,6 +5044,7 @@ async def run_domclick_city_sweep(
) )
counters.lots_inserted += inserted counters.lots_inserted += inserted
counters.lots_updated += updated counters.lots_updated += updated
_saved = True
try: try:
await asyncio.wait_for(_domclick_phase(), timeout=_sweep_timeout) await asyncio.wait_for(_domclick_phase(), timeout=_sweep_timeout)
@ -5004,7 +5098,12 @@ async def run_domclick_city_sweep(
# #3118: чекпоинт = унаследованное завершённое в этом прогоне. # #3118: чекпоинт = унаследованное завершённое в этом прогоне.
# Пишем heartbeat'ом СЕЙЧАС (мерж jsonb) — финализаторы ключ не # Пишем heartbeat'ом СЕЙЧАС (мерж jsonb) — финализаторы ключ не
# затирают, и оборванный болезнью финализации прогон его не теряет. # затирают, и оборванный болезнью финализации прогон его не теряет.
_checkpoint = sorted(skip_buckets | set(_s.completed_buckets)) # #2406: только если лоты сохранены. Снятая watchdog'ом или упавшая фаза
# не дошла до save_listings — у её корзин в БД ноль строк, а отметка
# «пройдена» заставила бы следующий прогон пропустить их навсегда
# (механизм разобран в миграции 308, из-за него выключены свипы 77/50).
if _saved:
_checkpoint = sorted(skip_buckets | set(_s.completed_buckets))
runs.update_heartbeat(db, run_id, _payload()) runs.update_heartbeat(db, run_id, _payload())
counters.bucket_start_index = _s.bucket_start_index counters.bucket_start_index = _s.bucket_start_index

View file

@ -46,6 +46,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from scraper_kit.orchestration.run_context import current_run_id from scraper_kit.orchestration.run_context import current_run_id
from scraper_kit.proxy_errors import caused_by_no_proxy
try: # sentry опционален — kit не тянет его в зависимостях try: # sentry опционален — kit не тянет его в зависимостях
import sentry_sdk import sentry_sdk
@ -815,11 +816,19 @@ def honors_cancel(source: str) -> bool:
def is_cancelled(db: Session, run_id: int) -> bool: def is_cancelled(db: Session, run_id: int) -> bool:
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline).""" """Проверить status='cancelled' (cooperative cancel в long-running pipeline).
#3480: SELECT открывает транзакцию (autobegin), а вызывающие сразу уходят в сетевую
фазу. Без commit сессия висела 'idle in transaction' весь обход и держала горизонт
vacuum всей БД: прод 17.09, domclick_city_sweep_moskva 7344 транзакция 1.6 ч с
последним запросом ровно этим SELECT; 10 из 12 окон > 1 ч за 14 суток свипы
ДомКлика. Закрываем здесь: проверку отмены зовут перед каждым долгим await.
"""
row = db.execute( row = db.execute(
text("SELECT status FROM scrape_runs WHERE id = :id"), text("SELECT status FROM scrape_runs WHERE id = :id"),
{"id": run_id}, {"id": run_id},
).fetchone() ).fetchone()
db.commit()
return row is not None and row.status == "cancelled" return row is not None and row.status == "cancelled"
@ -1009,6 +1018,37 @@ def mark_banned(
_alert_on_run_id(db, run_id) _alert_on_run_id(db, run_id)
def mark_crashed(db: Session, run_id: int, exc: BaseException) -> None:
"""Финализировать прогон, чья задача вылетела исключением наружу (#1940).
Зовут те, кто запускает пайплайн: планировщик (`scheduler._dispatch`) и ручные
запуски из админки. Исключение, брошенное ДО try-финализатора пайплайна (прод:
пустой пул прокси в `BrowserFetcher.__aenter__` через 10 мс после claim),
оставляло строку 'running' без пульса до zombie-reaper'а через 6 ч.
Строку, которую пайплайн уже финализировал сам (обычный путь: mark_failed и
raise), не трогаем и ПОВТОРНО mark_* не зовём: UPDATE был бы no-op, но mark_*
всё равно позвал бы `_alert_on_run_id` стрик тот же, и на вехе лестницы
(`_streak_alert_due`) в Sentry ушёл бы дубль, плюс WARNING «no-op» на каждом
таком падении. Best-effort: сбой финализации логируется, не бросается.
"""
try:
db.rollback() # сессия упавшей задачи может быть в aborted-транзакции
row = db.execute(
text("SELECT status FROM scrape_runs WHERE id = :id"),
{"id": run_id},
).fetchone()
db.rollback() # чтение не держит транзакцию (#3480)
if row is None or row.status != "running":
return
if caused_by_no_proxy(exc):
mark_banned(db, run_id, f"crashed: {exc}", {}, ban_kind=BAN_KIND_INFRA)
else:
mark_failed(db, run_id, f"crashed: {type(exc).__name__}: {exc}", {})
except Exception:
logger.exception("mark_crashed: не удалось финализировать run_id=%d", run_id)
def _dominant_ban_kind(census: Mapping[str, int]) -> str: def _dominant_ban_kind(census: Mapping[str, int]) -> str:
"""Диагноз по переписи блоков прогона: kind -> сколько раз он встретился (#3178). """Диагноз по переписи блоков прогона: kind -> сколько раз он встретился (#3178).

View file

@ -1024,8 +1024,11 @@ async def _dispatch(
run_db = ctx.session_factory() run_db = ctx.session_factory()
try: try:
await handler.job(run_db, run_id, params, ctx) await handler.job(run_db, run_id, params, ctx)
except Exception: except Exception as exc:
logger.exception("scheduler: %s crashed run_id=%d", handler.log_name, run_id) logger.exception("scheduler: %s crashed run_id=%d", handler.log_name, run_id)
# #1940: исключение мимо try-финализатора хендлера оставляло строку
# 'running' до zombie через 6 ч. См. runs.mark_crashed.
ctx.runs.mark_crashed(run_db, run_id, exc)
finally: finally:
run_db.close() run_db.close()

View file

@ -212,13 +212,10 @@ def build_browser_fetcher(
Служебные ручки `app/api/v1/admin.py` (cian-login, domclick-detail-debug) были Служебные ручки `app/api/v1/admin.py` (cian-login, domclick-detail-debug) были
последними прямыми конструкциями мимо фабрики переведены сюда же (#3197 хвост); последними прямыми конструкциями мимо фабрики переведены сюда же (#3197 хвост);
прямые конструкции в `orchestration/pipeline.py` и в backfill-задачах пул получают прямые конструкции в `orchestration/pipeline.py` и в backfill-задачах пул получают
своими kwargs. Живой `proxy_provider=None` остался ровно один cian-login: сайдкар своими kwargs. cian-login тоже с провайдером (#3410): сайдкар на `/login` берёт proxy
на `/login` proxy-override не берёт (`browser/server.py::login_handler` из тела, `BrowserFetcher.login` кладёт туда узел аренды. `use_pool` при
`_no_live_proxy(provider, None)`, а `_post_login` не кладёт `payload["proxy"]`), так `proxy_provider is None` игнорируется `BrowserFetcher` (`_acquire_lease`:
что аренда там была бы холостой, а на проде при пустом пуле роняла бы ручку `use_pool AND provider is not None`) ни аренды, ни прод-отказа.
восстановления в 502. `use_pool` при `proxy_provider is None` игнорируется
`BrowserFetcher` (`_acquire_lease`: `use_pool AND provider is not None`) ни аренды,
ни прод-отказа, поведение как до фабрики.
`fetch_timeout_s=None` (дефолт) используется дефолт `BrowserFetcher` `fetch_timeout_s=None` (дефолт) используется дефолт `BrowserFetcher`
(120s). Явный таймаут передаёт ровно один call-site `yandex/serp.py` (30s); (120s). Явный таймаут передаёт ровно один call-site `yandex/serp.py` (30s);

View file

@ -34,7 +34,7 @@ from uuid import UUID
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from scraper_kit.providers._base import DEFAULT_IMPERSONATE from scraper_kit.providers._base import DOCUMENT_HEADERS, build_document_session
from scraper_kit.providers._proxy import acurl_proxy_url from scraper_kit.providers._proxy import acurl_proxy_url
from scraper_kit.providers.avito.shared import _unix_to_date from scraper_kit.providers.avito.shared import _unix_to_date
@ -54,19 +54,6 @@ IMV_ENDPOINT = "/web/1/realty-imv/get-data"
# Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR. # Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR.
WARMUP_URL = f"{AVITO_BASE}/evaluation/realty" WARMUP_URL = f"{AVITO_BASE}/evaluation/realty"
# Заголовки document-навигации (warm-up GET) — зеркалят production-набор
# из avito.py (AvitoScraper.__aenter__). Нужны чтобы пройти TLS+header anti-bot.
_DOC_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
# Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют # Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch. # fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
_COMMON_HEADERS = { _COMMON_HEADERS = {
@ -486,8 +473,7 @@ async def evaluate_via_imv(
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси + # камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403 # warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op, # (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
# браузер принадлежит caller'у — backfill открывает один на батч). Этот путь # браузер принадлежит caller'у — backfill открывает один на батч).
# НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке.
# AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up + # AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up +
# geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию; # geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию;
# для browser_fetcher / переданной cffi_session — no-op. Исключение из блока # для browser_fetcher / переданной cffi_session — no-op. Исключение из блока
@ -499,53 +485,24 @@ async def evaluate_via_imv(
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL) cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
_own_session = True _own_session = True
else: else:
# Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети _own_session = False
try: if cffi_session is None:
from curl_cffi.requests import AsyncSession as CffiAsyncSession # Общая document-сессия из _base (#2386): impersonate=DEFAULT_IMPERSONATE TLS +
# DOCUMENT_HEADERS + timeout. Затем warm-up GET для seed anti-bot cookies —
_own_session = False # bare-session XHR Avito банит на server-IP.
if cffi_session is None: # Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162): # scraper_proxy_url. proxy=None → прямое подключение (dev).
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout. # Тесты подменяют конструктор через `scraper_kit.providers._base.AsyncSession`.
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito _env = config.scraper_proxy_url if config is not None else None
# банит на server-IP. # acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env # стоял ДО первого await и держал event loop `/estimate`.
# scraper_proxy_url. proxy=None → прямое подключение (dev). _proxy_url = await _proxy_stack.enter_async_context(
# acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env)
# NB (#2361 F4a): НЕ мигрировано на build_document_session() из )
# scraper_kit.providers._base, хотя headers=_DOC_HEADERS — тот же cffi_session = build_document_session(
# дублированный dict, что и там. _base.py делает module-level proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC
# `from curl_cffi.requests import AsyncSession` (биндится ОДИН раз )
# при первом импорте _base) — tests/scrapers/test_avito_imv_kit_parity.py _own_session = True
# (#2334) патчит `curl_cffi.requests.AsyncSession` через unittest.mock,
# что работает ТОЛЬКО если конструктор вызывается через СВЕЖИЙ lookup
# атрибута на живом module-объекте (как этот локальный import ниже),
# а не через имя, захваченное в чужом module namespace на момент его
# импорта. Мигрировав, ловим 2 real failures
# (test_kit_evaluate_via_imv_{with,without}_config_uses_proxy) — НЕ
# тестовый шум, тест был зелёным на main. Production-поведение
# идентично в обоих вариантах (сеть не отличает откуда взят класс),
# но так как таск прямо запрещает "weaken or skip" parity-тесты —
# оставляю это единственное место немигрированным и репортю как
# finding, а не тихо чиню тест.
_env = config.scraper_proxy_url if config is not None else None
# acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
# стоял ДО первого await и держал event loop `/estimate`.
_proxy_url = await _proxy_stack.enter_async_context(
acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env)
)
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
cffi_session = CffiAsyncSession(
impersonate=DEFAULT_IMPERSONATE,
timeout=_HTTP_TIMEOUT_SEC,
proxies=_proxies,
headers=_DOC_HEADERS,
)
_own_session = True
except ImportError as exc:
raise RuntimeError(
"curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
) from exc
try: try:
if _own_session: if _own_session:
@ -579,7 +536,7 @@ async def _warmup(session: Any) -> None:
всё равно попробуют и дадут типизированную ошибку. всё равно попробуют и дадут типизированную ошибку.
""" """
try: try:
resp = await session.get(WARMUP_URL, headers=_DOC_HEADERS) resp = await session.get(WARMUP_URL, headers=DOCUMENT_HEADERS)
logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code) logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code)
except Exception as exc: except Exception as exc:
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную # warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную

View file

@ -24,7 +24,6 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.cian_state_parser import extract_all_states, extract_state from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
@ -214,7 +213,7 @@ class NewbuildingEnrichment:
async def fetch_newbuilding( async def fetch_newbuilding(
zhk_url: str, zhk_url: str,
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> NewbuildingEnrichment | None: ) -> NewbuildingEnrichment | None:
@ -225,9 +224,8 @@ async def fetch_newbuilding(
Args: Args:
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/' zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
config: ScraperConfig источник browser_http_endpoint для BrowserFetcher config: ScraperConfig источник browser_http_endpoint/use_pool для BrowserFetcher.
(#2322 fix: раньше не принимался вообще, BrowserFetcher(source="cian") Обязателен (#2385): без него фетчер строился с endpoint=None мимо пула.
конструировался без endpoint= TypeError на любом вызове).
session: зарезервирован для обратной совместимости с вызывающими; session: зарезервирован для обратной совместимости с вызывающими;
больше не используется для fetch страницы ЖК. Resolve-функции больше не используется для fetch страницы ЖК. Resolve-функции
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi. (resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
@ -254,14 +252,10 @@ async def fetch_newbuilding(
# Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState' # Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState'
# разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор. # разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор.
# #
# config=None остаётся законным (admin.py debug-роут и # config обязателен (#2385): запасная ветка `config=None` строила
# test_cian_fetch_newbuilding_endpoint_none_without_config зовут без config= и # BrowserFetcher(endpoint=None) мимо фабрики — новый вызывающий без config молча уходил
# ждут graceful endpoint=None, а не AttributeError), поэтому фабрика применяется # на env-узел сайдкара, тот самый путь, что стоил 8 суток выше.
# только когда config есть — она требует ScraperConfig mandatory by design. browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
if config is not None:
browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
else:
browser = BrowserFetcher(source="cian", endpoint=None) # type: ignore[arg-type]
# ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть # ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый).
@ -883,7 +877,7 @@ async def _fetch_zhk_slug(session: AsyncSession, nb_id: int) -> str | None:
async def resolve_cian_zhk_url_via_search( async def resolve_cian_zhk_url_via_search(
nb_id: int, nb_id: int,
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
session: AsyncSession | None = None, session: AsyncSession | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> str | None: ) -> str | None:
@ -928,7 +922,7 @@ async def resolve_cian_zhk_url_via_search(
# Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env # Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
# cian_proxy_url; пусто → прямое подключение (dev/no-op). # cian_proxy_url; пусто → прямое подключение (dev/no-op).
# curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе. # curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе.
_env = config.cian_proxy_url if config is not None else None _env = config.cian_proxy_url
with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url: with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
own_session = build_curl_cffi_session( own_session = build_curl_cffi_session(
proxy_url=_proxy_url, proxy_url=_proxy_url,

View file

@ -27,7 +27,8 @@ _extract_ssr_state делает balanced-brace scan (с пропуском ск
Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27): Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27):
productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo. productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo.
priceHistory ({date ISO8601+tz, price, diff, state}), productCard.egrnData priceHistory ({date ISO8601+tz, price, diff, state}), productCard.egrnData
(snake_case owners_count/collateral/collateral_sber), productCard.legalOptions. (snake_case; area/floor/owners_count обёртка {status, value}, collateral/
collateral_sber bool; перепроверено 17.09, #3252), productCard.legalOptions.
saleType, productCard.viewsCount/callsCount, houseInfo.info.{wallType,floorType}, saleType, productCard.viewsCount/callsCount, houseInfo.info.{wallType,floorType},
и ТОП-УРОВНЕМ pricePrediction (DomClick AVM raw_extra.avm). и ТОП-УРОВНЕМ pricePrediction (DomClick AVM raw_extra.avm).
@ -283,6 +284,29 @@ def _to_int(value: Any) -> int | None:
return None return None
def _pos_float(value: Any) -> float | None:
"""float > 0, иначе None (#3252).
Домклик отдаёт ``livingArea: 0`` / ``kitchenArea: 0`` как «не указано» (живая
карточка 2078257603, 17.09). Без этого в колонку ложился 0.00, а COALESCE в
UPDATE затирал им ранее известную площадь: на проде 303 кухни и 84 жилые
площади = 0 только у domklik и только у обогащённых после 26.08.
"""
f = _to_float(value)
return f if f is not None and f > 0 else None
def _egrn_value(value: Any) -> Any:
"""Узел egrnData → значение (#3252).
Поля ЕГРН приходят обёрткой ``{"status": "success"|"warning"|"error", "value": X}``
(живые карточки 17.09: area, floor, owners_count). ``status`` сверка с тем, что
указал продавец, а ``value`` сам факт из ЕГРН, поэтому берём value при любом
статусе. Скаляр пропускаем как есть.
"""
return value.get("value") if isinstance(value, dict) else value
def _parse_change_time(value: Any) -> datetime | None: def _parse_change_time(value: Any) -> datetime | None:
"""Нормализует дату изменения цены → timezone-aware datetime. """Нормализует дату изменения цены → timezone-aware datetime.
@ -436,8 +460,8 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment:
repair_state = _map_repair_state(repair_type) repair_state = _map_repair_state(repair_type)
# площади # площади
living_area_m2 = _to_float(oi.get("livingArea")) living_area_m2 = _pos_float(oi.get("livingArea"))
kitchen_area_m2 = _to_float(oi.get("kitchenArea")) kitchen_area_m2 = _pos_float(oi.get("kitchenArea"))
# балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None # балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None
balconies = oi.get("balconies") balconies = oi.get("balconies")
@ -454,10 +478,11 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment:
sale_type = (pc.get("legalOptions") or {}).get("saleType") sale_type = (pc.get("legalOptions") or {}).get("saleType")
# owners_count — защищаемся от двух написаний ключа # owners_count — обёртка {status, value} (см. _egrn_value); до #3252 int()
owners_count = _to_int(egrn.get("owners_count")) # от словаря молча давал None: 0 из ~2970 карточек, обогащённых с 29.08.
owners_count = _to_int(_egrn_value(egrn.get("owners_count")))
if owners_count is None: if owners_count is None:
owners_count = _to_int(egrn.get("ownersCount")) owners_count = _to_int(_egrn_value(egrn.get("ownersCount")))
# encumbrances_clean (ИНВЕРСИЯ): collateral/collateral_sber обозначают # encumbrances_clean (ИНВЕРСИЯ): collateral/collateral_sber обозначают
# НАЛИЧИЕ обременения. Любой truthy → clean=False; оба отсутствуют/false # НАЛИЧИЕ обременения. Любой truthy → clean=False; оба отсутствуют/false
@ -523,8 +548,10 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment:
# raw_extra → merge в listings.raw_payload. wall/floor type ОСТАЮТСЯ тут, # raw_extra → merge в listings.raw_payload. wall/floor type ОСТАЮТСЯ тут,
# НЕ пишем в listings.house_type (кросс-источниковый словарь грязный). # НЕ пишем в listings.house_type (кросс-источниковый словарь грязный).
# egrn area key — точное написание не подтверждено; пробуем несколько. # egrnData.area — ключ подтверждён на живых карточках 17.09 (#3252). В raw_payload
egrn_area = egrn.get("area") or egrn.get("rosreestrArea") or egrn.get("object_area") # кладём обёртку {status, value} целиком: status говорит, сошлась ли площадь
# ЕГРН с площадью объявления, и уже лежит в таком виде у 2156 строк.
egrn_area = egrn.get("area")
demand = _compact( demand = _compact(
{ {
"calls": pc.get("callsCount"), "calls": pc.get("callsCount"),

View file

@ -28,7 +28,6 @@ from pydantic import BaseModel, Field
from selectolax.parser import HTMLParser, Node from selectolax.parser import HTMLParser, Node
from scraper_kit.base import BaseScraper from scraper_kit.base import BaseScraper
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.providers._base import build_browser_fetcher from scraper_kit.providers._base import build_browser_fetcher
from scraper_kit.proxy_errors import caused_by_no_proxy from scraper_kit.proxy_errors import caused_by_no_proxy
from scraper_kit.yandex_helpers import ( from scraper_kit.yandex_helpers import (
@ -158,7 +157,7 @@ class YandexNewbuildingScraper(BaseScraper):
def __init__( def __init__(
self, self,
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> None: ) -> None:
@ -193,14 +192,10 @@ class YandexNewbuildingScraper(BaseScraper):
""" """
url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/" url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/"
# #3197: через фабрику — endpoint/use_pool/environment из config mandatory # #3197: через фабрику — endpoint/use_pool/environment из config mandatory
# (образец: yandex/serp.py::__aenter__). config=None (dev/офлайн-тесты) — # (образец: yandex/serp.py::__aenter__). Запасной ветки config=None больше нет (#2385).
# прежний путь без пула, как в cian/newbuilding.py. fetcher_cm = build_browser_fetcher(
if self._config is not None: self._config, "yandex", proxy_provider=self._proxy_provider
fetcher_cm = build_browser_fetcher( )
self._config, "yandex", proxy_provider=self._proxy_provider
)
else:
fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type]
try: try:
async with fetcher_cm as fetcher: async with fetcher_cm as fetcher:
html = await fetcher.fetch(url) html = await fetcher.fetch(url)
@ -359,7 +354,7 @@ async def resolve_yandex_jk_slug(
jk_id: str, jk_id: str,
city: str = "ekaterinburg", city: str = "ekaterinburg",
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> str | None: ) -> str | None:
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id). """Найти Yandex Realty slug для ЖК по его ext_id (jk_id).
@ -402,10 +397,7 @@ async def resolve_yandex_jk_slug(
""" """
page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/" page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
# #3197: см. fetch_jk — та же проводка пула через фабрику. # #3197: см. fetch_jk — та же проводка пула через фабрику.
if config is not None: fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
else:
fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type]
try: try:
async with fetcher_cm as fetcher: async with fetcher_cm as fetcher:
html = await fetcher.fetch(page_url) html = await fetcher.fetch(page_url)