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

View file

@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
from __future__ import annotations
import asyncio
import json
import logging
import secrets
@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
_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):
@ -234,6 +243,7 @@ async def glitchtip_webhook(
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
try:
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
text=text,
@ -243,10 +253,11 @@ async def glitchtip_webhook(
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramError:
except (TelegramError, TimeoutError):
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry).

View file

@ -718,6 +718,7 @@ def load_estimate(
_qc_geo_to_precision,
_resolve_target_city,
_source_counts,
analog_tier_from_explanation,
rehydrate_search_radius_m,
)
@ -896,6 +897,10 @@ def load_estimate(
# осознанно НЕ заполняем — payload.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 активных строк).
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):
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_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): цена обязана опираться на
# данные ЭТОГО адреса, а не на среднее по городу.
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
@ -408,25 +403,6 @@ class Settings(BaseSettings):
# False ⇒ прежнее поведение целиком, без релиза.
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 ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
@ -521,128 +497,13 @@ class Settings(BaseSettings):
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_factor_min: float = 0.75
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²)
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 ────────────────────────────────
# Исключать items старше N месяцев из price_trend (house_placement_history).
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
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 ──
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
@ -711,6 +572,16 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
estimate_unknown_attr_penalty_enabled: bool = False
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): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
@ -725,20 +596,6 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
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 ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
@ -763,26 +620,7 @@ class Settings(BaseSettings):
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
avito_imv_use_browser_fetcher: bool = False
# ── #764: per-cadastral-quarter price index correction ───────────────────
# 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
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (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)
# регион 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),
# 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают.
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
@ -1194,14 +1032,6 @@ class Settings(BaseSettings):
# False = старое поведение (без фильтра). ENV: 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) ────────────────────────────
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
@ -1221,25 +1051,19 @@ class Settings(BaseSettings):
cian_login_success_cookie: str = "DMIR_AUTH"
cian_login_wait_ms: int = 4000
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
# → 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.
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
# True = curl_cffi через settings.scraper_proxy_url.
#
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
# Прод переведён на браузер через docker-compose.prod.yml (environment
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
# других окружений вслепую.
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
# environment'ом сервиса scraper, а tradein-backend (ручка админки
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
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. Зависший

View file

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

View file

@ -67,8 +67,12 @@ class BlockRatioBreaker:
ratio_threshold: float
safety_min: int
snapshot_size: int
# #3439: сколько отказов НАШЕЙ стороны (kind != platform) подряд, без единого ответа
# площадки, рвут прогон. 0 — критерий выключен.
infra_streak_limit: int = 0
_window: deque[bool] = field(init=False, repr=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)
streak_histogram: Counter[int] = field(default_factory=Counter, init=False)
@ -106,10 +110,16 @@ class BlockRatioBreaker:
короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают.
Свой предохранитель у такого отказа серия infra_streak_limit (#3439): без
него серия любой длины не рвала прогон ничем (7302: 1598 отказов сайдкара из
1600, status='done'). Серию гасит любой ответ площадки успех, её блок, 404.
"""
if kind != BAN_KIND_PLATFORM:
self._consecutive_infra += 1
self.record_failure()
return
self._consecutive_infra = 0
self._consecutive_blocks += 1
self._window.append(True)
@ -117,6 +127,7 @@ class BlockRatioBreaker:
"""Единственный исход, снимающий safety-net (#3184: пачка блоков ПОСЛЕ хотя
бы одного успеха -- уже не "чистый с рождения прогона" burst)."""
self._flush_streak()
self._consecutive_infra = 0
self._pure_block_run = False
self._window.append(False)
@ -131,8 +142,11 @@ class BlockRatioBreaker:
AvitoListingGoneError). Класс написан источник-агностично на будущее (не
только avito), но сейчас единственный вызывающий -- avito_detail_backfill
(#3184 review MAJOR 1: применение к domclick_detail_backfill снято из этой
задачи -- своя калибровка, свои ограничения прокси-пула)."""
return
задачи -- своя калибровка, свои ограничения прокси-пула).
Единственное, что 404 двигает, -- серия infra (#3439): площадка ответила,
значит наш тракт жив."""
self._consecutive_infra = 0
def _flush_streak(self) -> None:
if self._consecutive_blocks:
@ -142,9 +156,11 @@ class BlockRatioBreaker:
def abort_reason(self) -> str | 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_min блоков в начале длинного прогона
# абортила бы его так же, как до правки (#3184 review MAJOR 2).
@ -176,6 +192,11 @@ class BlockRatioBreaker:
should_abort(), так что в логи не попадает.
"""
reason = self.abort_reason()
if reason == "infra_streak":
return (
f"{self._consecutive_infra} отказов нашего тракта (сайдкар/пул) подряд "
"без единого ответа площадки"
)
if reason == "ratio":
return (
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.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.schemas.trade_in import (
AggregatedEstimate,
@ -283,6 +283,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | 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(
relaxations: list[str] | None, analog_distances_m: list[int | None]
) -> int | None:
@ -316,7 +342,7 @@ def rehydrate_search_radius_m(
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
# московский коридор занижен на ~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, т.е. едет итоговая цена.
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
@ -416,6 +442,107 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
("премиум", 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`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -2013,7 +2140,7 @@ def _resolve_target_city(address_text: str | None) -> str | None:
Returns None если ни один город не распознан (адрес вне известного
списка) caller (_fetch_dkp_corridor) откатывается на behaviour без
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 влияет на итоговую цену.
"""
if not address_text:
@ -2223,7 +2350,7 @@ def _fetch_dkp_corridor(
latest_deal = dd
ppm2_values = sorted(adjusted)
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
# Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
scope = "street"
@ -2592,7 +2719,7 @@ def _fetch_anchor_comps(
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
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 ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address)
@ -2975,12 +3102,8 @@ def _compute_same_building_anchor(
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
effective_mad_k = mad_k
if (
tier != "A"
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
if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
effective_mad_k = SB_MAD_K_SMALL_N
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
if len(surviving_idx) < min_comps:
logger.info(
@ -3394,7 +3517,7 @@ def _price_from_inputs(
"""
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
# по кросс-постам.
listings = _dedup_cross_source(listings)
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
@ -3591,32 +3714,28 @@ def _price_from_inputs(
area_target=area_m2,
rooms_target=rooms,
tier=anchor_tier or "",
sigma=settings.estimate_sb_area_sigma,
rooms_boost=settings.estimate_sb_rooms_match_boost,
sigma=SB_AREA_SIGMA,
rooms_boost=SB_ROOMS_MATCH_BOOST,
floor_target=floor,
total_floors_target=total_floors,
floor_sigma=settings.estimate_sb_floor_sigma,
min_comps=settings.estimate_sb_min_comps,
mad_k=settings.estimate_sb_mad_k,
floor_sigma=SB_FLOOR_SIGMA,
min_comps=SB_MIN_COMPS,
mad_k=SB_MAD_K,
)
# #1795 шаг 3: гейт Tier C.
if (
anchor is not None
and anchor_tier == "C"
and settings.estimate_anchor_tier_c_corridor_mult > 0
):
if anchor is not None and anchor_tier == "C":
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
else:
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:
logger.info(
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
" → keep radius median (anchor suppressed)",
int(anchor["anchor_ppm2"]),
settings.estimate_anchor_tier_c_corridor_mult,
ANCHOR_TIER_C_CORRIDOR_MULT,
int(gate_threshold),
)
anchor = None
@ -3624,10 +3743,7 @@ def _price_from_inputs(
# #audit-1: low-confidence gate.
if anchor is not None:
gate_low = anchor["confidence"] == "low"
gate_thin = (
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
if gate_low or gate_thin:
logger.info(
"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
est_ppm2 = anchor["anchor_ppm2"]
# 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:
est_ppm2 = floor_ppm2
new_ppm2 = est_ppm2 * repair_coef
point = int(new_ppm2 * area_m2)
# 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_high = int(point * (1.0 + half))
# Спред комплов.
@ -3699,10 +3815,10 @@ def _price_from_inputs(
range_low = new_range_low
range_high = new_range_high
confidence = anchor["confidence"]
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
# #695: explanation описывает ИМЕННО якорные комплы.
# #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает
# GET-rehydrate (analog_tier_from_explanation) — менять только через константу.
explanation = (
f"Оценка построена по {anchor['n']} аналогам из {tier_label}"
f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}"
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
) + repair_note
# #695 (QA fixup): n_analogs по anchor-популяции.
@ -3726,7 +3842,7 @@ def _price_from_inputs(
# #1871 P2: split-дома wide-corridor disclosure.
if anchor_tier == "A" and median_price > 0:
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)
explanation = (explanation or "") + (
" Очень широкий ценовой диапазон по дому (вероятно, дом "
@ -3830,8 +3946,8 @@ def _price_from_inputs(
area=area_m2,
anchor_total=anchor_total,
anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight,
threshold=settings.estimate_imv_blend_threshold,
weight=IMV_BLEND_WEIGHT,
threshold=IMV_BLEND_THRESHOLD,
market_count=(
avito_imv_summary.market_count if avito_imv_summary is not None else None
),
@ -3843,7 +3959,7 @@ def _price_from_inputs(
median_price,
new_median,
anchor_used,
settings.estimate_imv_blend_weight,
IMV_BLEND_WEIGHT,
range_high,
new_range_high,
)
@ -3911,15 +4027,15 @@ def _price_from_inputs(
# Bimodal/nominal guard (Guard-4).
if (
target_qi > settings.estimate_quarter_index_max_for_small_n
and target_n_deals < settings.estimate_quarter_index_small_n_threshold
target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N
and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD
):
logger.info(
"quarter_index: bimodal guard triggered "
"(index=%.3f n=%d < %d) for %s — no-op",
target_qi,
target_n_deals,
settings.estimate_quarter_index_small_n_threshold,
QUARTER_INDEX_SMALL_N_THRESHOLD,
target_quarter,
)
else:
@ -3944,11 +4060,11 @@ def _price_from_inputs(
if lot_quarters_for_guard2
else 0.0
)
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO:
logger.info(
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
same_quarter_ratio,
settings.estimate_quarter_match_skip_ratio,
QUARTER_MATCH_SKIP_RATIO,
target_quarter,
)
else:
@ -4001,8 +4117,8 @@ def _price_from_inputs(
base_range_high=range_high,
target_index=target_qi,
avg_analog_index=avg_analog_index,
min_factor=settings.estimate_quarter_index_factor_min,
max_factor=settings.estimate_quarter_index_factor_max,
min_factor=QUARTER_INDEX_FACTOR_MIN,
max_factor=QUARTER_INDEX_FACTOR_MAX,
)
analogs_with_qi = sum(
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"})
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
slack = settings.estimate_corridor_clamp_slack
slack = CORRIDOR_CLAMP_SLACK
if dkp_raw is not None and median_ppm2 > 0:
old_ppm2 = median_ppm2
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"],
anchor_tier=anchor_tier,
slack=slack,
min_n=settings.estimate_corridor_clamp_min_n,
min_n=CORRIDOR_CLAMP_MIN_N,
)
if clamped:
logger.info(
@ -4059,9 +4175,9 @@ def _price_from_inputs(
and dkp_raw is not None
and dkp_raw.get("low_ppm2", 0) > 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:
floor_factor = radius_floor_ppm2 / median_ppm2
logger.info(
@ -4069,7 +4185,7 @@ def _price_from_inputs(
" → lifting (factor=%.3f)",
int(median_ppm2),
dkp_raw["low_ppm2"],
settings.estimate_radius_floor_factor,
RADIUS_FLOOR_FACTOR,
int(radius_floor_ppm2),
floor_factor,
)
@ -4089,7 +4205,7 @@ def _price_from_inputs(
if asking_to_sold_ratio is not None and median_price > 0:
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(
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
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
# 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).
if settings.estimate_expected_sold_le_asking and expected_sold_price:
if expected_sold_price:
expected_sold_price = min(expected_sold_price, median_price)
if expected_sold_per_m2:
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),
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
# `_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
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
# but the stale tier flag falsely reads as "anchor claimed the headline"
@ -5166,14 +5282,14 @@ async def estimate_quality(
return _lookup_quarter_index(
db,
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]:
return _lookup_quarter_indexes(
db,
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 ──────────────────────────────────
@ -5311,10 +5427,11 @@ async def estimate_quality(
estimate_id = uuid4()
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
# меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и
# 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`.
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
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",
estimate_id,
dkp_corridor.count,
settings.estimate_corridor_clamp_min_n,
CORRIDOR_CLAMP_MIN_N,
(dkp_raw or {}).get("scope", "unknown"),
target_city or "unknown",
)
@ -5573,10 +5690,8 @@ async def estimate_quality(
# radius-путь: analog_tier "W" → "city", остальные → "district".
# None только если нет аналогов (median=0, insufficient_data=True).
if median_price > 0:
if anchor_tier == "A":
api_analog_tier: str | None = "same_building"
elif anchor_tier == "C":
api_analog_tier = "micro_radius"
if anchor_tier in ANCHOR_API_TIERS:
api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier]
elif analog_tier == "W":
api_analog_tier = "city"
else:
@ -5641,7 +5756,6 @@ async def estimate_quality(
confidence,
range_low,
range_high,
settings,
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
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 флаг не выставится, даже если дом объективно элитный по
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости
`estimate_manual_review_high_value_rub` (expected_sold_price порога) и
`estimate_manual_review_elite_ppm2` (asking /м² порога) независимо от
`MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price порога) и
`MANUAL_REVIEW_ELITE_PPM2` (asking /м² порога) независимо от
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
почти всегда подхватывается value-based триггером той же функции.
"""
@ -5819,7 +5933,6 @@ def _manual_review(
confidence: str,
range_low: int | None,
range_high: int | None,
settings: Settings,
asking_ppm2: int | None = None,
) -> tuple[bool, list[str]]:
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
@ -5849,15 +5962,13 @@ def _manual_review(
reasons: list[str] = []
if premium_building:
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
high_value = settings.estimate_manual_review_high_value_rub
if expected_sold_price and expected_sold_price >= high_value:
if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB:
reasons.append("высокая стоимость (≥20 млн ₽)")
if confidence == "low":
reasons.append("низкая уверенность оценки")
wide_ratio = settings.estimate_manual_review_wide_range_ratio
if range_low and range_high and range_high / range_low >= wide_ratio:
if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO:
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(
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
@ -6551,8 +6662,9 @@ def _fetch_analogs(
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
Tier A) + ST_DWithin(radius_m). Если 3 результатов tier='S'.
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
Если 5 результатов возвращаем; tier='H'.
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors
±estimate_tier_h_floors_tol (0.30). Если estimate_tier_h_min_comps (5)
результатов возвращаем; tier='H'.
Пропускается если year_built или total_floors неизвестны.
**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"
# ── 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:
year_min = year_built - 15
year_max = year_built + 15
tf_min = math.floor(total_floors * 0.7)
tf_max = math.ceil(total_floors * 1.3)
tf_min = math.floor(total_floors * (1 - settings.estimate_tier_h_floors_tol))
tf_max = math.ceil(total_floors * (1 + settings.estimate_tier_h_floors_tol))
tier_h_rows = (
db.execute(
@ -6860,7 +6972,7 @@ def _fetch_analogs(
min_n=settings.estimate_unknown_attr_penalty_min_n,
)
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(
"analogs tier=H year=%d±15 tf=%d-%d%d results",
year_built,
@ -7328,15 +7440,18 @@ _DEDUP_STREET_TYPE_ALT = "|".join(
)
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# («8 Марта», «40 лет Октября», «22 Партсъезда»; #2291 класс 4 — и через дефис:
# «40-летия Комсомола», «22-го Партсъезда»; однобуквенный порядковый «2-я
# Новосибирская», «3-й Конный», в т.ч. перед нумерованным именем «2-й переулок
# 1 Мая»); она разрешена ТОЛЬКО перед буквенным словом
# (?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
_DEDUP_STREET_NAME_RE = re.compile(
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE,
)
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
@ -7348,10 +7463,19 @@ _DEDUP_STREET_NAME_RE = re.compile(
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
r"(?:^|[,\s])"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
r"((?:\d+-[а-яё]\s+)?(?:\d+(?:\s+|-))?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
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 без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
@ -7537,6 +7661,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
# «Минометчиков» должны схлопываться в один канонический токен.
s = s.replace("ё", "е")
s = _DEDUP_ORDINAL_BEFORE_TYPE_RE.sub(r"\1\3 \2 ", s)
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
m = _DEDUP_STREET_NAME_RE.search(s)
@ -7655,8 +7780,7 @@ def _union_find_phys_dedup(
не дедупятся остаются уникальными.
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) ценовой дрейф между кросс-постами
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ 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/
median/cv/source_counts/sources_used (представитель несёт свой source лот
считается один раз; source кросс-поста, не ставший представителем, выпадает
из выборки по нему нет независимой ценовой точки). Флаг
estimate_dedup_analogs_enabled OFF no-op (байт-идентичный проход, нужно
для frozen backtest regression gate). Ключ включает price_bucket
из выборки по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель
снят (#2378) — бэктест #1966 OFF vs ON был
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%), дедуп меняет
только честность счёта n_analogs. Ключ включает price_bucket
(_lot_dedup_components include_price=True по умолчанию) см.
_union_find_phys_dedup.
"""
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
if len(lots) < 2:
return lots
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: тот флаг управляет
статистикой (median/n_analogs), этот только честностью UI-карточек и
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
Независим от _dedup_cross_source: тот управляет статистикой
(median/n_analogs), этот только честностью UI-карточек. НЕ трогает
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]]:
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small несколько элитных
#1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD)
ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL несколько элитных
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
тянут радиусную медиану вверх. threshold=0 выключено (старое поведение).
тянут радиусную медиану вверх.
"""
if len(lots) < 5:
return lots # на маленькой выборке нечего фильтровать
@ -7856,11 +7980,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
return lots
tukey_k = 1.5
if (
settings.estimate_outlier_tukey_k_small < 1.5
and len(prices) < settings.estimate_outlier_small_n_threshold
):
tukey_k = settings.estimate_outlier_tukey_k_small
if len(prices) < OUTLIER_SMALL_N_THRESHOLD:
tukey_k = OUTLIER_TUKEY_K_SMALL
q1 = _percentile(prices, 0.25)
q3 = _percentile(prices, 0.75)

View file

@ -42,6 +42,12 @@ counters["block_streak_histogram"], иначе эффект правки неч
своей величиной: доля печатает "14/20", safety-net -- длину серии. Раньше лог
печатал серию всегда, и прогон 5210 (обрыв по доле 14/20) отчитался как
"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
@ -98,9 +104,9 @@ from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
# принимает config: ScraperConfig | None (Strangler-инъекция #2330) и пробрасывает
# его в _build_detail_session() для settings.scraper_proxy_url (sticky МГТС-прокси),
# зеркаля fetch_detail. Оба call site'а ниже передают config=RealScraperConfig() —
# без него warm-batch curl-путь (use_curl=True, ДЕФОЛТ прод-режима,
# avito_detail_backfill_use_curl=True) молча терял бы backconnect (прямое
# datacenter-подключение вместо sticky-прокси), тот же класс бага что #2322/#2310.
# без него warm-batch curl-путь (use_curl=True, avito_detail_backfill_use_curl) молча
# терял бы backconnect (прямое datacenter-подключение вместо sticky-прокси), тот же
# класс бага что #2322/#2310.
logger = logging.getLogger(__name__)
@ -232,12 +238,10 @@ async def _rotate_current_proxy(
settings.scraper_proxy_url БЕЗ lease там ротировать нечего, вызывающий цикл не
зовёт эту функцию в том режиме вовсе.
Прод при этом в browser-режиме, а не в curl: у контейнера tradein-scraper (там же
живёт планировщик) проверено AVITO_DETAIL_BACKFILL_USE_CURL=false при
SCRAPER_FETCH_MODE=browser и USE_PROXY_POOL_BROWSER=true, так что ротация
активна. Значение true стоит только у tradein-backend, который добор не запускает.
Комментарий ниже по файлу (~строка 653) называет use_curl=True «прод-дефолтом»
это предсуществующее заблуждение, а не описание текущего прода.
Прод в browser-режиме, а не в curl: дефолт avito_detail_backfill_use_curl=False
(#3045). Добор исполняют ДВА процесса — планировщик в tradein-scraper и ручка
админки scrape_avito_detail_backfill в tradein-backend, поэтому режим задаётся
дефолтом кода, а не environment'ом одного сервиса.
Отказ провайдера (лимит исчерпан, нет rotate_url, сетевой сбой, неизвестный хост)
НЕ должен ронять прогон логируем и продолжаем на текущем адресе. Эта попытка НЕ
@ -329,7 +333,8 @@ async def run_avito_detail_backfill(
скользящем окне, см. settings.detail_backfill_block_ratio_window/
_threshold (module docstring).
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
default 25 (см. комментарий у чтения параметра ниже).
default 25 (см. комментарий у чтения параметра ниже). Тот же порог рвёт
серию отказов НАШЕГО тракта без ответа площадки (infra_streak, #3439).
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
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),
ratio_threshold=float(settings.detail_backfill_block_ratio_threshold),
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): пачка блоков в
# начале ДЛИННОГО прогона не должна абортить его так же, как раньше --
# safety-net включён только когда снапшот короче окна и ratio-критерий
@ -705,7 +714,7 @@ async def run_avito_detail_backfill(
# config.scraper_proxy_url=None для backconnect-gate'а
# (elif not use_curl own-session path), теряя reconnect-
# on-403 поведение legacy (settings.scraper_proxy_url
# напрямую). Не влияет на use_curl=True (прод-дефолт)
# напрямую). Не влияет на use_curl=True
# там reconnect_on_block=False уже гасит backconnect.
config=RealScraperConfig(),
origin=serp_origin,
@ -1025,6 +1034,12 @@ async def run_avito_detail_backfill(
counters.duration_sec = time.monotonic() - start
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 -> сколько раз встретилась) --
# иначе эффект правки на #2674-статистике нечем измерить постфактум. finalize()
# досчитывает хвостовую пачку, если прогон оборвался посреди серии.
@ -1036,21 +1051,28 @@ async def run_avito_detail_backfill(
# и без этого ключа "banned" опять не отличить по причине (#3178).
if abort_reason is not None:
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
# Остановки по вине НАШЕЙ стороны — НЕ блок, поэтому и не aborted_by_blocks:
# иначе прогон уйдёт в 'banned' (или в 'done' по #3367) и запись будет
# утверждать про площадку то, чего не было.
stop_reason: str | None = None
if no_proxy_stop:
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
# #3288 (как #3283 у домклика): пустой пул — к площадке не ходили вовсе.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(
db,
run_id,
"пул прокси пуст — к площадке не ходили (#3288)",
current_counters,
stop_reason = "пул прокси пуст — к площадке не ходили (#3288)"
elif abort_reason == "infra_streak":
# #3439: сайдкар/пул отказывали подряд — площадка всё это время молчала.
stop_reason = (
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(
"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",
run_id,
stop_reason,
counters.attempted,
counters.enriched,
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
``params`` block. Touches NO DB / network and does NOT consult
``settings_at_capture`` it prices against the live committed
``estimator.settings`` defaults (so a settings change is caught as a metric
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
потока (см. комментарий у пина ниже). Deterministic: same fixture identical
dict.
``estimator.settings`` defaults and module constants (so a change is caught as a
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
(закоммиченная такая); более старая упадёт на «control flow diverged from
capture» её надо перезахватить. Deterministic: same fixture identical dict.
"""
est = _import_estimator_full()
m = est.m
@ -1991,26 +1990,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
pred_ppm2_all: list[float] = []
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
# вызывается, а реплей с dedup=False их оценивает и упирается в
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
# False — для старых фикстур без settings_at_capture поведение прежнее).
_dedup_capture = bool(
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
)
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
try:
for rec in deals:
kw = dict(rec["kwargs"])
sold_ppm2_all.append(float(rec["sold_ppm2"]))
@ -2066,9 +2045,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
predictions.append(prediction)
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.
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.
"""
m = est.m
settings = est.settings
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
db, deal, est, target_house_id=target_house_id
@ -2212,7 +2187,7 @@ def _predict_full_spine(
res = m._lookup_quarter_index(
db,
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:
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(
db,
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:
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
@ -2658,7 +2633,9 @@ def _write_fixture(
) -> None:
"""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
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
JSON-plain before ``json.dump(allow_nan=False)``.
@ -2668,6 +2645,11 @@ def _write_fixture(
for name in sorted(type(est.settings).model_fields)
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 = {
"schema_version": FIXTURE_SCHEMA_VERSION,
"engine": "full",

View file

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

View file

@ -36,6 +36,7 @@ import pytest
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
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 (
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
)
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
captured.update(kwargs)
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))
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)
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()))
assert captured.get("proxies") == {
"http": "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": "Свободная продажа"},
"egrnData": {
"area": 38.2,
"floor": 5,
"owners_count": 1,
"area": {"status": "success", "value": 38.2},
"floor": {"status": "success", "value": 5},
"owners_count": {"status": "success", "value": 1},
"collateral": true,
"collateral_sber": false
},
@ -264,7 +264,7 @@ def test_parse_detail_html_raw_extra() -> None:
# wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type.
assert "house_type" not in raw
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"]["favorites"] == 12
# AVM (Layer C, top-level pricePrediction) → raw_extra.avm

View file

@ -15,6 +15,7 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
# 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,
)
# config обязателен (#2385); на пути с переданной session он не читается.
_CONFIG = SimpleNamespace(cian_proxy_url=None)
_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:
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
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"
# caller owns a passed-in session — resolver must NOT close it.
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:
"""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>")
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
async def test_resolve_via_search_non_200_returns_none() -> None:
"""A non-200 (block / 404) → None without attempting extraction."""
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
@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None:
session = MagicMock()
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
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_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-остановкой исполняет только
# Postgres. В ci-tradein.yml бегут по-настоящему (postgres-сервис, #2745); краснеют от
# снятия подписи батча, сужения окна до ×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
config=ScraperConfig and silently drops settings.scraper_proxy_url (sticky МГТС-прокси)
-- 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()
alone wouldn't catch someone dropping the kwarg later (mirrors the fetch_detail config=
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` **настоящий фикс**. Он ходит через
`BrowserFetcher.fetch`, а `fetch`/`fetch_json` единственные методы, которые кладут
`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает
`POST /admin/scrape/domclick/debug/detail-fetch` ходит через `BrowserFetcher.fetch`, который
кладёт `payload["proxy"]` в тело POST /fetch сайдкара, а сайдкар этот override читает
(`_resolve_proxy_override` `_ensure_browser(provider, proxy_override=...)`). Прямая
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
`proxy_provider`/`use_pool`/`environment` без них сайдкар брал свой env-узел
@ -10,17 +9,11 @@
прод-отказ «пул пуст не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
`POST /admin/scrape/cian/auto-login` **намеренно без пула**, и это проверяется обратными
по значению утверждениями. Логин идёт не через `fetch`, а через `login` `_post_login`,
который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler`
(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и
`_ensure_browser(provider)` без override то есть **сайдкар на `/login` proxy-override не
принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы
холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а
при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST
и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во
время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся
ради endpoint/environment из одного места); пул для логина отдельная задача сайдкара.
`POST /admin/scrape/cian/auto-login` (#3410) — до правки намеренно без пула: `/login`
сайдкара proxy-override не принимал, `_post_login` его не клал, и логин шёл с того же
мёртвого env-узла при любом теле. Теперь `/login` берёт proxy из тела, `login()` кладёт
туда узел аренды, а ручка передаёт провайдер. Пустой пул на проде честный 503 «нет
узла для cian», а не 502 «Browser login failed» и не заход на мёртвый env-узел.
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
@ -81,17 +74,20 @@ class _RealLeaseFetcher(BrowserFetcher):
"""
captured: ClassVar[list[dict[str, Any]]] = []
login_proxy: ClassVar[list[str | None]] = []
def __init__(self, **kwargs: Any) -> None:
_RealLeaseFetcher.captured.append(kwargs)
super().__init__(**kwargs)
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}
class _SpyProvider:
"""Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None.
"""Спай пула: `acquire` пишет вызовы в ClassVar и отдаёт `lease` (None — пул пуст).
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
и «ни разу не позвали» должно покрывать в том числе «даже не создали».
@ -99,10 +95,11 @@ class _SpyProvider:
acquired: ClassVar[list[str]] = []
released: ClassVar[list[int]] = []
lease: ClassVar[ProxyLease | None] = None
def acquire(self, provider: str) -> ProxyLease | None:
_SpyProvider.acquired.append(provider)
return None
return _SpyProvider.lease
def release(self, lease: ProxyLease) -> None:
_SpyProvider.released.append(lease.id)
@ -124,8 +121,10 @@ def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
_CapturingFetcher.captured = []
_RealLeaseFetcher.captured = []
_RealLeaseFetcher.login_proxy = []
_SpyProvider.acquired = []
_SpyProvider.released = []
_SpyProvider.lease = 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()))
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
@pytest.mark.usefixtures("_pool_on")
async def test_cian_auto_login_does_not_lease_from_pool(
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
) -> None:
"""Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг)."""
_cian_login_settings(monkeypatch, use_pool=use_pool, env=environment)
async def test_cian_auto_login_logs_in_through_pool_lease(monkeypatch: pytest.MonkeyPatch) -> None:
"""#3410: прод + узел в пуле → логин идёт через него, аренда отпускается."""
_cian_login_settings(monkeypatch, use_pool=True, env="production")
_SpyProvider.lease = ProxyLease(id=13, url="http://u:p@pool:8080", kind="http")
with ExitStack() as stack:
_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, "ручка обязана построить ровно один фетчер"
kwargs = _RealLeaseFetcher.captured[0]
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен"
assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)"
assert kwargs.get("proxy_provider") is not None, "без провайдера логин идёт с env-узла"
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).
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")
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,
) -> None:
"""Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502).
"""Пустой пул на проде → 503 «нет узла для cian», логин не запускается.
Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при
`use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`,
`cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` то
есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию,
хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает).
Не 502 «Browser login failed» (читается как сломанные селекторы/креды) и не заход на
env-узел сайдкара (#2616: на проде он мёртв).
"""
_cian_login_settings(monkeypatch, use_pool=True, env="production")
with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher)
try:
result = 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}")
with pytest.raises(HTTPException) as exc_info:
await admin.cian_auto_login(db=MagicMock(), body=None)
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")])

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
async def test_real_platform_bans_still_abort_by_ratio() -> None:
"""Контроль: 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, (
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 переименование импорта
# оставило бы тест зелёным против фантома.
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")
@ -213,7 +213,7 @@ def _run_with_pool(
with (
patch.object(estimator, "RealProxyProvider", lambda: provider),
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 вокруг него.
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
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 снизу гейтятся
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
`CORRIDOR_CLAMP_MIN_N` (10). Между ними лежит зона, где коридор
существует, показывается и участвует в fallback-путях, а цену не держит; на
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 3 и 72 8
@ -34,8 +35,8 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import settings
from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N
from app.core.config import CORRIDOR_CLAMP_MIN_N
from app.services.estimator import CORRIDOR_CLAMP_SLACK, DKP_CORRIDOR_CITY_WIDE_MIN_N
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
@ -46,7 +47,7 @@ _MARKER = "corridor_advisory_zone"
def _cap_ppm2() -> float:
"""Потолок клампа: 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]:
@ -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 схлопнулась — пороги показа и клампа сравнялись, "
"advisory-only решение #3452 больше не описывает реальность"
)
@ -167,15 +168,14 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
caplog: pytest.LogCaptureFixture,
) -> None:
"""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"):
est = _run_estimate(_corridor(n))
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
assert est.dkp_corridor.count == n
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, но порог ему не дал.
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()]
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
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
@ -193,13 +193,13 @@ def test_corridor_above_threshold_is_not_flagged_and_clamps(
caplog: pytest.LogCaptureFixture,
) -> None:
"""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"):
est = _run_estimate(_corridor(n))
assert est.dkp_corridor is not None
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()), (

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_gz = bt.replay_fixture(loaded_gz)
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
from pathlib import Path
import pytest
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
_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}"
def test_backtest_regression_gate(monkeypatch: pytest.MonkeyPatch) -> 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)
def test_backtest_regression_gate() -> None:
fixture = load_fixture(_FIXTURE_PATH)
baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8"))
# 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.testclient import TestClient # noqa: E402
from app.services.estimator import analog_tier_from_explanation # noqa: E402
_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
@ -170,6 +172,9 @@ def _stub_precision_and_pdf():
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
# поведение (см. tests/test_estimator_search_radius_2632.py).
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")
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
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:
"""No X-Authenticated-User header → 401."""
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))

View file

@ -277,6 +277,8 @@ def _estimator_stub():
# этой фикстуры analogs пусты → None и есть настоящее поведение
# (см. tests/test_estimator_search_radius_2632.py).
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,
)
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-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
# иначе тест проверял бы не то, что заявлено в docstring.
assert estimator.settings.estimate_dedup_analogs_enabled is True
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"

View file

@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp(
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
# SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
_OTHER_COMPS = [
_comp(
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_
anchor est.analogs содержит физлот ровно ОДИН раз (n=1).
"""
from app.core.config import settings
from app.services import estimator
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())
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
при одном реальном физлоте (дубль посчитан дважды) самосогласованно, но
нечестно. После фикса n_analogs=1, синхронно с analogs."""
from app.core.config import settings
from app.services import estimator
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())
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:
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 = (
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
# n=2 < 3 — должен сработать если FSD тоже высокий
assert anchor["n"] == 2
# 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 элементов
# Ожидаем что 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"
@ -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)]
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.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]]:
"""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`).
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
@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
"period_months": 12,
}
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.
db.execute.side_effect = _fake_anchor_sql_execute(
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),

View file

@ -3,7 +3,7 @@
Проверяет:
- композиты физического ключа (_lot_dedup_components) что образуют / не образуют;
- _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя
(свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично;
(свежайший scraped_at), не-дубли не трогаются;
- выбор представителя детерминирован (freshest scraped_at).
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:
# #2173: default флага теперь ON (в проде дедуп активен без ENV-override).
# Без monkeypatch settings отражает config-default → дедуп схлопывает
# кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно
# поведение по умолчанию.
assert estimator.settings.estimate_dedup_analogs_enabled is True
# #2378: выключателя больше нет — дедуп работает всегда и схлопывает
# кросс-пост-тройку одного физлота.
lots = [
_lot(source="avito"),
_lot(source="cian"),
_lot(source="domklik"),
]
out = estimator._dedup_cross_source(lots)
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 # тот же объект → нулевое влияние на выборку
assert len(out) == 1
# --------------------------------------------------------------------------- #
# _dedup_cross_source — флаг ON
# _dedup_cross_source
# --------------------------------------------------------------------------- #
def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_collapses_cross_source_triple() -> None:
# Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik.
lots = [
_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"
def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_keeps_distinct_units() -> None:
# Разные физлоты: разный этаж / площадь / цена / здание → не дубли.
lots = [
_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 # ничего не схлопнулось
def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_preserves_first_seen_order() -> None:
# relevance-порядок из _fetch_analogs должен сохраниться: группа занимает
# позицию первого появления.
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"]
def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_lots_without_key_stay_unique() -> None:
# Нет площади → ключ None → не дедупим, даже если оба «пустые».
lots = [
_lot(source="avito", area=0),
@ -225,10 +205,9 @@ def test_extract_street_token_strips_house_and_noise() -> 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М показан
# как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -259,10 +238,9 @@ def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -
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 есть, у другого нет, адрес совпадает → сливаются по
# street-композиту (#2265: union по любому совпавшему ключу).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -285,12 +263,9 @@ def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPat
assert len(out) == 1
def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_distinct_cadnums_same_metrics_do_not_merge() -> None:
# Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
# кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -313,12 +288,9 @@ def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
assert len(out) == 2
def test_dedup_same_street_different_metrics_stay_distinct(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_same_street_different_metrics_stay_distinct() -> None:
# Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
# разными (защита от over-merge при отсутствии cadnum).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_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),
@ -350,9 +322,8 @@ def test_extract_street_token_numbered_not_degraded() -> None:
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 пуст → один физлот.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -383,10 +354,9 @@ def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.Monke
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, но РАЗНЫЕ извлечённые номера
# дома → house-guard не даёт слить (both-present-different).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_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),
@ -395,10 +365,9 @@ def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatc
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)
lots = [
_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
def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_empty_cadnum_and_street_stay_separate() -> None:
# Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
# идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_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),
@ -485,7 +453,7 @@ def test_extract_street_token_extended_same_token_across_formats() -> None:
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»
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
@ -493,7 +461,6 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
# адреса используют одну и ту же глухую («к1») family корпуса, и после
# #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
# независимо от пробела перед «к» (см. _normalize_house_no).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domklik",
@ -519,7 +486,7 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
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 округляется в
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
# 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
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
# _normalize_house_no), house-guard не блокирует merge независимо от пробела.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
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
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_avito_glued_suffix() -> None:
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
# 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
# (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_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),
@ -565,11 +530,10 @@ def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) ->
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) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
# сливаются даже при совпадении метрик.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_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),
@ -622,11 +586,10 @@ def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str)
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» раньше
# ключевался по району и никогда не сливался с avito/cian, где «готвальда»
# распознаётся явной веткой (тип-слово «улица»).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
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-форматах —
# «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_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),
@ -753,13 +715,10 @@ def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")
def test_dedup_collapses_avito_glued_metro_suffix_house_match(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_collapses_avito_glued_metro_suffix_house_match() -> None:
# e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
# извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
# больше не блокирует street-union (оба извлекают «2») → один физлот.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
@ -782,14 +741,11 @@ def test_dedup_collapses_avito_glued_metro_suffix_house_match(
assert len(out) == 1
def test_dedup_glued_metro_noise_does_not_cause_over_merge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_glued_metro_noise_does_not_cause_over_merge() -> None:
# Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
# адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
# («24» vs «26»), каждый одет в приклеенное название станции разной длины,
# ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
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")
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus() -> None:
# Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
# нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
# физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
# схлопнуться в один. union() зависит от порядка (кто станет root) —
# проверяем ОБА порядка следования лотов.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def _pair(first_source: str, second_source: str) -> list[dict]:
by_source = {
@ -903,14 +856,11 @@ def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
assert len(out_domklik_first) == 1
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus() -> None:
# Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
# пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
# «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
# должно.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
@ -933,13 +883,10 @@ def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
assert len(out) == 2
def test_dedup_house_no_guard_blocks_different_corpus_numbers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_house_no_guard_blocks_different_corpus_numbers() -> None:
# Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
# «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
# по-прежнему их разделять после нормализации.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domklik",
@ -977,3 +924,128 @@ def test_parse_street_house_dangling_corpus_letter_no_digit_is_liter() -> None:
# сразу после буквы ⇒ литер, см. design §3), остаётся «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(
ratio_tuple: tuple[float | None, str | None],
*,
clamp_enabled: bool = True,
) -> Any:
from app.services.estimator import estimate_quality
@ -90,7 +88,6 @@ def _run_estimate(
async def _run() -> Any:
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
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
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:
ratio = 1.15
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.expected_sold_price_rub is not None
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:
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.expected_sold_price_rub == round(est.median_price_rub * 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:
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_per_m2 is None
assert est.expected_sold_range_low_rub is None
assert est.expected_sold_range_high_rub is None
assert est.asking_to_sold_ratio is None
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);
* the neutral year term when ``target_year`` is None ( year 2000);
* 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
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:
"""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
(otherwise the re-clamp would cap it at the asking headline).
ratio 0.70 keeps 0.70 × factor_max (1.30) = 0.91 < 1.0, so the always-on
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: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
текущую подгонку (после перефита 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_larea_coef", -0.1603)
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)
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)
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)
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:
"""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_expected_sold_le_asking", True)
# 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.
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
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:
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.

View file

@ -15,7 +15,6 @@
from __future__ import annotations
import os
from types import SimpleNamespace
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",
range_low=7_500_000,
range_high=8_500_000,
settings=_settings(),
)
assert rec is True
assert reasons == [PREMIUM_REASON]
@ -70,7 +54,6 @@ def test_high_value_alone() -> None:
confidence="high",
range_low=24_000_000,
range_high=26_000_000,
settings=_settings(),
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
@ -83,7 +66,6 @@ def test_low_confidence_alone() -> None:
confidence="low",
range_low=4_500_000,
range_high=5_500_000,
settings=_settings(),
)
assert rec is True
assert reasons == [LOW_CONF_REASON]
@ -96,7 +78,6 @@ def test_wide_range_alone() -> None:
confidence="high",
range_low=5_000_000,
range_high=10_000_000, # ratio 2.0 ≥ 1.9
settings=_settings(),
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
@ -114,7 +95,6 @@ def test_elite_ppm2_alone() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000, # ratio ~1.18 < 1.9
settings=_settings(),
asking_ppm2=300_000, # ≥ 250_000
)
assert rec is True
@ -129,7 +109,6 @@ def test_elite_ppm2_boundary_inclusive() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=250_000,
)
assert rec is True
@ -144,7 +123,6 @@ def test_elite_ppm2_just_below_threshold() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=249_999,
)
assert rec is False
@ -159,7 +137,6 @@ def test_elite_ppm2_none_safe() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=None,
)
assert rec is False
@ -175,7 +152,6 @@ def test_elite_ppm2_zero_safe() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=0,
)
assert rec is False
@ -190,7 +166,6 @@ def test_elite_ppm2_combined_with_other_triggers() -> None:
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
asking_ppm2=400_000,
)
assert rec is True
@ -214,7 +189,6 @@ def test_multiple_reasons_combined() -> None:
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
)
assert rec is True
# порядок детерминирован: premium → high_value → low_conf → wide_range
@ -228,7 +202,6 @@ def test_all_clear_no_reasons() -> None:
confidence="medium",
range_low=9_000_000,
range_high=11_000_000, # ratio ~1.22 < 1.9
settings=_settings(),
)
assert rec is False
assert reasons == []
@ -245,7 +218,6 @@ def test_high_value_boundary_inclusive() -> None:
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
settings=_settings(),
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
@ -258,7 +230,6 @@ def test_high_value_just_below_threshold() -> None:
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
settings=_settings(),
)
assert rec is False
assert reasons == []
@ -272,7 +243,6 @@ def test_wide_range_boundary_inclusive() -> None:
confidence="high",
range_low=10_000_000,
range_high=19_000_000, # ratio == 1.9
settings=_settings(),
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
@ -285,7 +255,6 @@ def test_wide_range_just_below_threshold() -> None:
confidence="high",
range_low=10_000_000,
range_high=18_900_000, # ratio 1.89 < 1.9
settings=_settings(),
)
assert rec is False
assert reasons == []
@ -311,7 +280,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
confidence="high",
range_low=range_low,
range_high=range_high,
settings=_settings(),
)
assert rec is False
assert WIDE_RANGE_REASON not in reasons
@ -332,7 +300,6 @@ def test_recommended_equals_bool_reasons_invariant() -> None:
confidence=conf,
range_low=9_000_000,
range_high=11_000_000,
settings=_settings(),
)
assert rec is bool(reasons)

View file

@ -26,12 +26,9 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import estimator
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
# estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None
@ -43,16 +40,6 @@ _h = importlib.util.module_from_spec(_spec)
_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]:
"""Радиусный аналог; 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=215_000.0),
_listing(price_per_m2=225_000.0),
_listing(price_per_m2=None),
_listing(price_per_m2=None),
# Разная площадь → разные физлоты: кросс-source дедуп (#2087 H4, всегда ON
# с #2378) не схлопывает два безценовых листинга в один.
_listing(price_per_m2=None, area_m2=61.0),
_listing(price_per_m2=None, area_m2=62.0),
]
est = _h._run_estimate(
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:
"""Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется,
зеркало 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(
anchor_comps=[],
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 гейта выше:
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched``
``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched``
дальше по коду он читается как «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)
pr = _call(
listings=_lots(100_000, n=5),

View file

@ -389,6 +389,7 @@ def _run_estimate_qi(
anchor_tier_override: str | None = None,
analog_indexes: dict[str, float] | None = None,
imv_anchor: dict[str, Any] | None = None,
seen_min_n: list[int] | None = None,
):
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
@ -396,6 +397,9 @@ def _run_estimate_qi(
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
Кварталы вне карты не попадают в ответ ровно как в FDW при n_deals < порога.
seen_min_n сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог
фильтрует строки в SQL, и заглушка его иначе теряет.
"""
from app.services.estimator import estimate_quality
@ -406,7 +410,14 @@ def _run_estimate_qi(
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
# что 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):
if seen_min_n is not None:
seen_min_n.append(min_n_deals)
if analog_indexes is not None:
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
if qi_lookup_result is None:
@ -451,7 +462,7 @@ def _run_estimate_qi(
# Stub singular target-quarter lookup
patch(
"app.services.estimator._lookup_quarter_index",
return_value=qi_lookup_result,
side_effect=_fake_lookup_index,
),
# Stub batched analog-quarter lookup
patch(
@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
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
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
# ─────────────────────────────────────────────────────────────────────────────
@ -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:
"""Bimodal guard: price_index=3.5, n_deals=20 → no-op."""
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i)
for i in range(5)
]
base_median = round(_BASE_PPM2 * _AREA)
@pytest.mark.parametrize(
("target_qi", "expected_factor"),
[
(3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op
(2.01, 1.0),
(2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8
],
)
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(
analogs=analogs_no_cadnum,
analogs=_ANALOGS_OTHER_QUARTER,
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:

View file

@ -8,6 +8,8 @@
- radius median выше dkp_low × factor no-op (медиана не изменена)
- dkp_raw is None no-op (нет базы для floor)
- anchor-путь (anchor_tier != None) не затронут floor'ом
- коридор ниже CORRIDOR_CLAMP_MIN_N floor не применяется (#3466)
- коридор ровно CORRIDOR_CLAMP_MIN_N floor применяется (#3466)
"""
from __future__ import annotations
@ -73,10 +75,7 @@ def _make_payload():
def _run_estimate(
analogs: list[dict[str, Any]],
dkp_raw: dict[str, Any] | None,
*,
radius_floor_factor: float = 0.8,
) -> Any:
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
@ -107,24 +106,31 @@ def _run_estimate(
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
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 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 → поднята ───────────────────────────────
def test_radius_median_below_dkp_floor_is_lifted() -> None:
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
# analogs с median ~80k
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),
]
analogs = _six(80_000.0)
dkp_raw = {
"count": 15,
"low_ppm2": 150_000,
@ -132,11 +138,12 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
"high_ppm2": 220_000,
"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
assert est.median_price_per_m2 >= floor_ppm2, (
f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}"
# Ровно floor, а не «не ниже»: медиана коридора (180k) тоже «не ниже».
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:
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
analogs = [
_make_listing(price_per_m2=190_000.0),
_make_listing(price_per_m2=200_000.0),
_make_listing(price_per_m2=210_000.0),
]
analogs = _six(200_000.0)
dkp_raw = {
"count": 15,
"low_ppm2": 150_000,
@ -157,15 +160,15 @@ def test_radius_median_above_dkp_floor_unchanged() -> None:
"high_ppm2": 220_000,
"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, медиана не изменяется
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
floor_ppm2 = 150_000 * 0.8 # 120_000
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
# Медиана соответствует аналогам (~200k), а не floor
assert 180_000 <= est.median_price_per_m2 <= 220_000, (
f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)"
# Медиана — ровно медиана аналогов, а не floor и не медиана коридора (180k).
assert est.median_price_per_m2 == 200_000, (
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=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 не поднимает
assert est.median_price_per_m2 < 100_000, (
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
DEFAULT_RADIUS_M,
_compute_confidence,
analog_tier_from_explanation,
radius_from_relaxations,
radius_relaxation_label,
rehydrate_search_radius_m,
@ -193,6 +194,8 @@ def _light_estimator_stub():
_source_counts=lambda *a, **k: {},
_canonical_sources=lambda *a, **k: ["cian"],
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]
yield stub

View file

@ -22,8 +22,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
import pytest
from app.core.config import settings
from app.services.estimator import _downgrade_confidence
from app.services.estimator import WIDE_CORRIDOR_THRESHOLD, _downgrade_confidence
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
# 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]
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
with patch("app.services.estimator.WIDE_CORRIDOR_THRESHOLD", threshold):
return _h._run_estimate(
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
anchor_tier=anchor_tier,
@ -67,7 +66,7 @@ _DISCLOSURE_MARKER = "разбит на секции"
def test_threshold_default_1_2() -> None:
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
# 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")
import asyncio
import time
from typing import Any, ClassVar
import pytest
@ -46,12 +47,13 @@ class _FakeTelegramClient:
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети.
`responses` очередь: каждый вызов `send_message` берёт следующий элемент
(dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не
удалась, повторная фоном прошла».
(dict = успех, Exception = отказ, float = зависнуть на столько секунд, потом
успех), позволяя смоделировать «первая попытка не удалась, повторная фоном
прошла».
"""
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:
pass
@ -61,6 +63,9 @@ class _FakeTelegramClient:
outcome = _FakeTelegramClient.responses.pop(0)
if isinstance(outcome, Exception):
raise outcome
if isinstance(outcome, float):
await asyncio.sleep(outcome)
return {"message_id": 1}
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"]
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(
client: TestClient, _fake_telegram_client: Any
) -> None:

View file

@ -640,3 +640,36 @@ async def test_report_ban_does_not_release_or_rotate_lease() -> None:
assert provider.released == []
assert bf._lease is not None
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
import anyio
import pytest
from app.services import estimator
from app.services.estimator import (
_compute_same_building_anchor,
_geocode_is_coarse,
@ -395,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [
]
# Same-building комплы Хохрякова 48 (флагман 684k внутри).
# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755).
# 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755).
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [
{"price_per_m2": 399_478, "area_m2": 153.2, "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)
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:
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
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:
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
4 компла satisfies estimate_sb_min_comps=4 (#755).
4 компла satisfies SB_MIN_COMPS=4 (#755).
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
(все отклонения << 3.5×MAD при tight spread)."""
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:
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
вылетает за диапазон зеркало 8 Марта 204Г view-кейса).
4 компла satisfies estimate_sb_min_comps=4 (#755)."""
4 компла satisfies SB_MIN_COMPS=4 (#755)."""
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
view_comps = [
{"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:
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
4 компла satisfies estimate_sb_min_comps=4 (#755)."""
4 компла satisfies SB_MIN_COMPS=4 (#755)."""
comps_with_display = [
{
"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 "")
def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
def test_radius_path_n_analogs_unchanged() -> None:
"""#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).
Проверяет radius-passthrough vs anchor-override ортогонально кросс-source
дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно
патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF,
чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON).
дедупу (#2087 H4, всегда ON с #2378). Площади разные, поэтому это 5 разных
физлотов: 5 ИДЕНТИЧНЫХ листингов дедуп честно схлопнул бы в 1.
"""
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)]
radius = [_make_listing(price_per_m2=200_000.0, area_m2=60.0 + i) for i in range(5)]
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius)
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
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.
#2385: `config` стал обязательным во всех четырёх точках входа. Прежние кейсы
`*_without_config` (ждали endpoint=None / proxy_url=None) заменены одним тестом
«без config TypeError, фетчер и сессия не строятся».
"""
from __future__ import annotations
@ -42,6 +46,7 @@ from scraper_kit.providers.yandex.newbuilding import (
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
_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:
@ -77,21 +82,6 @@ async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
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 ─────────────────────
@ -115,21 +105,6 @@ async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch):
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 ────────────────────────────────
@ -151,20 +126,6 @@ async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch):
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 ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
#
# Замер 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)
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]
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):
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
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
@ -221,9 +182,9 @@ async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch):
Без привязки к id функция вернула бы «shishkinn» для чужого дома.
"""
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
@ -232,9 +193,9 @@ async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch):
spy = _spy_browser_fetcher(
'<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 ─────────────────────────
@ -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
# ── #2385: config обязателен — без него ни фетчера, ни сессии ────────────────
@pytest.mark.asyncio
async def test_resolve_cian_zhk_url_via_search_proxy_none_without_config(monkeypatch):
"""Без config= (SILENT footgun): вызов НЕ падает (в отличие от BrowserFetcher
assertion выше в этом файле), но proxy_url молча становится None резолвер уходит
на прямое (непроксированное) соединение вместо настроенного мобильного прокси."""
spy = _spy_curl_session(_SERP_HTML)
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", spy)
@pytest.mark.parametrize(
"call",
[
lambda: fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/"),
lambda: YandexNewbuildingScraper().fetch_jk(jk_slug="tatlin", jk_id="1592987"),
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
spy.assert_called_once()
_, kwargs = spy.call_args
assert kwargs.get("proxy_url") is None
assert browser_spy.call_count == 0
assert session_spy.call_count == 0

View file

@ -40,10 +40,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _mock_settings(
scraper_proxy_url: str | None,
yandex_cookies_file: str | None = None,
) -> SimpleNamespace:
def _mock_settings(scraper_proxy_url: str | None) -> SimpleNamespace:
"""Minimal settings stand-in with fields read by proxy helpers and scraper __aenter__."""
return SimpleNamespace(
scraper_proxy_url=scraper_proxy_url,
@ -52,7 +49,6 @@ def _mock_settings(
# removed — dead mobileproxy accounts). Mirrored here for test compat.
cian_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),
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
# а не через 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
@ -222,8 +222,8 @@ async def test_cian_newbuilding_own_session_receives_proxies():
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(zhk_url)
with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(zhk_url, config=_NB_CONFIG)
# BrowserFetcher.fetch вызван с ЖК-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")
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session)
with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(
zhk_url, config=_NB_CONFIG, session=sentinel_session
)
# BrowserFetcher.fetch вызван — именно через него тянется страница
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"),
]
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)
assert tier == "A"
# Все 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"),
]
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)
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
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"),
]
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)
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
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),
]
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)
assert tier == "A"
# 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)
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)
assert tier == "A"
# 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),
]
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)
assert tier == "A"
# 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():
"""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

View file

@ -107,10 +107,12 @@ Per-provider модель (#1793):
поиск yandex.ru, а не голым goto (#3251). Ручная сессия
29.08.2026 показала эталонный человеческий путь:
yandex.ru клик по результату выдача с Referer
yandex.ru. Дефолт "domclick" авито/циан/яндекс
проверяются отдельно в #3251, не включать здесь.
Пустая строка = выключено везде (полный откат к
прежнему поведению). Провайдер без соответствующего
yandex.ru. Дефолт пусто, выключено везде (#3263):
выдача Яндекса капчит конкретные выходные узлы пула,
и путь срабатывал ~2 раза из 9. Якорь поднимается
прямым goto на выдачу площадки, Referer карточке даёт
переход с неё. Включать только явным env и только
после замера на узлах пула. Провайдер без соответствующего
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не
делает см. ниже.
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поисковый запрос на yandex.ru для
@ -143,6 +145,7 @@ import logging
import os
import random
import re
import time
from collections.abc import Callable, Mapping
from typing import NamedTuple
from urllib.parse import quote, urlparse
@ -329,12 +332,15 @@ BROWSER_PAGE_INTERVAL_JITTER_S: float = float(
)
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env =
# выключено везде — полный откат на поведение до #3251.
# поиск yandex.ru, а не голым goto. Дефолт — пусто (#3263): капча на выдаче Яндекса —
# свойство выходного узла (замер владельца 29.08: узлы 1/10/11 — капча, 9 и домашний
# IP — выдача), в проде путь срабатывал ~2 из 9, а в логах 15-16.09 все 7 капч
# закончились «якорная вкладка не поднялась (Error)» через 0,2 с после отката — якоря
# не было вовсе. Без поиска якорь сразу открывает выдачу площадки, карточка идёт с
# referer=origin — переход внутри площадки. Включать только явным env после замера.
_ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset(
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()
)
@ -832,6 +838,12 @@ _last_goto_at: dict[str, float] = {} # provider → loop-time последн
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
_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 или динамический
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
# смене прокси» — 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),
# Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон.
"block_webrtc": True,
# Форсим таймзону +3 МСК. geoip берёт tz одного IP на старте, а БэкКоннект
# ротирует IP на каждый запрос (видели Ульяновск UTC+4 при выходе СПб/МСК) →
# геолик tz↔IP. Жёсткий Europe/Moscow убирает рассинхрон для РФ-мобильного.
"config": {"timezone": "Europe/Moscow"},
# Таймзону НЕ задаём (#3187): её ставит geoip=True по exit-IP на запуске —
# из той же GeoIP-записи, что и координаты geolocation. Жёсткий
# Europe/Moscow в config (было) camoufox 0.5.5 применял поверх geoip
# (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:
kwargs["proxy"] = proxy
@ -1454,8 +1472,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
status = _last_response_status.get(provider)
except Exception as exc:
logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s",
"tradein-browser[%s]: fetch error nav_ms=%s url=%r: %s: %s",
provider,
_last_nav_ms.get(provider),
url,
type(exc).__name__,
exc,
@ -2349,6 +2368,7 @@ async def _fetch_once(
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
# уехать статус предыдущей страницы этого же провайдера (#3196).
_last_response_status[provider] = None
_last_nav_ms[provider] = None
if reset_context:
await _close_reusable_context(provider)
@ -2423,7 +2443,11 @@ async def _fetch_once(
}
if referer:
goto_kwargs["referer"] = referer
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)
if BROWSER_WAIT_MS > 0:
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]
_page_counters[provider] = _page_counters.get(provider, 0) + 1
logger.debug(
"tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d",
# INFO, а не DEBUG (#3419): без строк успеха распределение времени навигации не
# снять, а сравнивать его с таймаутами и есть цель.
logger.info(
"tradein-browser[%s]: fetch OK nav_ms=%s url=%r pages_since_launch=%d",
provider,
_last_nav_ms.get(provider),
url,
_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)
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.
if IS_PROD and _no_live_proxy(provider, None):
if IS_PROD and _no_live_proxy(provider, proxy_override):
logger.warning(
"tradein-browser[%s]: /login отказ — нет env-прокси (prod) — "
"tradein-browser[%s]: /login отказ — нет прокси в теле и нет env-прокси (prod) — "
"не подключаемся напрямую с IP сервера (#2616)",
provider,
)
@ -2828,7 +2859,7 @@ async def login_handler(request: web.Request) -> web.Response:
async with lock:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка, иначе 503 — без прокси не логинимся.
if not await _ensure_browser(provider):
if not await _ensure_browser(provider, proxy_override=proxy_override):
logger.warning(
"tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)",
provider,

View file

@ -34,9 +34,12 @@ _HOSTNAME = "ekaterinburg.domclick.ru"
@pytest.fixture(autouse=True)
def _reset_state() -> None:
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
server._contexts = {}
server._anchor_pages = {}
# Путь через поиск выключен по умолчанию (#3263) — тесты ниже проверяют его
# поведение при ЯВНОМ включении env'ом BROWSER_ANCHOR_VIA_SEARCH=domclick.
monkeypatch.setattr(server, "_ANCHOR_VIA_SEARCH_PROVIDERS", frozenset({"domclick"}))
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:
"""domclick включён по умолчанию: заход на yandex.ru → клик по ссылке на хост."""
"""domclick включён env'ом: заход на yandex.ru → клик по ссылке на хост."""
page = _FakeAnchorPage(link_host=_HOSTNAME)
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:
"""avito не в BROWSER_ANCHOR_VIA_SEARCH (дефолт только domclick) — поведение прежнее."""
"""avito не в BROWSER_ANCHOR_VIA_SEARCH (включён только domclick) — поведение прежнее."""
origin = "https://www.avito.ru/ekaterinburg/kvartiry"
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 page.opened_popup is not None
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")]
_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)" ────────

View file

@ -149,3 +149,46 @@ def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch
asyncio.run(_run())
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.
USE_PROXY_POOL_CURL: "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:
browser:
condition: service_started
@ -453,6 +458,8 @@ services:
# Settings()-объект образа где-то читает флаг при импорте (defense-in-depth,
# аналогично backend). tgbot_main.py не должен зависеть от этого значения.
SCHEDULER_ENABLE: "false"
# #3182: то же значение, что у backend/scraper (см. комментарий у backend).
AVITO_DETAIL_BACKFILL_USE_CURL: "false"
depends_on:
postgres:
condition: service_healthy

View file

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

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 =
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
@ -53,13 +53,23 @@ export function dealsAsOfLabel(
* min_n коридор из пяти сделок там способен уронить headline на треть.
* Утверждение про размер выборки истинно во всех достижимых состояниях.
*
* #3466: сделок может быть и много, а коридор цену всё равно не держит когда
* она посчитана по аналогам того же дома (analog_tier "same_building"): кламп
* этот тир не трогает, radius-floor тоже. Здесь утверждение про алгоритм уже
* истинно при любом count (гейт Tier C к этому тиру не относится) его и говорим.
*
* null (флага нет оценка старше деплоя, либо сделок хватает) подписи нет.
*/
export function corridorAdvisoryNote(
dkp: DkpCorridor | null | undefined,
headlineFromDeals: boolean,
analogTier?: AnalogTier | 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 —
// похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и
// есть цена, и клиент обязан знать, на скольких сделках она стоит.

View file

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

View file

@ -587,9 +587,18 @@ def save_listings(
price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2,
-- 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,
balconies_count = EXCLUDED.balconies_count,
balconies_count = COALESCE(
EXCLUDED.balconies_count, listings.balconies_count
),
loggias_count = EXCLUDED.loggias_count,
description_minhash = EXCLUDED.description_minhash,
cadastral_number = EXCLUDED.cadastral_number,
@ -742,8 +751,11 @@ def save_listings(
listings.price_previous_rub, listings.newbuilding_id,
listings.newbuilding_url, listings.card_hash, listings.is_active
) IS DISTINCT FROM (
EXCLUDED.price_rub, EXCLUDED.price_per_m2, EXCLUDED.living_area_m2,
EXCLUDED.bedrooms_count, EXCLUDED.balconies_count, EXCLUDED.loggias_count,
EXCLUDED.price_rub, EXCLUDED.price_per_m2,
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.building_cadastral_number,
-- #3063: те же COALESCE, что в SET выше. Правая часть гейта ОБЯЗАНА
@ -823,9 +835,9 @@ def save_listings(
is_active = true,
price_rub = :price_rub,
price_per_m2 = :ppm2,
living_area_m2 = :living_area_m2,
living_area_m2 = COALESCE(:living_area_m2, living_area_m2),
bedrooms_count = :bedrooms_count,
balconies_count = :balconies_count,
balconies_count = COALESCE(:balconies_count, balconies_count),
loggias_count = :loggias_count,
description_minhash = :description_minhash,
cadastral_number = :cadastral_number,
@ -940,7 +952,7 @@ def save_listings(
skip_snapshot = today_row is not None
if skip_snapshot:
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,
listing_id,
)

View file

@ -656,6 +656,13 @@ class BrowserFetcher:
}
if wait_ms is not None:
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:
return await self._post_login(body)

View file

@ -36,7 +36,7 @@ from __future__ import annotations
import asyncio
import logging
import random
from collections.abc import Callable
from collections.abc import Callable, Mapping
from contextlib import AsyncExitStack
from dataclasses import dataclass, field, fields
from datetime import date, timedelta
@ -1346,6 +1346,43 @@ async def run_avito_pipeline(
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
class CitySweepCounters:
"""Aggregate counters для full city sweep run."""
@ -1629,6 +1666,12 @@ async def run_avito_city_sweep(
# #3074: сбрасывается на КАЖДОЙ итерации — иначе один упавший якорь
# заразил бы все последующие, и чекпоинт не пополнялся бы вовсе.
_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
# if the coroutine is scheduled after the loop variable changes.
@ -2137,6 +2180,22 @@ async def run_avito_city_sweep(
# его как пройденный значило бы, что следующий прогон его пропустит и
# объявления оттуда не соберутся НИКОГДА, причём молча: прогон
# завершится штатно. Тот же инвариант, что у 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:
_done_anchors.add(name)
runs.update_heartbeat(db, run_id, _ckpt())
@ -3313,6 +3372,10 @@ async def run_cian_city_sweep(
)
# ── Per-anchor phases под watchdog-таймаутом ────────────────────
# #3415: counters у свипа run-level — снимок ДО якоря даёт бухгалтерию
# его собственных фаз (разницей), по которой ниже решается, считать ли
# якорь пройденным.
_before_anchor = counters.to_dict()
anchor_lots: list[ScrapedLot] = []
_c_lat, _c_lon, _c_name = lat, lon, name
@ -3529,6 +3592,12 @@ async def run_cian_city_sweep(
len(nb_id_list),
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)
try:
db.rollback()
@ -3720,7 +3789,28 @@ async def run_cian_city_sweep(
# Записать упавший якорь пройденным значило бы, что следующий прогон
# пропустит его навсегда — молча, потому что прогон завершится
# штатно, просто часть города не соберётся.
#
# #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(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)}
)
@ -4902,10 +4992,13 @@ async def run_domclick_city_sweep(
)
lots: list[ScrapedLot] = []
# #2406: собранное сохраняется ОДНИМ save_listings после всех корзин, поэтому
# корзина считается пройденной только если фаза дошла до конца (см. чекпоинт ниже).
_saved = False
async def _domclick_phase() -> None:
"""Единственная citywide-фаза: fetch_city + save."""
nonlocal lots
nonlocal lots, _saved
async with DomClickScraper(
config,
proxy_provider=proxy_provider,
@ -4951,6 +5044,7 @@ async def run_domclick_city_sweep(
)
counters.lots_inserted += inserted
counters.lots_updated += updated
_saved = True
try:
await asyncio.wait_for(_domclick_phase(), timeout=_sweep_timeout)
@ -5004,6 +5098,11 @@ async def run_domclick_city_sweep(
# #3118: чекпоинт = унаследованное завершённое в этом прогоне.
# Пишем heartbeat'ом СЕЙЧАС (мерж jsonb) — финализаторы ключ не
# затирают, и оборванный болезнью финализации прогон его не теряет.
# #2406: только если лоты сохранены. Снятая watchdog'ом или упавшая фаза
# не дошла до save_listings — у её корзин в БД ноль строк, а отметка
# «пройдена» заставила бы следующий прогон пропустить их навсегда
# (механизм разобран в миграции 308, из-за него выключены свипы 77/50).
if _saved:
_checkpoint = sorted(skip_buckets | set(_s.completed_buckets))
runs.update_heartbeat(db, run_id, _payload())
counters.bucket_start_index = _s.bucket_start_index

View file

@ -46,6 +46,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.orchestration.run_context import current_run_id
from scraper_kit.proxy_errors import caused_by_no_proxy
try: # sentry опционален — kit не тянет его в зависимостях
import sentry_sdk
@ -815,11 +816,19 @@ def honors_cancel(source: str) -> 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(
text("SELECT status FROM scrape_runs WHERE id = :id"),
{"id": run_id},
).fetchone()
db.commit()
return row is not None and row.status == "cancelled"
@ -1009,6 +1018,37 @@ def mark_banned(
_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:
"""Диагноз по переписи блоков прогона: kind -> сколько раз он встретился (#3178).

View file

@ -1024,8 +1024,11 @@ async def _dispatch(
run_db = ctx.session_factory()
try:
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)
# #1940: исключение мимо try-финализатора хендлера оставляло строку
# 'running' до zombie через 6 ч. См. runs.mark_crashed.
ctx.runs.mark_crashed(run_db, run_id, exc)
finally:
run_db.close()

View file

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

View file

@ -34,7 +34,7 @@ from uuid import UUID
from sqlalchemy import text
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.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.
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). Имитируют
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
_COMMON_HEADERS = {
@ -486,8 +473,7 @@ async def evaluate_via_imv(
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
# браузер принадлежит caller'у — backfill открывает один на батч). Этот путь
# НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке.
# браузер принадлежит caller'у — backfill открывает один на батч).
# AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up +
# geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию;
# для browser_fetcher / переданной cffi_session — no-op. Исключение из блока
@ -499,53 +485,24 @@ async def evaluate_via_imv(
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
_own_session = True
else:
# Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети
try:
from curl_cffi.requests import AsyncSession as CffiAsyncSession
_own_session = False
if cffi_session is None:
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162):
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout.
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito
# банит на server-IP.
# Общая document-сессия из _base (#2386): impersonate=DEFAULT_IMPERSONATE TLS +
# DOCUMENT_HEADERS + timeout. Затем warm-up GET для seed anti-bot cookies —
# bare-session XHR Avito банит на server-IP.
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
# scraper_proxy_url. proxy=None → прямое подключение (dev).
#
# NB (#2361 F4a): НЕ мигрировано на build_document_session() из
# scraper_kit.providers._base, хотя headers=_DOC_HEADERS — тот же
# дублированный dict, что и там. _base.py делает module-level
# `from curl_cffi.requests import AsyncSession` (биндится ОДИН раз
# при первом импорте _base) — tests/scrapers/test_avito_imv_kit_parity.py
# (#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, а не тихо чиню тест.
# Тесты подменяют конструктор через `scraper_kit.providers._base.AsyncSession`.
_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,
cffi_session = build_document_session(
proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC
)
_own_session = True
except ImportError as exc:
raise RuntimeError(
"curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
) from exc
try:
if _own_session:
@ -579,7 +536,7 @@ async def _warmup(session: Any) -> None:
всё равно попробуют и дадут типизированную ошибку.
"""
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)
except Exception as exc:
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную

View file

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

View file

@ -27,7 +27,8 @@ _extract_ssr_state делает balanced-brace scan (с пропуском ск
Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27):
productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo.
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},
и ТОП-УРОВНЕМ pricePrediction (DomClick AVM raw_extra.avm).
@ -283,6 +284,29 @@ def _to_int(value: Any) -> int | 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:
"""Нормализует дату изменения цены → timezone-aware datetime.
@ -436,8 +460,8 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment:
repair_state = _map_repair_state(repair_type)
# площади
living_area_m2 = _to_float(oi.get("livingArea"))
kitchen_area_m2 = _to_float(oi.get("kitchenArea"))
living_area_m2 = _pos_float(oi.get("livingArea"))
kitchen_area_m2 = _pos_float(oi.get("kitchenArea"))
# балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None
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")
# owners_count — защищаемся от двух написаний ключа
owners_count = _to_int(egrn.get("owners_count"))
# owners_count — обёртка {status, value} (см. _egrn_value); до #3252 int()
# от словаря молча давал None: 0 из ~2970 карточек, обогащённых с 29.08.
owners_count = _to_int(_egrn_value(egrn.get("owners_count")))
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 обозначают
# НАЛИЧИЕ обременения. Любой 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 ОСТАЮТСЯ тут,
# НЕ пишем в listings.house_type (кросс-источниковый словарь грязный).
# egrn area key — точное написание не подтверждено; пробуем несколько.
egrn_area = egrn.get("area") or egrn.get("rosreestrArea") or egrn.get("object_area")
# egrnData.area — ключ подтверждён на живых карточках 17.09 (#3252). В raw_payload
# кладём обёртку {status, value} целиком: status говорит, сошлась ли площадь
# ЕГРН с площадью объявления, и уже лежит в таком виде у 2156 строк.
egrn_area = egrn.get("area")
demand = _compact(
{
"calls": pc.get("callsCount"),

View file

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