diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index aa3abd64..1f24dd8c 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -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()
diff --git a/tradein-mvp/backend/app/api/v1/glitchtip.py b/tradein-mvp/backend/app/api/v1/glitchtip.py
index 0539698e..7de099de 100644
--- a/tradein-mvp/backend/app/api/v1/glitchtip.py
+++ b/tradein-mvp/backend/app/api/v1/glitchtip.py
@@ -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,19 +243,21 @@ async def glitchtip_webhook(
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
try:
- await client.send_message(
- chat_id=settings.telegram_alerts_chat_id,
- text=text,
- message_thread_id=settings.telegram_alerts_topic_id or None,
- # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
- # может легально висеть воркерные минуты ретраев.
- timeout=_INTERACTIVE_SEND_TIMEOUT_S,
- max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
- )
- except TelegramError:
+ async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
+ await client.send_message(
+ chat_id=settings.telegram_alerts_chat_id,
+ text=text,
+ message_thread_id=settings.telegram_alerts_topic_id or None,
+ # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
+ # может легально висеть воркерные минуты ретраев.
+ timeout=_INTERACTIVE_SEND_TIMEOUT_S,
+ max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
+ )
+ 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).
diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py
index f16525eb..a6fe69f3 100644
--- a/tradein-mvp/backend/app/api/v1/trade_in.py
+++ b/tradein-mvp/backend/app/api/v1/trade_in.py
@@ -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),
)
diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py
index d291f723..0a2b15de 100644
--- a/tradein-mvp/backend/app/core/config.py
+++ b/tradein-mvp/backend/app/core/config.py
@@ -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²)×(1−tol)
- estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > 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. Зависший
diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py
index 00d6c074..922098c0 100644
--- a/tradein-mvp/backend/app/schemas/trade_in.py
+++ b/tradein-mvp/backend/app/schemas/trade_in.py
@@ -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:
diff --git a/tradein-mvp/backend/app/services/backfill_block_breaker.py b/tradein-mvp/backend/app/services/backfill_block_breaker.py
index 75352846..9c55100d 100644
--- a/tradein-mvp/backend/app/services/backfill_block_breaker.py
+++ b/tradein-mvp/backend/app/services/backfill_block_breaker.py
@@ -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} в окне "
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index cd477ac6..8beb6cd1 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -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²)×(1−tol)
+SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > 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)
diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py
index dcbe755a..94053726 100644
--- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py
@@ -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,
diff --git a/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql b/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql
new file mode 100644
index 00000000..e2147b23
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql
@@ -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.08–14.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;
diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py
index 63f1785b..1a4b1645 100644
--- a/tradein-mvp/backend/scripts/backtest_estimator.py
+++ b/tradein-mvp/backend/scripts/backtest_estimator.py
@@ -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,83 +1990,60 @@ 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"]))
- kw["geo"] = est.GeocodeResult(**kw["geo"])
- kw["ratio_resolver"] = _make_call_stub(
- rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
- )
- # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
- # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
- # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
- # которое надо знать при чтении метрик: для этих сделок квартальный индекс в
- # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
- # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
- # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
- kw["quarter_index_lookup"] = _make_call_stub(
- rec.get("qi_calls") or [],
- label="quarter_index_lookup",
- coerce=_coerce_qi_return,
- on_exhausted=None,
- unrecorded_counter=unrecorded,
- )
- kw["quarter_indexes_lookup"] = _make_call_stub(
- rec.get("qis_calls") or [],
- label="quarter_indexes_lookup",
- coerce=_coerce_qis_return,
- on_exhausted={},
- unrecorded_counter=unrecorded,
- )
+ for rec in deals:
+ kw = dict(rec["kwargs"])
+ sold_ppm2_all.append(float(rec["sold_ppm2"]))
+ kw["geo"] = est.GeocodeResult(**kw["geo"])
+ kw["ratio_resolver"] = _make_call_stub(
+ rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
+ )
+ # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
+ # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
+ # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
+ # которое надо знать при чтении метрик: для этих сделок квартальный индекс в
+ # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
+ # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
+ # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
+ kw["quarter_index_lookup"] = _make_call_stub(
+ rec.get("qi_calls") or [],
+ label="quarter_index_lookup",
+ coerce=_coerce_qi_return,
+ on_exhausted=None,
+ unrecorded_counter=unrecorded,
+ )
+ kw["quarter_indexes_lookup"] = _make_call_stub(
+ rec.get("qis_calls") or [],
+ label="quarter_indexes_lookup",
+ coerce=_coerce_qis_return,
+ on_exhausted={},
+ unrecorded_counter=unrecorded,
+ )
- pr = m._price_from_inputs(**kw)
+ pr = m._price_from_inputs(**kw)
- _es_ppm2_raw = pr.expected_sold_per_m2
- _es_price_raw = pr.expected_sold_price
- _r_low_raw = pr.expected_sold_range_low
- _r_high_raw = pr.expected_sold_range_high
- es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
- es_price = float(_es_price_raw) if _es_price_raw is not None else None
- r_low = float(_r_low_raw) if _r_low_raw is not None else None
- r_high = float(_r_high_raw) if _r_high_raw is not None else None
- prediction = Prediction(
- deal_id=int(rec["deal_id"]),
- rooms=rec["rooms"],
- area_m2=float(rec["area_m2"]),
- sold_ppm2=float(rec["sold_ppm2"]),
- median_ppm2=float(pr.median_ppm2),
- confidence=pr.confidence,
- anchor_tier=pr.anchor_tier,
- expected_sold_ppm2=es_ppm2,
- expected_sold_price=es_price,
- range_low=r_low,
- range_high=r_high,
- )
- predictions.append(prediction)
- pred_ppm2_all.append(prediction.median_ppm2)
-
- finally:
- m.settings.estimate_dedup_analogs_enabled = _dedup_saved
+ _es_ppm2_raw = pr.expected_sold_per_m2
+ _es_price_raw = pr.expected_sold_price
+ _r_low_raw = pr.expected_sold_range_low
+ _r_high_raw = pr.expected_sold_range_high
+ es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
+ es_price = float(_es_price_raw) if _es_price_raw is not None else None
+ r_low = float(_r_low_raw) if _r_low_raw is not None else None
+ r_high = float(_r_high_raw) if _r_high_raw is not None else None
+ prediction = Prediction(
+ deal_id=int(rec["deal_id"]),
+ rooms=rec["rooms"],
+ area_m2=float(rec["area_m2"]),
+ sold_ppm2=float(rec["sold_ppm2"]),
+ median_ppm2=float(pr.median_ppm2),
+ confidence=pr.confidence,
+ anchor_tier=pr.anchor_tier,
+ expected_sold_ppm2=es_ppm2,
+ expected_sold_price=es_price,
+ range_low=r_low,
+ range_high=r_high,
+ )
+ predictions.append(prediction)
+ pred_ppm2_all.append(prediction.median_ppm2)
# 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",
diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
index 12017ca0..6b445063 100644
--- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
+++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json
@@ -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,
diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py
index 3cc4e08e..7da60d9c 100644
--- a/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py
+++ b/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py
@@ -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
diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py
index 68f606d4..8bcccd4e 100644
--- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py
+++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py
@@ -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
diff --git a/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py b/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py
index f3b3519e..6459bbbc 100644
--- a/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py
+++ b/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py
@@ -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="no results")
- 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
diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt
index 69c61d4b..f10a9f75 100644
--- a/tradein-mvp/backend/tests/skip_allowlist.txt
+++ b/tradein-mvp/backend/tests/skip_allowlist.txt
@@ -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.
diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
index 9e963679..e2c89336 100644
--- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py
@@ -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)."""
diff --git a/tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py b/tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py
new file mode 100644
index 00000000..676f03df
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_1940_crashed_run_is_finalized.py
@@ -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)
diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py
new file mode 100644
index 00000000..a2421aac
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_2406_sweep_edge_cases.py b/tradein-mvp/backend/tests/test_2406_sweep_edge_cases.py
new file mode 100644
index 00000000..feeac535
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2406_sweep_edge_cases.py
@@ -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"
diff --git a/tradein-mvp/backend/tests/test_3045_use_curl_default.py b/tradein-mvp/backend/tests/test_3045_use_curl_default.py
new file mode 100644
index 00000000..893d740b
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3045_use_curl_default.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_3182_detail_transport_one_value.py b/tradein-mvp/backend/tests/test_3182_detail_transport_one_value.py
new file mode 100644
index 00000000..446a74dd
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3182_detail_transport_one_value.py
@@ -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")
diff --git a/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py b/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py
index ba6f2586..3a2b22bc 100644
--- a/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py
+++ b/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py
@@ -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")])
diff --git a/tradein-mvp/backend/tests/test_3234_tier_h_settings.py b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py
new file mode 100644
index 00000000..b7480b7a
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3234_tier_h_settings.py
@@ -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)
diff --git a/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py b/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py
new file mode 100644
index 00000000..d5af85af
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py
@@ -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""
+ 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)
diff --git a/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py b/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py
index 61fd0460..a43dcefd 100644
--- a/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py
+++ b/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py
@@ -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 — рвать не по чему"
+ )
diff --git a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py
index d91726f3..0e820899 100644
--- a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py
+++ b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py
@@ -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(
diff --git a/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py b/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py
new file mode 100644
index 00000000..6b0155a8
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3415_bucket_done_only_if_phases_ok.py
@@ -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"], (
+ "унаследованный якорь потерян — формат чекпоинта разъехался со старыми прогонами"
+ )
diff --git a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py
index afcdabf7..75a32859 100644
--- a/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py
+++ b/tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py
@@ -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()), (
diff --git a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
new file mode 100644
index 00000000..72542446
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_3480_no_open_transaction_over_http.py b/tradein-mvp/backend/tests/test_3480_no_open_transaction_over_http.py
new file mode 100644
index 00000000..8d1e8011
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3480_no_open_transaction_over_http.py
@@ -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]
diff --git a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py
index 4e7ee9e4..b648afba 100644
--- a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py
+++ b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py
@@ -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 по-прежнему в снимке
diff --git a/tradein-mvp/backend/tests/test_backtest_regression_gate.py b/tradein-mvp/backend/tests/test_backtest_regression_gate.py
index 0275db49..8f963aa5 100644
--- a/tradein-mvp/backend/tests/test_backtest_regression_gate.py
+++ b/tradein-mvp/backend/tests/test_backtest_regression_gate.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py
index d2dff0d0..5ed999e1 100644
--- a/tradein-mvp/backend/tests/test_estimate_idor.py
+++ b/tradein-mvp/backend/tests/test_estimate_idor.py
@@ -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"))
diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py
index d41f2e4d..3957f26d 100644
--- a/tradein-mvp/backend/tests/test_estimate_revival.py
+++ b/tradein-mvp/backend/tests/test_estimate_revival.py
@@ -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]
diff --git a/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py
index c69cc3cc..ccb82884 100644
--- a/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py
+++ b/tradein-mvp/backend/tests/test_estimator_analogs_display_consistency.py
@@ -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 ошибочно поймал дрейф — тест не то мерит"
diff --git a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py
index 1a7386f1..9987f3e9 100644
--- a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py
+++ b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py
index f6cb2430..83833adc 100644
--- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py
+++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py
index 2b8f2a3c..3d7ef194 100644
--- a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py
+++ b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py
@@ -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]),
diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py
index 4564c412..260fd49d 100644
--- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py
+++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
index ec38f8a5..290f03b7 100644
--- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
+++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
@@ -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)
diff --git a/tradein-mvp/backend/tests/test_estimator_hedonic.py b/tradein-mvp/backend/tests/test_estimator_hedonic.py
index d21d3302..1424d934 100644
--- a/tradein-mvp/backend/tests/test_estimator_hedonic.py
+++ b/tradein-mvp/backend/tests/test_estimator_hedonic.py
@@ -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.
diff --git a/tradein-mvp/backend/tests/test_estimator_manual_review.py b/tradein-mvp/backend/tests/test_estimator_manual_review.py
index d2c9d916..681c934c 100644
--- a/tradein-mvp/backend/tests/test_estimator_manual_review.py
+++ b/tradein-mvp/backend/tests/test_estimator_manual_review.py
@@ -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)
diff --git a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py
index d73a9364..251d5964 100644
--- a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py
+++ b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py
@@ -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,
diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py
index 162fa2cc..b0aa665c 100644
--- a/tradein-mvp/backend/tests/test_estimator_price_spine.py
+++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py
@@ -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),
diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py
index 13b89e2d..5cb7c1bd 100644
--- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py
+++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py
@@ -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:
diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py
index aba07b00..0f3e8485 100644
--- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py
+++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py
@@ -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"
+ )
diff --git a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
index 5b824132..1e65e2b6 100644
--- a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
+++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py
index f594deb4..afc2ea1b 100644
--- a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py
+++ b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
index 48b3fe3e..92b6d370 100644
--- a/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
+++ b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
@@ -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:
diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py
index 6c19c9e5..81a3e8f9 100644
--- a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py
+++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py
@@ -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 == []
diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py
index 0f0502ee..268adbbd 100644
--- a/tradein-mvp/backend/tests/test_same_building_anchor.py
+++ b/tradein-mvp/backend/tests/test_same_building_anchor.py
@@ -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
diff --git a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py
index ae15377e..755cb7c7 100644
--- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py
+++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py
@@ -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("")
- 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("")
- 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("")
- 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=` Яндексом
@@ -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(
'сама страница'
)
- 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("")
+ 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
diff --git a/tradein-mvp/backend/tests/test_scraper_proxy.py b/tradein-mvp/backend/tests/test_scraper_proxy.py
index 165539d8..44e8539c 100644
--- a/tradein-mvp/backend/tests/test_scraper_proxy.py
+++ b/tradein-mvp/backend/tests/test_scraper_proxy.py
@@ -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]
diff --git a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py
index 373ab2b0..abc94f4e 100644
--- a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py
+++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py
@@ -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, ничего не схлопнуто.
diff --git a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py
index f5e413fc..b4df3078 100644
--- a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py
+++ b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py
@@ -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
diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py
index 71de6501..4cbc3a8c 100644
--- a/tradein-mvp/browser/server.py
+++ b/tradein-mvp/browser/server.py
@@ -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
- response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
+ nav_started = time.monotonic()
+ try:
+ response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
+ finally:
+ _last_nav_ms[provider] = int((time.monotonic() - nav_started) * 1000)
_last_response_status[provider] = _status_of(response)
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,
diff --git a/tradein-mvp/browser/test_server_anchor_search.py b/tradein-mvp/browser/test_server_anchor_search.py
index c1cec4ba..2ced4212 100644
--- a/tradein-mvp/browser/test_server_anchor_search.py
+++ b/tradein-mvp/browser/test_server_anchor_search.py
@@ -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}]
diff --git a/tradein-mvp/browser/test_server_nav_timing.py b/tradein-mvp/browser/test_server_nav_timing.py
new file mode 100644
index 00000000..68330b9e
--- /dev/null
+++ b/tradein-mvp/browser/test_server_nav_timing.py
@@ -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 "карточка"
+
+ 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]
diff --git a/tradein-mvp/browser/test_server_no_proxy_refusal.py b/tradein-mvp/browser/test_server_no_proxy_refusal.py
index b74321ec..c000152a 100644
--- a/tradein-mvp/browser/test_server_no_proxy_refusal.py
+++ b/tradein-mvp/browser/test_server_no_proxy_refusal.py
@@ -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)" ────────
diff --git a/tradein-mvp/browser/test_server_proxy_override.py b/tradein-mvp/browser/test_server_proxy_override.py
index 2ec4fdd4..dd124b6b 100644
--- a/tradein-mvp/browser/test_server_proxy_override.py
+++ b/tradein-mvp/browser/test_server_proxy_override.py
@@ -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 == []
diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml
index 191dfd8a..5aa3ef03 100644
--- a/tradein-mvp/docker-compose.prod.yml
+++ b/tradein-mvp/docker-compose.prod.yml
@@ -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
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index db30df9a..d03f2419 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -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;
diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryCorridorNote.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryCorridorNote.test.tsx
new file mode 100644
index 00000000..54009c31
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryCorridorNote.test.tsx
@@ -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(
+ {}} />,
+ );
+ 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("коридор её не ограничивает");
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts
new file mode 100644
index 00000000..e72710e1
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/corridorAdvisoryNote.test.ts
@@ -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): 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) — коридор ориентировочный");
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
index e907526a..5f33f713 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
@@ -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) {
diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts
index 3e92bb8a..f5cb7b68 100644
--- a/tradein-mvp/frontend/src/lib/rosreestr.ts
+++ b/tradein-mvp/frontend/src/lib/rosreestr.ts
@@ -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 —
// похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и
// есть цена, и клиент обязан знать, на скольких сделках она стоит.
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index be0846ec..bcec3816 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -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;
}
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py
index d8676724..f470f51e 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py
@@ -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,
)
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
index a2587ab2..afdcc228 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
@@ -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)
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
index f9ad6e60..0c69733b 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
@@ -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(
# Записать упавший якорь пройденным значило бы, что следующий прогон
# пропустит его навсегда — молча, потому что прогон завершится
# штатно, просто часть города не соберётся.
- _done_anchors.add(name)
+ #
+ # #3415: потока управления мало. Фаза внутри якоря отказывает БЕЗ
+ # исключения — `fetch_newbuilding` возвращает None, счётчик растёт,
+ # `_cian_anchor_phases` завершается штатно, — и такой якорь доходил
+ # сюда наравне с целым. Дальше ровно тот же исход, что и у записанного
+ # упавшего: следующий прогон пропускает якорь и рапортует `done` с
+ # нулевой фазой. Форма отметки прежняя (плоский список имён): чекпоинт
+ # читают ещё три свипа и `scheduler._resume_decision`, а ключ
+ # `bucket:phase` потребовал бы новых читателей ради того же решения.
+ _failed_phase = _bucket_phase_totally_failed(_before_anchor, counters.to_dict())
+ if _failed_phase is None:
+ _done_anchors.add(name)
+ else:
+ logger.warning(
+ "cian-sweep run_id=%d: якорь %s НЕ помечен пройденным — фаза '%s' "
+ "не дала ни одной удачной попытки; следующий прогон соберёт его заново",
+ run_id,
+ name,
+ _failed_phase,
+ )
+ # Heartbeat пишем в любом случае: без него reap_zombies посчитает живой
+ # прогон мёртвым, а jsonb-мерж сохранит унаследованную часть точки.
runs.update_heartbeat(
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,7 +5098,12 @@ async def run_domclick_city_sweep(
# #3118: чекпоинт = унаследованное ∪ завершённое в этом прогоне.
# Пишем heartbeat'ом СЕЙЧАС (мерж jsonb) — финализаторы ключ не
# затирают, и оборванный болезнью финализации прогон его не теряет.
- _checkpoint = sorted(skip_buckets | set(_s.completed_buckets))
+ # #2406: только если лоты сохранены. Снятая watchdog'ом или упавшая фаза
+ # не дошла до save_listings — у её корзин в БД ноль строк, а отметка
+ # «пройдена» заставила бы следующий прогон пропустить их навсегда
+ # (механизм разобран в миграции 308, из-за него выключены свипы 77/50).
+ if _saved:
+ _checkpoint = sorted(skip_buckets | set(_s.completed_buckets))
runs.update_heartbeat(db, run_id, _payload())
counters.bucket_start_index = _s.bucket_start_index
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
index 4b38558a..2053530b 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py
@@ -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).
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py
index 7923efbe..51bd6702 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py
@@ -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()
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py
index f78c0bdb..b21b8aa4 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py
@@ -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);
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py
index ae5bd34f..4c9915c2 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py
@@ -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.
- # Прокси: пул за флагом 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, а не тихо чиню тест.
- _env = config.scraper_proxy_url if config is not None else None
- # acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
- # стоял ДО первого await и держал event loop `/estimate`.
- _proxy_url = await _proxy_stack.enter_async_context(
- acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env)
- )
- _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
- cffi_session = CffiAsyncSession(
- impersonate=DEFAULT_IMPERSONATE,
- timeout=_HTTP_TIMEOUT_SEC,
- proxies=_proxies,
- headers=_DOC_HEADERS,
- )
- _own_session = True
- except ImportError as exc:
- raise RuntimeError(
- "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
- ) from exc
+ _own_session = False
+ if cffi_session is None:
+ # Общая 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).
+ # Тесты подменяют конструктор через `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)
+ )
+ cffi_session = build_document_session(
+ proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC
+ )
+ _own_session = True
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 опционален: логируем и продолжаем — шаги ниже дадут типизированную
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py
index 74a239b0..b59a4aaa 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py
@@ -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:
- browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
- else:
- browser = BrowserFetcher(source="cian", endpoint=None) # type: ignore[arg-type]
+ # config обязателен (#2385): запасная ветка `config=None` строила
+ # BrowserFetcher(endpoint=None) мимо фабрики — новый вызывающий без config молча уходил
+ # на env-узел сайдкара, тот самый путь, что стоил 8 суток выше.
+ browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
# 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,
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py
index 66912922..2a845426 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py
@@ -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"),
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py
index 0e94266c..ad071b26 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py
@@ -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:
- 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]
+ # (образец: yandex/serp.py::__aenter__). Запасной ветки config=None больше нет (#2385).
+ fetcher_cm = build_browser_fetcher(
+ self._config, "yandex", proxy_provider=self._proxy_provider
+ )
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]
+ fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
try:
async with fetcher_cm as fetcher:
html = await fetcher.fetch(page_url)