Москва: пред-геокод Авито, Яндекс третьей площадкой, продукт отвечает по региону 77 #3440

Merged
bot-backend merged 9 commits from feat/msk-collector-cian into main 2026-09-11 22:30:12 +00:00
22 changed files with 5491 additions and 1017 deletions

7
.gitignore vendored
View file

@ -109,3 +109,10 @@ ops/metrics/alertmanager/alertmanager.yml
# и Prometheus не видел ни одного приёмника (#3155). Производный файл убирает
# сам зазор — правится только там же, где принимается решение о профиле.
ops/metrics/prometheus/alertmanager_targets.gen.yml
# Временные рабочие копии-worktree вида _wt-<тема>/ живут рядом с репозиторием
# и в него попадать не должны. 09.09 копия _wt-mskcol попала в индекс одним
# файлом сборщика, и три следующих фикса ушли в дубликат мимо канонического
# tradein-mvp/scripts/local-avito-msk/collect.py — дефект нашёлся только при
# сверке page size.
_wt-*/

View file

@ -1,785 +0,0 @@
#!/usr/bin/env python3
"""Локальный ручной сборщик SERP Авито по Москве и МО (эпик #2989, трек 1).
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
прокси-пул не задействованы вообще: браузер уже открытый Chrome владельца
(подключение по CDP), парсер импорт из scraper-kit, заливка поток в psql
через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
Дефолтный режим --measure 100 (замер): полный проход только по явному --full.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import io
import json
import math
import os
import random
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Iterable
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
# --- импорт парсера из scraper-kit без установки backend -------------------
_KIT_SRC = Path(__file__).resolve().parents[2] / "packages" / "scraper-kit" / "src"
if str(_KIT_SRC) not in sys.path:
sys.path.insert(0, str(_KIT_SRC))
from scraper_kit.providers.avito.serp import ( # noqa: E402
AvitoScraper,
_is_firewall_page,
)
# Вкладка, открытая у владельца: вторичка, Москва + МО.
DEFAULT_BASE_URL = (
"https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg"
"?f=ASgBAgICA0SSA8YQ5geMUuDC3c0CgOkP"
)
# Замерено живым проходом (не из документации): выдача Москва+МО отдаёт 50 карточек
# на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал
# последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных.
# Молчаливое усечение читается как «покрыто всё», поэтому число проверяется живьём.
PAGE_SIZE = 50 # карточек на странице выдачи
MAX_PAGES = 30 # потолок пагинации Авито → 30*50 = 1500 на один запрос
HARD_CAP = PAGE_SIZE * MAX_PAGES
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
PRICE_CEIL = 2_000_000_000
MIN_WIDTH_RATIO = 1.05 # уже этого коридор не делим (геометрическая ширина)
MAX_DEPTH = 12
_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_POW_MARKERS = ("startpow", "доступ ограничен: проверка безопасности")
# Заполняется при входе в Loader.__aenter__ (playwright импортируется лениво,
# чтобы --help работал без установленного пакета).
PlaywrightTimeoutError: type[BaseException] = TimeoutError
# Через столько загрузок вкладка сборщика пересоздаётся (см. _recycle_if_needed).
PAGE_RECYCLE_EVERY = 75
class Blocked(Exception):
"""Первый признак блока. Ретраев нет — только немедленный стоп."""
def __init__(self, reason: str, detail: str = "") -> None:
super().__init__(f"{reason}: {detail}" if detail else reason)
self.reason = reason
self.detail = detail
class BudgetExhausted(Exception):
"""Потолок --measure выбран: штатный выход, не ошибка."""
# --- план коридоров --------------------------------------------------------
@dataclass
class Corridor:
lo: int | None
hi: int | None
count: int | None = None
truncated: bool = False
pages_done: int = 0
status: str = "pending" # pending | done
missed: int = 0 # заведомо недобрано (count - HARD_CAP), если truncated
def label(self) -> str:
lo = "-" if self.lo is None else f"{self.lo:_}"
hi = "-" if self.hi is None else f"{self.hi:_}"
return f"[{lo} .. {hi}]"
def to_json(self) -> dict[str, Any]:
return {
"lo": self.lo, "hi": self.hi, "count": self.count,
"truncated": self.truncated, "pages_done": self.pages_done,
"status": self.status, "missed": self.missed,
}
@staticmethod
def from_json(d: dict[str, Any]) -> "Corridor":
return Corridor(
lo=d.get("lo"), hi=d.get("hi"), count=d.get("count"),
truncated=bool(d.get("truncated")),
pages_done=int(d.get("pages_done") or 0),
status=d.get("status") or "pending", missed=int(d.get("missed") or 0),
)
def planned_pages(self) -> int:
if not self.count:
return 1
return max(1, min(MAX_PAGES, math.ceil(self.count / PAGE_SIZE)))
@dataclass
class Plan:
base_url: str
target: int
batch_id: str
corridors: list[Corridor] = field(default_factory=list)
created_at: str = ""
def save(self, path: Path) -> None:
path.write_text(
json.dumps(
{
"version": 1, "base_url": self.base_url, "target": self.target,
"batch_id": self.batch_id, "created_at": self.created_at,
"corridors": [c.to_json() for c in self.corridors],
},
ensure_ascii=False, indent=1,
),
encoding="utf-8",
)
@staticmethod
def load(path: Path) -> "Plan":
d = json.loads(path.read_text(encoding="utf-8"))
return Plan(
base_url=d["base_url"], target=int(d["target"]), batch_id=d["batch_id"],
created_at=d.get("created_at", ""),
corridors=[Corridor.from_json(c) for c in d.get("corridors", [])],
)
def build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
"""URL коридора: pmin/pmax + пагинация. Гео-параметры не добавляем — #3043."""
parts = urlsplit(base_url)
q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
if k not in {"p", "pmin", "pmax"}]
if lo is not None:
q.append(("pmin", str(int(lo))))
if hi is not None:
q.append(("pmax", str(int(hi))))
if page > 1:
q.append(("p", str(page)))
return urlunsplit(
(parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
)
def geometric_mid(lo: int | None, hi: int) -> int:
"""Геометрическая середина коридора.
Цены логнормальны: арифметическая середина 1 млн..100 млн (50 млн)
отрезает вырожденно-пустую верхнюю половину. sqrt(lo*hi) делит выборку
заметно ровнее.
"""
low = max(int(lo or PRICE_FLOOR), 1)
mid = int(math.sqrt(low * float(hi)))
return max(low + 1, min(hi - 1, mid))
def width_ratio(lo: int | None, hi: int | None) -> float:
if hi is None:
return float("inf")
return float(hi) / max(float(lo or PRICE_FLOOR), 1.0)
# --- загрузка страницы -----------------------------------------------------
def _guard(html: str, status: int | None) -> None:
"""Порядок проверок фиксирован заданием; первое срабатывание = стоп."""
if status in (403, 439):
raise Blocked("platform", f"HTTP {status}")
if status == 429:
raise Blocked("ratelimit", "HTTP 429")
if _is_firewall_page(html):
raise Blocked("firewall", "firewall-страница на HTTP 200")
head = html[:4096].lower()
if any(m in head for m in _POW_MARKERS):
raise Blocked("challenge", "PoW / проверка безопасности")
class Loader:
"""Одна СВОЯ вкладка в уже открытом Chrome владельца (CDP).
Ни браузер, ни контекст, ни чужие вкладки не закрываются и не трогаются:
это рабочий Chrome с залогиненным техаккаунтом.
"""
def __init__(self, delay: float, page_budget: int | None) -> None:
self._delay = delay
self._budget = page_budget
self.loads = 0
self._page: Any = None
self._pw: Any = None
self._browser: Any = None
self._ctx: Any = None
self._last_load = 0.0
self._loads_on_page = 0
async def __aenter__(self) -> "Loader":
from playwright.async_api import async_playwright
from playwright.async_api import TimeoutError as _PwTimeout
global PlaywrightTimeoutError
PlaywrightTimeoutError = _PwTimeout
endpoint = os.environ.get("AVITO_CDP", "http://localhost:9222")
self._pw = await async_playwright().start()
try:
self._browser = await self._pw.chromium.connect_over_cdp(endpoint)
except Exception as exc: # noqa: BLE001 — подсказка важнее типа
await self._pw.stop()
raise SystemExit(
f"Не удалось подключиться по CDP к {endpoint}: {exc}\n"
"Запусти Chrome с залогиненным техаккаунтом Авито и ключом "
"--remote-debugging-port=9222, либо укажи адрес в AVITO_CDP."
) from exc
if not self._browser.contexts:
await self._pw.stop()
raise SystemExit(
"В подключённом Chrome нет ни одного контекста. Открой обычное окно "
"Chrome, запущенное с --remote-debugging-port=9222."
)
self._ctx = self._browser.contexts[0]
self._page = await self._ctx.new_page()
return self
async def __aexit__(self, *exc: object) -> None:
if self._page is not None:
try:
await self._page.close() # ТОЛЬКО своя вкладка
except Exception: # noqa: BLE001
pass
if self._pw is not None:
try:
await self._pw.stop()
except Exception: # noqa: BLE001
pass
def budget_left(self) -> bool:
return self._budget is None or self.loads < self._budget
async def _pause(self) -> None:
if self._last_load == 0.0:
return
jitter = self._delay * random.uniform(-0.2, 0.2)
wait = max(0.0, self._delay + jitter - (time.monotonic() - self._last_load))
if wait > 0:
print(f" пауза {wait:.1f} с", flush=True)
await asyncio.sleep(wait)
async def fetch(self, url: str) -> tuple[str, int | None]:
if not self.budget_left():
raise BudgetExhausted()
await self._recycle_if_needed()
await self._pause()
resp = await self._goto(url)
self._loads_on_page += 1
self.loads += 1
self._last_load = time.monotonic()
status = resp.status if resp is not None else None
try:
await self._page.wait_for_selector('[data-marker="item"]', timeout=7_000)
except Exception: # noqa: BLE001 — пустая/блочная страница разбирается ниже
pass
html = await self._read_content()
_guard(html, status)
return html, status
async def _recycle_if_needed(self) -> None:
"""Каждые PAGE_RECYCLE_EVERY загрузок пересоздаём свою вкладку.
Рендерер Chrome накапливает память по всем навигациям вкладки, а проход
по Москве под тысячу страниц в одной. Наблюдалось живьём: вкладка
падала с «Опаньки Код ошибки: Out of Memory» при 34 ГБ свободных в
системе, то есть упирался именно рендерер, а не машина. Свежая вкладка
стоит одну навигацию и обнуляет счёт.
Закрывается ТОЛЬКО своя вкладка; контекст и чужие вкладки владельца не
трогаются это его рабочий Chrome.
"""
if self._loads_on_page < PAGE_RECYCLE_EVERY:
return
print(f" вкладка пересоздаётся после {self._loads_on_page} загрузок "
"(память рендерера)", flush=True)
old = self._page
self._page = await self._ctx.new_page()
self._loads_on_page = 0
try:
await old.close()
except Exception: # noqa: BLE001 — старая вкладка могла уже умереть
pass
async def _goto(self, url: str, attempts: int = 3):
"""goto с ограниченным ретраем на таймаут навигации.
Авито изредка держит соединение до упора и goto падает по timeout. Это
НЕ признак отказа: в наблюдавшемся случае вкладка показывала нормальную
выдачу, а маркеров фаервола/PoW не было. Но и молча ретраить бесконечно
нельзя тихий отказ выглядит ровно так же. Поэтому: перед каждым
повтором пробуем прочитать то, что в документе, и прогнать через _guard,
чтобы настоящий блок остановил прогон с правильной причиной; исчерпали
попытки жёсткий стоп с причиной nav_timeout.
"""
for i in range(attempts):
try:
return await self._page.goto(url, wait_until="domcontentloaded",
timeout=90_000)
except PlaywrightTimeoutError:
try:
partial = await self._page.content()
except Exception: # noqa: BLE001 — документа может не быть вовсе
partial = ""
if partial:
_guard(partial, None) # настоящий блок остановит прогон здесь
if i == attempts - 1:
raise Blocked("nav_timeout") from None
print(f" таймаут навигации, попытка {i + 2}/{attempts}", flush=True)
await asyncio.sleep(10.0)
async def _read_content(self, attempts: int = 4) -> str:
"""page.content() с узким ретраем на гонку клиентской перенавигации.
Авито дорисовывает выдачу после domcontentloaded, и content() иногда
попадает ровно в момент смены документа: "Unable to retrieve content
because the page is navigating and changing the content". Это НЕ отказ
площадки гвардов не касается, поэтому ретраим только эту ошибку и
только её, а любую другую поднимаем как есть.
"""
last: Exception | None = None
for i in range(attempts):
try:
return await self._page.content()
except Exception as exc: # noqa: BLE001 — сузили проверкой текста ниже
if "page is navigating" not in str(exc):
raise
last = exc
print(f" content() поймал перенавигацию, попытка {i + 2}/{attempts}",
flush=True)
await asyncio.sleep(1.5)
raise RuntimeError(f"page.content() не отдал документ за {attempts} попыток") from last
# --- заливка в msk_raw -----------------------------------------------------
def _sql_str(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _csv_rows(rows: Iterable[dict[str, Any]]) -> str:
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
for r in rows:
writer.writerow([
r["source_id"], r["observed_at"], r["batch_id"], r["kind"],
r["url"], r["price"], r["payload"],
])
return buf.getvalue()
def build_sql(batch_id: str, query: str, rows: list[dict[str, Any]],
started_at: str, kind: str = "serp") -> str:
"""Один поток на `psql -f -`: batch (FK!) → TEMP staging → \\copy → INSERT.
Одиночный `psql -c` через ssh ломается на квотинге скобок и кавычек, поэтому
только поток. rows_new = разница count(*) по batch_id до и после вставки.
"""
bid = _sql_str(batch_id)
return (
"BEGIN;\n"
"INSERT INTO msk_raw.batches (batch_id, kind, query, started_at)\n"
f"VALUES ({bid}, {_sql_str(kind)}, {_sql_str(query)}, "
f"CAST({_sql_str(started_at)} AS timestamptz))\n"
"ON CONFLICT (batch_id) DO NOTHING;\n"
"CREATE TEMP TABLE _stg (LIKE msk_raw.avito_cards INCLUDING DEFAULTS) "
"ON COMMIT DROP;\n"
"CREATE TEMP TABLE _before ON COMMIT DROP AS\n"
f" SELECT count(*) AS n FROM msk_raw.avito_cards WHERE batch_id = {bid};\n"
"\\copy _stg (source_id,observed_at,batch_id,kind,url,price,payload) "
"FROM STDIN WITH (FORMAT csv)\n"
+ _csv_rows(rows)
+ "\\.\n"
"INSERT INTO msk_raw.avito_cards "
"(source_id,observed_at,batch_id,kind,url,price,payload)\n"
"SELECT source_id,observed_at,batch_id,kind,url,price,payload FROM _stg\n"
"ON CONFLICT (source_id,batch_id,kind) DO NOTHING;\n"
"UPDATE msk_raw.batches b SET\n"
" rows_sent = coalesce(b.rows_sent,0) + (SELECT count(*) FROM _stg),\n"
" rows_new = coalesce(b.rows_new,0) +\n"
f" ((SELECT count(*) FROM msk_raw.avito_cards WHERE batch_id = {bid})\n"
" - (SELECT n FROM _before))\n"
f"WHERE b.batch_id = {bid};\n"
"COMMIT;\n"
)
def build_finalize_sql(batch_id: str, query: str, notes: str) -> str:
bid = _sql_str(batch_id)
return (
"INSERT INTO msk_raw.batches (batch_id, kind, query, started_at)\n"
f"VALUES ({bid}, 'serp', {_sql_str(query)}, now())\n"
"ON CONFLICT (batch_id) DO NOTHING;\n"
f"UPDATE msk_raw.batches SET finished_at = now(), notes = {_sql_str(notes)}\n"
f"WHERE batch_id = {bid};\n"
)
def run_psql(sql: str, ssh_host: str, container: str, db_user: str, db_name: str,
attempts: int = 4) -> None:
"""Заливка батча через ssh с ретраем на обрыв транспорта.
Прогон длится часами, и ssh рвётся: живьём поймано «Connection reset by peer»
(ssh возвращает 255) прямо посреди заливки весь прогон умирал, а несброшенный
батч терялся. Ретраить безопасно: SQL идемпотентен (batch через ON CONFLICT DO
NOTHING, карточки через ON CONFLICT (source_id,batch_id,kind) DO NOTHING).
Ретраится ТОЛЬКО транспорт (ssh 255). Ошибка самого psql (ON_ERROR_STOP, любой
другой код) это дефект данных или SQL, её повтор не лечит: поднимаем сразу.
"""
cmd = [
"ssh", ssh_host,
f"docker exec -i {container} psql -U {db_user} -d {db_name} "
"-v ON_ERROR_STOP=1 -f -",
]
for i in range(attempts):
proc = subprocess.run(cmd, input=sql.encode("utf-8"), capture_output=True)
out = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
if proc.returncode == 0:
if out:
print(f" psql: {out}", flush=True)
return
if proc.returncode != 255 or i == attempts - 1:
raise RuntimeError(f"psql через ssh вернул {proc.returncode}:\n{out}")
tail = out.splitlines()[-1] if out else "без вывода"
print(f" ssh оборвался ({tail}), повтор заливки {i + 2}/{attempts}", flush=True)
time.sleep(15.0 * (i + 1))
# --- накопитель карточек ---------------------------------------------------
@dataclass
class Sink:
"""Батчами на прод (ssh+psql) или в локальный CSV при --dry-run."""
batch_id: str
started_at: str
query: str
batch_size: int
dry_run: bool
csv_path: Path
ssh_host: str
container: str
db_user: str
db_name: str
buffer: list[dict[str, Any]] = field(default_factory=list)
sent: int = 0
skipped_non_numeric: int = 0
def add(self, lot: Any) -> None:
raw_id = str(getattr(lot, "source_id", "") or "")
try:
source_id = int(raw_id) # в БД bigint, у ScrapedLot — строка
except (TypeError, ValueError):
self.skipped_non_numeric += 1
return
payload = lot.model_dump(mode="json")
self.buffer.append({
"source_id": source_id,
"observed_at": datetime.now(timezone.utc).isoformat(),
"batch_id": self.batch_id,
"kind": "serp",
"url": payload.get("source_url"),
"price": payload.get("price_rub"),
"payload": json.dumps(payload, ensure_ascii=False),
})
def maybe_flush(self) -> None:
if len(self.buffer) >= self.batch_size:
self.flush()
def flush(self) -> None:
if not self.buffer:
return
rows, self.buffer = self.buffer, []
if self.dry_run:
fresh = not self.csv_path.exists()
with self.csv_path.open("a", encoding="utf-8", newline="") as fh:
if fresh:
fh.write("source_id,observed_at,batch_id,kind,url,price,payload\n")
fh.write(_csv_rows(rows))
print(f" [dry-run] {len(rows)} строк → {self.csv_path}", flush=True)
else:
run_psql(
build_sql(self.batch_id, self.query, rows, self.started_at),
self.ssh_host, self.container, self.db_user, self.db_name,
)
print(f" залито {len(rows)} строк в msk_raw.avito_cards", flush=True)
self.sent += len(rows)
def finalize(self, notes: str) -> None:
self.flush()
if self.dry_run:
print(f" [dry-run] finalize: {notes}", flush=True)
return
run_psql(
build_finalize_sql(self.batch_id, self.query, notes),
self.ssh_host, self.container, self.db_user, self.db_name,
)
# --- сбор ------------------------------------------------------------------
def parse_page(scraper: AvitoScraper, html: str, url: str) -> tuple[int | None, list[Any]]:
count = scraper._extract_total_count(html)
lots = scraper._parse_html(html, "https://www.avito.ru")
if not lots and count:
raise Blocked("empty_page", f"0 карточек при счётчике {count}: {url}")
return count, lots
async def probe(loader: Loader, scraper: AvitoScraper, base_url: str,
lo: int | None, hi: int | None) -> tuple[int | None, list[Any]]:
url = build_url(base_url, 1, lo, hi)
html, _ = await loader.fetch(url)
return parse_page(scraper, html, url)
async def build_plan(loader: Loader, scraper: AvitoScraper, base_url: str, target: int,
cache: dict[tuple[int | None, int | None], list[Any]]
) -> list[Corridor]:
"""Адаптивная бисекция по цене; страница 1 каждого коридора кэшируется."""
corridors: list[Corridor] = []
def emit(lo: int | None, hi: int | None, count: int | None,
truncated: bool, lots: list[Any]) -> None:
missed = max(0, (count or 0) - HARD_CAP) if truncated else 0
c = Corridor(lo=lo, hi=hi, count=count, truncated=truncated, missed=missed)
corridors.append(c)
cache[(lo, hi)] = lots
flag = " TRUNCATED" if truncated else ""
print(f" коридор {c.label()} count={count} "
f"страниц={c.planned_pages()}{flag}", flush=True)
if truncated:
print(f" ВНИМАНИЕ: коридор {c.label()} не влезает в потолок "
f"{HARD_CAP}; заведомо не добрано ~{missed} объявлений", flush=True)
async def find_upper(lo: int | None) -> int:
"""Верхнюю границу открытого коридора ищем удвоением от разумного старта."""
cand = max(int(lo or PRICE_FLOOR) * 2, PRICE_PROBE_START)
while cand < PRICE_CEIL:
cnt, _ = await probe(loader, scraper, base_url, cand, None)
print(f" проба хвоста pmin={cand:_} count={cnt}", flush=True)
if cnt is not None and cnt <= target:
return cand
cand *= 2
return cand
async def split(lo: int | None, hi: int | None, depth: int,
count: int | None, lots: list[Any]) -> None:
if count is None:
url = build_url(base_url, 1, lo, hi)
raise Blocked("empty_page", f"счётчик не прочитался: {url}")
if count <= target:
emit(lo, hi, count, False, lots)
return
if depth >= MAX_DEPTH or width_ratio(lo, hi) <= MIN_WIDTH_RATIO:
# Предохранитель: не молчим — помечаем truncated и считаем недобор.
emit(lo, hi, count, count > HARD_CAP, lots)
return
upper = hi if hi is not None else await find_upper(lo)
if hi is None:
tail_cnt, tail_lots = await probe(loader, scraper, base_url, upper, None)
emit(upper, None, tail_cnt,
bool(tail_cnt and tail_cnt > HARD_CAP), tail_lots)
mid = geometric_mid(lo, upper)
for sub_lo, sub_hi in ((lo, mid), (mid, upper)):
sub_cnt, sub_lots = await probe(loader, scraper, base_url, sub_lo, sub_hi)
print(f" проба {sub_lo or '-'}..{sub_hi} count={sub_cnt}", flush=True)
await split(sub_lo, sub_hi, depth + 1, sub_cnt, sub_lots)
root_cnt, root_lots = await probe(loader, scraper, base_url, None, None)
print(f"Всего по базовому запросу: {root_cnt}", flush=True)
await split(None, None, 0, root_cnt, root_lots)
return corridors
async def collect(args: argparse.Namespace) -> int:
# avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё, где в
# URL нет /ekaterinburg/ — то есть все подмосковные слаги (serp.py:2154).
scraper = AvitoScraper(
SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type]
target_city_slug="moskva",
)
out_dir = Path(args.out_dir).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
plan_path = out_dir / f"plan-{args.batch_id}.json"
csv_path = out_dir / f"cards-{args.batch_id}.csv"
started_at = datetime.now(timezone.utc).isoformat()
plan: Plan | None = None
if args.resume:
if not plan_path.exists():
print(f"--resume: плана нет — {plan_path}", file=sys.stderr)
return 1
plan = Plan.load(plan_path)
done = sum(1 for c in plan.corridors if c.status == "done")
print(f"Resume по {plan_path}: коридоров {len(plan.corridors)}, "
f"готово {done}", flush=True)
# Resume: URL берём из сохранённого плана, а не из CLI — коридоры посчитаны
# именно под него. Расхождение = молчаливая заливка чужой выдачи под тем же
# batch_id, поэтому это ошибка, а не тихий приоритет одного из двух.
if plan is not None and plan.base_url != args.base_url:
raise SystemExit(
"--resume: план построен для другого URL."
f" В плане {plan.base_url}, в аргументах {args.base_url}."
" Убери --base-url (возьмётся из плана) либо начни новый batch_id."
)
base_url = plan.base_url if plan is not None else args.base_url
page_budget = None if args.full else args.measure
mode = "FULL" if args.full else f"MEASURE<={page_budget}"
print(f"Режим: {mode}; batch_id={args.batch_id}; delay={args.delay}s; "
f"target={args.target_count}; dry_run={args.dry_run}", flush=True)
sink = Sink(
batch_id=args.batch_id, started_at=started_at, query=base_url,
batch_size=args.batch_size, dry_run=args.dry_run, csv_path=csv_path,
ssh_host=args.ssh_host, container=args.container,
db_user=args.db_user, db_name=args.db_name,
)
cache: dict[tuple[int | None, int | None], list[Any]] = {}
total = 0
stop_reason = ""
rc = 0
loads = 0
async with Loader(args.delay, page_budget) as loader:
try:
if plan is None:
print("Строю план коридоров...", flush=True)
corridors = await build_plan(loader, scraper, base_url,
args.target_count, cache)
plan = Plan(base_url=base_url, target=args.target_count,
batch_id=args.batch_id, corridors=corridors,
created_at=started_at)
plan.save(plan_path)
print(f"План сохранён: {plan_path} ({len(corridors)} коридоров)",
flush=True)
for corridor in plan.corridors:
if corridor.status == "done":
continue
pages = corridor.planned_pages()
print(f"Коридор {corridor.label()} count={corridor.count} "
f"страниц={pages} (с {corridor.pages_done + 1})", flush=True)
for page in range(corridor.pages_done + 1, pages + 1):
key = (corridor.lo, corridor.hi)
if page == 1 and key in cache:
lots = cache.pop(key) # страница 1 уже скачана при планировании
else:
url = build_url(base_url, page, corridor.lo, corridor.hi)
html, _ = await loader.fetch(url)
_, lots = parse_page(scraper, html, url)
for lot in lots:
sink.add(lot)
total += len(lots)
corridor.pages_done = page
print(f" стр.{page}/{pages}: карточек {len(lots)}, "
f"итого {total}", flush=True)
sink.maybe_flush()
plan.save(plan_path)
if not lots:
print(" пустая страница — конец коридора", flush=True)
break
corridor.status = "done"
plan.save(plan_path)
except BudgetExhausted:
stop_reason = "потолок --measure исчерпан"
print(f"Стоп: {stop_reason}", flush=True)
except Blocked as exc:
stop_reason = f"BLOCKED/{exc.reason}: {exc.detail}"
print(f"СТОП: {stop_reason}", file=sys.stderr, flush=True)
rc = 2
finally:
loads = loader.loads
if plan is not None:
plan.save(plan_path)
truncated = [c for c in (plan.corridors if plan else []) if c.truncated]
missed = sum(c.missed for c in truncated)
notes = "; ".join(x for x in [
f"mode={mode}", f"loads={loads}", f"cards={total}",
f"skipped_non_numeric={sink.skipped_non_numeric}",
(f"truncated_corridors={len(truncated)} missed~{missed}" if truncated else ""),
stop_reason,
] if x)
try:
sink.finalize(notes)
except Exception as exc: # noqa: BLE001 — не прятать исходную причину стопа
print(f"finalize провалился: {exc}", file=sys.stderr)
rc = rc or 1
print(f"Готово. Загрузок: {loads}; карточек: {total}; отправлено: {sink.sent}; "
f"пропущено нечисловых source_id: {sink.skipped_non_numeric}; "
f"notes: {notes}", flush=True)
return rc
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="collect.py",
description="Ручной сбор SERP Авито (вторичка, Москва+МО) в прод-схему msk_raw.",
)
p.add_argument("--base-url", default=DEFAULT_BASE_URL,
help="базовый URL выдачи (дефолт — вкладка владельца)")
p.add_argument("--measure", type=int, default=100, metavar="N",
help="режим замера: не больше N загрузок страниц (дефолт 100)")
p.add_argument("--full", action="store_true",
help="полный проход без потолка страниц (включается только явно)")
p.add_argument("--dry-run", action="store_true",
help="ничего не слать на прод, писать CSV локально")
p.add_argument("--resume", action="store_true",
help="продолжить по сохранённому плану коридоров")
p.add_argument("--delay", type=float, default=8.0,
help="пауза между загрузками, с (±20%% джиттер, дефолт 8.0)")
p.add_argument("--batch-size", type=int, default=1000,
help="карточек в одной заливке (дефолт 1000)")
p.add_argument("--target-count", type=int, default=1500,
help="целевой размер коридора; больше — делим (дефолт 1500)")
p.add_argument("--batch-id", default=None,
help="batch_id в msk_raw.batches (дефолт msk-serp-<UTC>)")
p.add_argument("--out-dir", default=str(Path(__file__).resolve().parent / "runs"),
help="каталог плана/CSV")
p.add_argument("--ssh-host", default="selectel", help="ssh-хост прода")
p.add_argument("--container", default="tradein-postgres",
help="имя контейнера Postgres на проде")
p.add_argument("--db-user", default="tradein")
p.add_argument("--db-name", default="tradein")
args = p.parse_args(argv)
if args.batch_id is None:
args.batch_id = "msk-serp-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if not _BATCH_ID_RE.match(args.batch_id):
p.error("--batch-id: допустимы только символы [A-Za-z0-9._-]")
if args.measure < 1:
p.error("--measure должен быть >= 1")
return args
def main(argv: list[str] | None = None) -> int:
return asyncio.run(collect(parse_args(argv)))
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -81,9 +81,20 @@ async def suggest_addresses(
),
),
] = None,
region_code: Annotated[
int,
Query(
description=(
"Регион покрытия (#3051). Дефолт 66 — Свердловская область, прежнее "
"поведение для существующих клиентов. 77 — Москва: без него DaData "
"и Nominatim получают свердловский hard-констрейнт и молча "
"возвращают ПУСТО на московском адресе."
),
),
] = 66,
) -> SuggestResponse:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
ЕКБ основной трафик, остаётся быстрым fast-path).
Используется в EstimateForm для подсказок пока пользователь печатает.
Bounded viewbox генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
@ -93,8 +104,13 @@ async def suggest_addresses(
/api/v1/geocode/suggest?q=Малышева
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
/api/v1/geocode/suggest?q=Тверская+6&region_code=77 # Москва
"""
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
try:
items = await suggest(q, db=db, limit=limit, city_hint=city_hint, region_code=region_code)
except ValueError as exc:
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
raise HTTPException(status_code=422, detail=str(exc)) from exc
return SuggestResponse(
items=[
SuggestItem(

View file

@ -2776,15 +2776,206 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
# тестом test_offered_cities_match_coverage_cities), а Москвы в том дропдауне
# нет и быть не должно — там выбирают город Свердловской области. Порог при
# этом нужен, и живёт он здесь, в том же _COVERAGE_CITY_THRESHOLDS.
#
# Тир — ЖЁЛТЫЙ (12), не зелёный. Замер на проде 10.09.2026: 200 случайных
# московских объявлений, когорта считалась тем же предикатом, что сама проба
# (см. SQL в coverage_probe ниже) — медиана когорты 14, p25 = 8, доля точек с
# когортой >= 12 равна 0.57, с когортой >= 8 равна 0.77. Контроли той же
# метрикой: Екатеринбург (зелёный, порог 8) — медиана 37, доля >= 12 равна
# 0.865; Нижний Тагил (жёлтый, порог 12) — медиана 11, доля >= 12 равна 0.473.
# То есть по плотности когорты Москва втрое реже зелёного эталона и стоит
# вплотную к жёлтому: с порогом 8 «ok» получали бы 77% адресов, но за этим «ok»
# стояла бы заметно менее надёжная оценка. Плюс модельная сторона для Москвы
# ещё не откалибрована (время правится свердловским рядом СберИндекса, полоса
# цен одна на весь город), так что 43% адресов честнее показать как thin.
COVERAGE_MOSCOW_MIN_N = COVERAGE_YELLOW_MIN_N
# Ключ центроида и display-имя города — РАЗНЫЕ вещи, и здесь это видно яснее
# всего: у Москвы 67 центроидов (сетка, см. `_MOSCOW_GRID_DEG` ниже), а наружу,
# в поле `city` ответа, обязано уходить одно имя «Москва» и один порог. Иначе
# повторится ровно тот баг, что был с Берёзовским: человек получает в ответе
# название района вместо своего города.
COVERAGE_MOSCOW_DISPLAY = "Москва"
# СЕТКА МОСКОВСКИХ ЦЕНТРОИДОВ (11.09.2026).
#
# Откуда координаты. Это не справочник районов и не ручной подбор: точки
# получены кластеризацией НАШЕГО ЖЕ корпуса объявлений средствами PostGIS —
# `ST_ClusterKMeans(ST_Transform(geom, 32637), K) OVER ()` по 35 552 активным
# вторичным объявлениям region_code=77 (geom заполнена у 100%), центроид
# кластера = `ST_Centroid(ST_Collect(...))` в UTM 37N, округление до 4 знаков.
# Поэтому точки стоят там, где реально живут объявления, а не там, где на карте
# нарисован центр района. Подписи в ключах — человеческие ярлыки для читаемости
# (ближайший район/поселение), на резолв они не влияют никак.
#
# Почему точек 67. Кластеризация дала K=56: порог качества был «меньше 2%
# московских объявлений уходит к подмосковному центроиду», свип по K на тех же
# данных — 24 → 6.06%, 30 → 4.02%, 36 → 3.67%, 42 → 3.46%, 48 → 2.23%,
# 54 → 2.12%, 56 → 1.79%, 60 → 1.64%, 75 → 0.84%, 90 → 0.45%, и K=56 — первое
# значение, берущее 2%. Из этих 56 один кластер выброшен как артефакт геокода,
# и 12 точек добавлены вручную по замеру 11.09.2026 (жилые районы, целиком
# проигрывавшие конкурс подмосковной точке): 56 1 + 12 = 67.
#
# Выброшенный призрак. «Москва (Восточный, эксклав)» 56.0087/37.7960: n=1,
# отрыв от остальной сетки 17.9 км. Адрес объявления — «ВАО, р-н Восточный, 4»,
# а настоящий район Восточный лежит на 55.81/37.85: геокодер промахнулся
# на 22 км. Точка стояла в 3.06 км от Пушкино и раздавала ему имя «Москва».
# Взамен добавлена «Москва (Восточный)» по фактическому центру девяти
# объявлений района. Проверены ВСЕ кластеры с n<60 либо изоляцией >12 км
# (9 штук, адреса подняты с прода) — артефакт ровно один. Второй кандидат,
# «Москва (юг ТАО)» 55.2643/37.1033 (n=1, адрес без улицы и дома =
# settlement-fallback геокодера), ОСТАВЛЕН намеренно: точка стоит внутри ТАО,
# при радиусе 8 км не достаёт ни до одного города области, а снос стоил бы
# покрытия анклава.
#
# Где проходит граница с областью. Двумя механизмами сразу, и нужны оба.
# (1) Конкурс ближайшего центроида: 32 подмосковные точки
# (`_COVERAGE_NEGATIVE_CENTROIDS_DEG` ниже) участвуют в поиске ближайшего, но
# порога не имеют, поэтому адрес, для которого выиграл подмосковный центр,
# получает «город не определён». (2) Радиус, который теперь СВОЙСТВО ЦЕНТРОИДА
# (`_CENTROID_RADIUS_KM` ниже), а не одна константа на всю страну: у московских
# точек он 8 км, у свердловских остались прежние 25.
#
# Почему радиус пришлось сделать свойством точки. 25 км были рассчитаны на ОДИН
# центроид в центре города — круг вокруг Кремля. У плотной сетки круги
# СКЛАДЫВАЮТСЯ, и объединение 67 кругов по 25 км — это уже не «Москва с
# запасом», а пятно, накрывающее половину области. Наро-Фоминск, Кубинка и Чехов
# резолвились как «Москва» именно так: до ближайшей точки сетки им 9.83, 17.18
# и 22.25 км (расстояния посчитаны _haversine_km этого же модуля, сфера
# R=6371 — замер на эллипсоиде PostGIS даёт на 0.1-0.5 км больше), то есть
# внутрь общего круга они попадали, а собственной
# отрицательной точки у них нет. Прежнее утверждение в этом месте — будто
# радиус 25 км «больше не ограничение, запас четырёхкратный» — было ложным:
# ограничение не исчезло, оно поменяло знак. Раньше радиус резал покрытие
# изнутри (адрес дальше 25 км от Кремля терял город), теперь протекал наружу.
#
# Откуда 8 км. Свип по радиусу на итоговой сетке, тот же корпус (35 552
# активных вторичных объявления, region_code=77): R = 5, 6, 7 и 8 дают
# ОДИНАКОВЫЙ результат — 0.00% объявлений вне радиуса, 29/29 московских
# контрольных адресов резолвятся в «Москву», 32/32 областных дают «не
# определён». Верхний край плато жёсткий: при R=10 внутрь входит Наро-Фоминск
# (9.83 км — город БЕЗ отрицательной точки), при R=12 — ещё Голицыно и
# Лыткарино. Берём верхний край: 8 км — это 3.5 км запаса на адреса вне
# корпуса (худший московский контроль — Рублёво, 4.44 км до сетки) и всё ещё
# на 1.83 км ниже потолка 9.83. Брать меньше нечем оправдать: по корпусу
# выигрыша нет, а запас на дырки в сетке тает.
#
# Остаточная цена. 48 московских объявлений из 35 552 = 0.14% (на прежней
# конфигурации — 636 = 1.79%) всё ещё выигрываются подмосковным центром:
# Молжаниновский 15, Реутов 8, Подольск 7, Долгопрудный 7, Мытищи 7,
# Красногорск 3, Королёв 1. Крупнейший кусок снимается 13-й точкой
# «Молжаниновский» 55.9480/37.3478 (0.14% → 0.09%) — она в замере посчитана,
# но в список не внесена, чтобы конфигурация здесь совпадала с той, на которой
# прогнаны оба контрольных набора. Одно объявление не берётся ничем:
# 56.0087/37.7960, тот самый кривой геокод, — лечится перегеокодированием
# строки адреса, а не сеткой.
#
# Не перегенерируйте кластеризацию перед правкой: ST_ClusterKMeans без сида
# недетерминирован, разброс между прогонами ±0.2 п.п. Список ниже перепроверен
# отдельным прогоном именно в том виде, в каком он здесь записан.
_MOSCOW_GRID_DEG: dict[str, tuple[float, float]] = {
"Москва (Рогово, ТАО)": (55.2146, 37.0695),
"Москва (юг ТАО)": (55.2643, 37.1033),
"Москва (Вороновское, ТАО)": (55.3159, 37.1759),
"Москва (Кленовское, ТАО)": (55.3343, 37.3299),
"Москва (ТАО у Подольска)": (55.3569, 37.4010),
"Москва (Шишкин Лес, ТАО)": (55.4202, 37.1687),
"Москва (Щапово, ТАО)": (55.4212, 37.3979),
"Москва (Новофёдоровское, ТАО)": (55.4307, 36.8675),
"Москва (Троицк, юг)": (55.4568, 37.2867),
"Москва (Остафьево, НАО)": (55.4760, 37.5249),
"Москва (Киевский, ТАО)": (55.4884, 36.9199),
"Москва (Троицк)": (55.4953, 37.3150),
"Москва (Щербинка, НАО)": (55.5046, 37.5578),
"Москва (Ватутинки, НАО)": (55.5187, 37.3605),
"Москва (Первомайское, ТАО)": (55.5405, 37.1599),
"Москва (Коммунарка, НАО)": (55.5474, 37.4927),
"Москва (Филимонковское, НАО)": (55.5568, 37.3188),
"Москва (Северное Бутово)": (55.5615, 37.5697),
"Москва (Саларьево, НАО)": (55.5862, 37.4568),
"Москва (Марушкино, НАО)": (55.5933, 37.1658),
"Москва (Бирюлёво Западное)": (55.5945, 37.6447),
"Москва (Внуковское, НАО)": (55.6030, 37.3796),
"Москва (Ясенево)": (55.6138, 37.5535),
"Москва (Зябликово)": (55.6225, 37.7303),
"Москва (Ново-Переделкино)": (55.6317, 37.3215),
"Москва (Нагорный)": (55.6425, 37.6245),
"Москва (Солнцево)": (55.6455, 37.3966),
"Москва (Тропарёво-Никулино)": (55.6576, 37.4684),
"Москва (Люблино)": (55.6696, 37.7461),
"Москва (Обручевский)": (55.6711, 37.5432),
"Москва (Даниловский)": (55.6921, 37.6397),
"Москва (Некрасовка)": (55.7033, 37.9093),
"Москва (Раменки)": (55.7048, 37.4870),
"Москва (Кузьминки)": (55.7143, 37.7723),
"Москва (Кунцево)": (55.7281, 37.4239),
"Москва (Якиманка)": (55.7290, 37.6055),
"Москва (Таганский)": (55.7370, 37.6845),
"Москва (Пресненский)": (55.7486, 37.5282),
"Москва (Новогиреево)": (55.7545, 37.8107),
"Москва (Тверской)": (55.7655, 37.6038),
"Москва (Хорошёво-Мнёвники)": (55.7657, 37.4668),
"Москва (Преображенское)": (55.7886, 37.7045),
"Москва (Хорошёвский)": (55.7951, 37.5373),
"Москва (Строгино)": (55.7982, 37.3859),
"Москва (Северное Измайлово)": (55.8071, 37.7881),
"Москва (Останкинский)": (55.8086, 37.6174),
"Москва (Покровское-Стрешнево)": (55.8203, 37.4470),
"Москва (Митино)": (55.8467, 37.3877),
"Москва (Отрадное)": (55.8558, 37.5796),
"Москва (Ховрино)": (55.8581, 37.4924),
"Москва (Бабушкинский)": (55.8633, 37.6740),
"Москва (Лианозово)": (55.8944, 37.5622),
"Москва (Куркино — Молжаниново)": (55.9169, 37.3837),
"Москва (Зеленоград, юг)": (55.9793, 37.1620),
"Москва (Зеленоград, север)": (55.9958, 37.2147),
# Кластер «Москва (Восточный, эксклав)» (56.0087, 37.7960) ВЫБРОШЕН
# 11.09.2026 как артефакт геокода — см. «Выброшенный призрак» выше.
#
# Ниже — 12 точек, добавленных тем же замером вручную: жилые районы
# Москвы, которые целиком проигрывали конкурс ближайшего подмосковной
# точке. В комментарии — сколько объявлений возвращает точка и кому они
# уходили (прод-корпус, is_active AND region_code=77).
"Москва (Восточный)": (55.8194, 37.8740), # +9, уходили к Балашихе
"Москва (Левобережный)": (55.8713, 37.4592), # +91, к Химкам
"Москва (Орехово-Борисово Южное)": (55.5986, 37.7233), # +80, к Развилке
"Москва (Северный)": (55.9278, 37.5411), # +79, к Долгопрудному
"Москва (Митино-запад)": (55.8410, 37.3507), # +78, к Красногорску
"Москва (Ивановское)": (55.7717, 37.8326), # +65, к Реутову
"Москва (Жулебино)": (55.6885, 37.8521), # +62, к Люберцам
"Москва (Можайский-запад)": (55.7008, 37.3936), # +50, к Немчиновке
"Москва (Новокосино)": (55.7385, 37.8576), # +44, к Реутову
"Москва (Капотня)": (55.6341, 37.7981), # +29, к Дзержинскому
"Москва (Куркино)": (55.8895, 37.3980), # +20, к Химкам
"Москва (Некрасовка-восток)": (55.6838, 37.9216), # +13, к Люберцам
}
# Все ключи сетки — один display «Москва» и один порог. Кортеж выводится из
# самого словаря, чтобы список ключей физически не мог разойтись с сеткой.
COVERAGE_MOSCOW_CENTROID_KEYS = tuple(_MOSCOW_GRID_DEG)
def _fold_city(name: str) -> str:
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
# Ключ — folded ИМЯ ЦЕНТРОИДА, значение — (display-имя города, порог). Для
# свердловских городов ключ и display совпадают, для московских центроидов —
# нет (все 67 ключей сетки дают «Москва»).
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
**{
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
for k in COVERAGE_MOSCOW_CENTROID_KEYS
},
}
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
@ -2807,7 +2998,15 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
# Радиусы матчинга. Радиус — СВОЙСТВО ЦЕНТРОИДА (таблица `_CENTROID_RADIUS_KM`
# ниже, применяется в `_resolve_coverage_city`), потому что 25 км осмысленны
# ровно для конфигурации «один центроид на город», а у Москвы центроидов 67 и
# их круги складываются — разбор в комментарии над `_MOSCOW_GRID_DEG`.
# Свердловские точки радиуса не меняли: у каждой из них один центр на город,
# складываться нечему, а замер, которым выбраны эти 25 км, к сетке отношения
# не имеет.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дефолт: один центроид на город, дальше — not_covered
COVERAGE_MOSCOW_MATCH_RADIUS_KM = 8.0 # сетка из 67 точек; верхний край плато 5..8, потолок 9.83
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Екатеринбург": (56.8389, 60.6057),
@ -2819,8 +3018,99 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772),
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
# Как сетка получена, почему точек именно столько, где проходит граница
# с областью и какова остаточная цена — см. большой комментарий над
# `_MOSCOW_GRID_DEG`. Здесь только подстановка: координаты и ключи живут
# в одном месте, чтобы их нельзя было рассинхронизировать.
**_MOSCOW_GRID_DEG,
}
# ОТРИЦАТЕЛЬНЫЕ центроиды: подмосковные города и посёлки, попадающие внутрь
# радиуса московских точек. Список 11.09.2026 перепроверен на итоговой
# конфигурации (67 точек сетки, радиус 8 км): все 32 точки живы — ни одну
# не перехватывает московский центроид, в самой точке выигрывает она сама,
# и ответ остаётся «город не определён». Внутрь 8 км от какой-нибудь точки
# сетки попадают 13 из них (Химки, Реутов, Люберцы, Котельники, Дзержинский,
# Красногорск, Долгопрудный, Мытищи, Видное, Одинцово, Подольск, Апрелевка,
# Селятино) — каждая выигрывает собственным центром. Ближайшая пара после
# добавления новых точек: «Митино-запад» в 1.6 км от Красногорска. Участвуют
# в конкурсе ближайшего центроида наравне с городами из
# `_CITY_CENTROIDS_DEG`, но НАМЕРЕННО отсутствуют в `_COVERAGE_CITY_THRESHOLDS`:
# центроид без порога = «город не определён», тот же ответ, что для точки
# в чистом поле. Обоснование и цена — в комментарии «ГРАНИЦА С ОБЛАСТЬЮ» выше.
#
# Координаты — WGS84, общедоступные центры НП; список закрытый и расширяется
# только по замеру (точка вне радиуса всех московских бесполезна, точка внутри
# Москвы сожгла бы ещё часть покрытия). Ключи здесь и в `_CITY_CENTROIDS_DEG`
# не должны пересекаться — это проверяется тестом.
#
# Города области, которые НЕ попадают ни в один московский радиус, здесь и не
# нужны: Наро-Фоминск (9.83 км до сетки), Кубинка (17.18), Чехов (22.25) отдают
# «город не определён» просто потому, что вне 8 км. При прежних 25 км все трое
# резолвились как «Москва» — это и был дефект, ради которого радиус переехал
# в свойство центроида.
_COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Андреевка": (55.9772, 37.1100),
"Менделеево": (56.0333, 37.2333),
"Сходня": (55.9500, 37.3000),
"Чёрная Грязь": (55.9667, 37.3167),
"Лунёво": (56.0000, 37.3500),
"Поварово": (56.0667, 37.0667),
"Дедовск": (55.8672, 37.1200),
"Нахабино": (55.8500, 37.1833),
"Реутов": (55.7614, 37.8564),
"Подольск": (55.4312, 37.5450),
"Апрелевка": (55.5500, 37.0700),
"Немчиновка": (55.7050, 37.3450),
"Химки": (55.8894, 37.4450),
"Одинцово": (55.6789, 37.2639),
"Лобня": (56.0100, 37.4750),
"Мытищи": (55.9116, 37.7308),
"Котельники": (55.6553, 37.8619),
"Красногорск": (55.8317, 37.3300),
"Люберцы": (55.6767, 37.8931),
"Дзержинский": (55.6294, 37.8500),
"Развилка": (55.5842, 37.7392),
"Климовск": (55.3667, 37.5333),
"Балашиха": (55.7969, 37.9386),
"Истра": (55.9167, 36.8667),
"Долгопрудный": (55.9386, 37.5100),
"Королёв": (55.9142, 37.8256),
"Видное": (55.5519, 37.7133),
"Селятино": (55.5081, 36.9825),
"Томилино": (55.6528, 37.9472),
"Некрасовский": (56.0500, 37.5500),
"Барвиха": (55.7333, 37.2333),
"Железнодорожный": (55.7444, 38.0128),
}
# Радиус матчинга по центроиду: ключ — folded имя центроида, значение — км.
# Чего здесь нет — берёт `COVERAGE_CITY_MATCH_RADIUS_KM` (25 км), то есть все
# свердловские точки. Таблица выводится из самих словарей координат, чтобы
# радиус физически не мог разойтись со списком точек.
#
# Почему у отрицательных точек радиус бесконечный. Отрицательный центроид
# ничего не «накрывает»: он влияет на ответ только там, где он ближе всех
# (в своей ячейке Вороного), а выигравший центроид без порога даёт «город не
# определён» на ЛЮБОМ расстоянии — обе ветки `_resolve_coverage_city` (вышли
# за радиус / нет порога) возвращают один и тот же ответ. Поэтому конечное
# число здесь было бы декорацией: поведение оно не меняет, но читалось бы как
# «дальше отрицательная точка перестаёт действовать», чего не происходит.
# math.inf говорит правду — границу отрицательной точки задаёт геометрия
# соседей, а не радиус, и попытка «подрезать» её числом ничего не вернёт
# Москве, зато спрячет этот факт от следующего читателя.
_CENTROID_RADIUS_KM: dict[str, float] = {
**{_fold_city(k): COVERAGE_MOSCOW_MATCH_RADIUS_KM for k in COVERAGE_MOSCOW_CENTROID_KEYS},
**{_fold_city(k): math.inf for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG},
}
def _centroid_radius_km(centroid_key: str) -> float:
"""Радиус матчинга КОНКРЕТНОГО центроида в км (см. `_CENTROID_RADIUS_KM`)."""
return _CENTROID_RADIUS_KM.get(_fold_city(centroid_key), COVERAGE_CITY_MATCH_RADIUS_KM)
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
@ -2836,23 +3126,49 @@ def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
в пределах СОБСТВЕННОГО радиуса этого центроида (`_centroid_radius_km`:
25 км у свердловских городов, 8 км у точек московской сетки почему так,
см. комментарий над `_MOSCOW_GRID_DEG`); иначе город не определён.
Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
В конкурсе ближайшего участвуют И отрицательные центроиды
(`_COVERAGE_NEGATIVE_CENTROIDS_DEG`) подмосковные точки без порога.
Выигравший центроид без порога означает «город не определён»: так граница
Москвы и области проходит по конкурсу центров, а не по кругу радиуса
(см. «ГРАНИЦА С ОБЛАСТЬЮ» над `_CITY_CENTROIDS_DEG`).
Возвращается display-имя из `_COVERAGE_CITY_THRESHOLDS`, а НЕ ключ
центроида: у Москвы 67 центроидов на один город, и наружу все они обязаны
отдавать «Москва» с одним порогом (см. `COVERAGE_MOSCOW_CENTROID_KEYS`).
"""
nearest_city: str | None = None
nearest_km = math.inf
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
for city, (clat, clon) in (
*_CITY_CENTROIDS_DEG.items(),
*_COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
):
distance_km = _haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km = distance_km
nearest_city = city
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
# Радиус берётся у ПОБЕДИТЕЛЯ конкурса, а не общий: круги плотной сетки
# складываются, и один глобальный радиус либо режет Свердловскую область,
# либо протекает из Москвы в область (см. `_CENTROID_RADIUS_KM`).
if nearest_city is None or nearest_km > _centroid_radius_km(nearest_city):
return "", 0, False
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
# .get(), а не индексирование: ближайшим мог оказаться отрицательный
# центроид (или новый центроид, для которого забыли завести порог) — это
# «город не определён», а не KeyError и 500 на живом адресе.
entry = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(nearest_city))
if entry is None:
return "", 0, False
display, threshold = entry
return display, threshold, True

View file

@ -349,6 +349,7 @@ async def suggest_addresses(
limit: int = 8,
city: str | None = "Екатеринбург",
region: str | None = None,
regions: list[str] | None = None,
) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address.
@ -367,6 +368,13 @@ async def suggest_addresses(
БЕЗ типа («Свердловская», а тип отдельно в `region_type`="обл").
Передашь «Свердловская область» совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
regions: НЕСКОЛЬКО регионов разом `locations` у DaData это список, и
элементы в нём складываются по ИЛИ. Нужно там, где один регион даёт
заведомо неверный ответ: адрес Авито по Москве и области («Юбилейная
ул.,20Б») лежит либо в 77, либо в 50, и констрейнт из одного региона
молча притягивает подмосковный дом к московской улице. Имеет
приоритет над `region`/`city`. Имена так же БЕЗ типа («Москва»,
«Московская»).
Returns:
list[DadataSuggestion] пустой список если:
@ -394,7 +402,11 @@ async def suggest_addresses(
"query": query.strip(),
"count": max(1, min(int(limit), 20)),
}
if region:
if regions:
# Список → ИЛИ по регионам (см. docstring). Пустые имена выбрасываем:
# `{"region": ""}` — не «любой регион», а гарантированный ноль хитов.
body["locations"] = [{"region": r} for r in regions if r and r.strip()]
elif region:
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,

View file

@ -0,0 +1,157 @@
"""Ключ города ДКП-сделки для ценовых полос (#3051 «Москва», округа).
ПРОБЛЕМА. deal_city_price_bands ключуется (region_code, city), а deals.city у
ВСЕХ 212 937 московских сделок буквально 'Москва' на весь город получалась
ОДНА полоса 34221..718870 /м². Москва неоднородна на порядок (Хамовники против
Некрасовки), поэтому единая полоса одновременно и не режет опечатки в дорогом
центре, и режет легитимный рынок на окраинах.
РЕШЕНИЕ. Ключом города становится
COALESCE(NULLIF(raw_payload->>'src_city', ''), city)
у московских сделок Росреестра raw_payload.src_city несёт муниципальный округ
('муниципальный округ Хамовники' и т.п.): заполнен у 198 600 из 212 937 сделок
(93.27%), 197 различных значений. Оставшиеся 6.73% (14 376 сделок) отдают
'Москва' и образуют СВОЮ строку-фолбэк (n=14376 tier 'full', полоса
22475..772165), а не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2,
откалиброванные под Екатеринбург. Ключи дизъюнктны: либо 'муниципальный округ X',
либо ровно 'Москва' b.city = ключ_сделки всегда матчит одну строку.
ИНВАРИАНТ РЕГИОНА 66. У сделок Свердловской области src_city пуст у ВСЕХ
108 623 строк, поэтому COALESCE отдаёт city и ключ не меняется. Проверено на
проде 2026-09-10: сделок региона 66, где ключ отличается от city, 0 штук;
пересчёт по новому выражению даёт те же 383 строки полос, все совпадают с
текущими по (ppm2_min, ppm2_max, n_deals, tier).
NULL-семантика. Если raw_payload отсутствует целиком (NULL), то
NULL->>'src_city' = NULL NULLIF(NULL,'') = NULL COALESCE отдаёт city.
Если src_city есть, но пустая строка NULLIF гасит её в NULL, тот же исход.
Питоновский helper ниже повторяет эту семантику один-в-один (пустая строка
считается отсутствующей, пробелы НЕ подрезаются SQL их тоже не подрезает).
Модуль намеренно крошечный и без зависимостей: выражение обязано быть ОДНИМ на
derivation (app/tasks/deal_city_price_bands_refresh.py) и на все читающие места
(app/services/estimator.py). Три копии выражения = три места, где полосы
разъезжаются молча.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
# Имя вычисляемой колонки-ключа в SELECT'ах, которые читают сделки для питонового
# пути фильтрации (_fetch_deals → _is_plausible_deal).
DEAL_CITY_KEY_COLUMN = "city_key"
def deal_city_key_sql(alias: str = "d") -> str:
"""SQL-выражение ключа города сделки.
alias префикс таблицы deals в запросе ('d' для `FROM deals d`, '' для
`FROM deals` без алиаса, как в derivation задачи полос).
"""
prefix = f"{alias}." if alias else ""
return f"COALESCE(NULLIF({prefix}raw_payload->>'src_city', ''), {prefix}city)"
def deal_city_key(row: Mapping[str, Any]) -> str | None:
"""Питоновский эквивалент deal_city_key_sql для уже прочитанной строки сделки.
Порядок: готовая колонка DEAL_CITY_KEY_COLUMN (её считает SQL) src_city из
raw_payload, если строку прочитали вместе с payload city. Пустая строка
трактуется как отсутствие значения как NULLIF(x, '') в SQL.
"""
key = row.get(DEAL_CITY_KEY_COLUMN)
if key:
return str(key)
raw = row.get("raw_payload")
if isinstance(raw, Mapping):
src = raw.get("src_city")
if src:
return str(src)
city = row.get("city")
return str(city) if city else None
# ── Двухступенчатый поиск полосы (#3051, разрыв на деплое) ───────────────────
#
# Таблицу deal_city_price_bands наполняет НОЧНАЯ задача, а читающая сторона
# уезжает на ключ-округ сразу с деплоем. В окне между деплоем и первым рефрешем
# по региону 77 в таблице лежит ровно ОДНА строка city='Москва': 93.27%
# московских сделок искали бы ключ 'муниципальный округ X', не находили и падали
# на глобальные DEAL_MIN_PPM2=50000 / DEAL_MAX_PPM2=800000 (калибровка ЕКБ) —
# нижняя граница прыгала бы с 34221 до 50000 и молча выбрасывала легитимно
# дешёвые сделки. Это ХУЖЕ, чем было до правки.
#
# Поэтому поиск полосы ДВУХСТУПЕНЧАТЫЙ и одинаковый во всех трёх читающих местах
# (два SQL-джойна ДКП-коридора + питоновский путь _fetch_deals):
# ступень 1 — строка по ключу-округу (deal_city_key / deal_city_key_sql);
# ступень 2 — строка по deals.city;
# ступень 3 — глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2.
# Порядок деплоя перестаёт иметь значение, а округ, для которого строки ещё нет
# (свежий округ, n<10, мусорное значение src_city), деградирует в ГОРОДСКУЮ
# полосу, а не в екатеринбургскую калибровку.
#
# Ступень не расщепляется по границам: ppm2_min и ppm2_max в таблице NOT NULL
# (data/sql/178_deal_city_price_bands.sql), значит найденная строка отдаёт ОБЕ
# границы — COALESCE не может взять min из округа, а max из города.
#
# ИНВАРИАНТ РЕГИОНА 66. src_city пуст у всех его 108 623 сделок → ключ ступени 1
# равен ключу ступени 2, обе ступени находят одну и ту же строку, результат
# байт-в-байт прежний. Вторая ступень для него — тавтология, не изменение.
DEAL_CITY_BAND_ALIAS = "b" # ступень 1: строка по ключу-округу
DEAL_CITY_BAND_FALLBACK_ALIAS = "bc" # ступень 2: строка по deals.city
def deal_city_band_join_sql(alias: str = "d", indent: str = "") -> str:
"""Два LEFT JOIN'а к deal_city_price_bands: ступень 1 (округ) + ступень 2 (город).
alias префикс таблицы deals, indent отступ строк со 2-й (косметика SQL).
"""
prefix = f"{alias}." if alias else ""
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
lines = [
f"LEFT JOIN deal_city_price_bands {b}",
f" ON {b}.region_code = {prefix}region_code",
f" AND {b}.city = {deal_city_key_sql(alias)}",
f"LEFT JOIN deal_city_price_bands {bc}",
f" ON {bc}.region_code = {prefix}region_code",
f" AND {bc}.city = {prefix}city",
]
return ("\n" + indent).join(lines)
def deal_city_band_bounds_sql(
min_param: str = ":ppm_min", max_param: str = ":ppm_max", indent: str = ""
) -> str:
"""Границы полосы одним выражением: округ → город → глобальные константы."""
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
lines = [
f"BETWEEN COALESCE({b}.ppm2_min, {bc}.ppm2_min, CAST({min_param} AS int))",
f" AND COALESCE({b}.ppm2_max, {bc}.ppm2_max, CAST({max_param} AS int))",
]
return ("\n" + indent).join(lines)
def resolve_city_band(
bands: Mapping[tuple[int, str], tuple[int, int]] | None,
region_code: int,
city_key: str | None,
city: str | None,
default: tuple[int, int],
) -> tuple[int, int]:
"""Питоновский эквивалент двух LEFT JOIN'ов выше: округ → город → default.
Ступени и их порядок обязаны совпадать с SQL-версией: разъехавшийся порядок
означал бы, что питоновский фильтр сделок судит по другой полосе, чем
ДКП-коридор на тех же данных.
"""
table = bands or {}
for key in (city_key, city):
if key is None:
continue
band = table.get((region_code, key))
if band is not None:
return band
return default

View file

@ -73,11 +73,18 @@ from app.services.dadata import DadataAddressResult
# CLEAN выключена → house_fias_id/гео берём из token-only /suggest). Локальный алиас
# оставлен как dadata_clean_address — это точка патча в 11 тестах (без churn).
from app.services.dadata import enrich_address as dadata_clean_address
from app.services.deal_city_key import (
DEAL_CITY_KEY_COLUMN,
deal_city_band_bounds_sql,
deal_city_band_join_sql,
deal_city_key,
deal_city_key_sql,
resolve_city_band,
)
from app.services.geocoder import (
SVERDLOVSK_OBLAST_CITIES,
GeocodeResult,
geocode,
is_within_oblast66_bbox,
)
from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house
@ -87,6 +94,24 @@ from app.tasks.asking_to_sold_ratio import area_bucket
logger = logging.getLogger(__name__)
# #3051 (округа Москвы): ключ города сделки для джойна с deal_city_price_bands.
# ОДНО выражение (app/services/deal_city_key.py) на все три читающих места ниже и
# на derivation полос (app/tasks/deal_city_price_bands_refresh.py) — разъехавшийся
# ключ означал бы, что окружные строки таблицы мёртвые, а сделки молча падают на
# глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2. Два варианта — под запрос с алиасом
# `FROM deals d` и без алиаса.
_DEAL_CITY_KEY_SQL_PLAIN = deal_city_key_sql("")
# #3051 (разрыв на деплое): полоса ищется ДВУХСТУПЕНЧАТО — строка по ключу-округу,
# при промахе строка по deals.city, при промахе обеих — глобальные
# DEAL_MIN_PPM2/DEAL_MAX_PPM2. Таблицу наполняет НОЧНАЯ задача, и до её первого
# прогона по региону 77 в ней лежит ровно ОДНА строка 'Москва': без второй ступени
# 93.27% московских сделок падали бы на ЕКБ-калибровку (пол 50000 вместо 34221) —
# хуже, чем до правки. Обе ступени и их порядок собраны в deal_city_key.py и
# ОДНИ на все три читающих места (два джойна ниже + питоновский _fetch_deals).
_DEAL_CITY_BAND_JOIN_D = deal_city_band_join_sql("d", indent=" " * 20)
_DEAL_CITY_BAND_BOUNDS = deal_city_band_bounds_sql(indent=" " * 22)
# ── Constants ────────────────────────────────────────────────────────────────
DEFAULT_RADIUS_M = 1000 # ПО ВСТРЕЧЕ ПТИЦЫ: «локация не дальше 800-1000 м»
@ -284,8 +309,69 @@ def rehydrate_search_radius_m(
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
SBER_TIME_ADJUST_REGION = "Свердловская область"
# to the latest available month.
# #3051: ряд БОЛЬШЕ НЕ прибит к Свердловской области. sber_price_index ключуется
# ТЕКСТОВОЙ колонкой city (не region_code), и до этой правки свердловский ряд
# применялся ко ВСЕМ сделкам, включая 212 937 московских (region_code=77, все ДКП).
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor
# и Tier-C anchor gate, т.е. едет итоговая цена.
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
# #3051: region_code → имя ряда в sber_price_index.city. Имена — ЗНАЧЕНИЯ ИСТОЧНИКА
# (так их кладёт загрузчик sber_index), поэтому карта заведена здесь явно и НЕ
# выводится из regions.REGIONS[...].name: переименование региона в нашем реестре не
# имеет права молча увести оценщик на пустой ряд. Проверено на проде 2026-09-10: у
# обоих имён есть real_estate_deals/«Вторичный», 115 месяцев 2017-01..2026-07.
_SBER_REGION_SERIES: dict[int, str] = {
66: SBER_TIME_ADJUST_REGION,
77: "Москва",
}
# Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0):
# отказ — не нейтральный выбор, а систематическое ЗАНИЖЕНИЕ коридора на всю величину
# роста рынка с даты замороженной сделки (на 12-месячном окне это +3.1% по обл.66 и
# +9.2% по Москве, замер 2026-09-10 — ровно столько мы бы потеряли). Общероссийский
# ряд ошибается лишь на РАСХОЖДЕНИЕ региона со страной, что заведомо меньше полного
# отказа, и он ровно так же полон (real_estate_deals/«Вторичный», 115 месяцев,
# max period 2026-07 — как у 66 и 77). Молчания при этом нет: промах по карте
# пишется в лог, а сам ряд под мониторингом (tasks/sber_freshness_monitor).
SBER_TIME_ADJUST_FALLBACK_REGION = "Россия"
# Ряды, которые оценщик способен прочитать — вход для tasks/sber_freshness_monitor.
# REQUIRED — регионы, по которым в БД есть сделки: пропажа ряда = дефект данных.
# MONITORED — плюс фолбэк, он advisory (по нему сегодня не считается ни одна сделка).
SBER_REQUIRED_REGIONS: tuple[str, ...] = tuple(dict.fromkeys(_SBER_REGION_SERIES.values()))
SBER_MONITORED_REGIONS: tuple[str, ...] = (
*SBER_REQUIRED_REGIONS,
SBER_TIME_ADJUST_FALLBACK_REGION,
)
def sber_region_series_name(region_code: int | None) -> str:
"""#3051: имя ряда sber_price_index для региона запроса.
66 «Свердловская область» (байт-в-байт прежнее поведение всех сегодняшних
вызовов обл.66), 77 «Москва». Регион вне карты (или None) «Россия» + WARNING:
ни падения (коридор не тот механизм, ради которого стоит ронять оценку), ни
молчаливой подстановки чужого регионального ряда именно она и стоила Москве
5.9% (см. комментарий у SBER_TIME_ADJUST_REGION).
"""
name = _SBER_REGION_SERIES.get(region_code) if region_code is not None else None
if name is not None:
return name
logger.warning(
"sber time-adjust: region_code=%r нет в _SBER_REGION_SERIES — беру "
"общероссийский ряд %r (НЕ свердловский). Если по региону пошли сделки — "
"заведи его ряд в карте, иначе поправка тупее регионального рынка",
region_code,
SBER_TIME_ADJUST_FALLBACK_REGION,
)
return SBER_TIME_ADJUST_FALLBACK_REGION
# Coefficient series preference — ТОЛЬКО вторичный рынок (эстиматор оценивает вторичку):
# real_estate_deals (Вторичный, зарег. сделки) → dinamika-tsen-obyavlenii (Вторичный, asking).
# #R2-H1: residential_real_estate_prices УБРАН — для обл.66 это 100% «Первичный рынок»
@ -356,7 +442,14 @@ _city_price_bands_cache: tuple[dict[tuple[int, str], tuple[int, int]], float] |
def _load_city_price_bands(db: Session) -> dict[tuple[int, str], tuple[int, int]]:
"""#2478 + #3051 (298): {(region_code, city): (ppm2_min, ppm2_max)} из deal_city_price_bands.
"""#2478 + #3051: {(region_code, city_key): (ppm2_min, ppm2_max)} из deal_city_price_bands.
ВАЖНО (#3051, округа): вторая часть ключа — НЕ deals.city, а deal_city_key
(округ из raw_payload.src_city при наличии, иначе city), см.
app/services/deal_city_key.py. Для региона 66 это тот же city (src_city пуст у
всех его сделок), для Москвы 197 округов + строка-фолбэк 'Москва'. Потолок
полосы теперь региональный (регион 66 800000, регион 77 1766742), поэтому
дорогой округ (p99 до 1 405 882 /м²) больше не срезается.
Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) таблица рефрешится
периодическим ре-запуском derivation (298 / deal_city_price_bands_refresh),
@ -1891,8 +1984,8 @@ def _fetch_dkp_corridor(
f"""
SELECT d.price_per_m2, d.deal_date
FROM deals d
LEFT JOIN deal_city_price_bands b
ON b.region_code = d.region_code AND b.city = d.city
-- #3051: ступень 1 — полоса округа (src_city), ступень 2 — полоса города.
{_DEAL_CITY_BAND_JOIN_D}
WHERE d.source = 'rosreestr'
AND d.region_code = CAST(:region_code AS int)
AND d.address ILIKE :street_pattern
@ -1904,13 +1997,12 @@ def _fetch_dkp_corridor(
AND d.price_per_m2 > 0
{city_filter_sql}
-- #699 + #2478 + #3051: режем нерыночные ppm²-выбросы из коридора
-- expected_sold. Per-(region, city) band (deal_city_price_bands,
-- миграция 178, ключ (region_code, city) миграция 298) когда для
-- (региона, города) сделки есть строка; иначе (в т.ч. Екатеринбург
-- region_code=66, НАМЕРЕННО не в таблице) fallback на глобальные
-- DEAL_MIN_PPM2/DEAL_MAX_PPM2 byte-identical.
AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int))
AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int))
-- expected_sold. Полоса берётся по ключу-округу (b), при промахе
-- по городу сделки (bc), при промахе обеих глобальные
-- DEAL_MIN_PPM2/DEAL_MAX_PPM2 (в т.ч. Екатеринбург region_code=66,
-- НАМЕРЕННО не в таблице) byte-identical. Расщепиться границам
-- некуда: ppm2_min/ppm2_max в таблице NOT NULL.
AND d.price_per_m2 {_DEAL_CITY_BAND_BOUNDS}
"""
),
{
@ -1935,7 +2027,10 @@ def _fetch_dkp_corridor(
# #794: apply СберИндекс time-adjustment to re-base stale Rosreestr ДКП ppm²
# to the latest available index month. Graceful: factor=1.0 when table is empty.
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
# #3051: ряд по региону ЗАПРОСА, а не константа. Для region_code=66 резолвер
# отдаёт ту же «Свердловскую область» — поведение обл.66 байт-в-байт прежнее.
sber_region = sber_region_series_name(region_code)
series = _load_sber_index_series(db, region=sber_region)
adjusted: list[float] = []
factors_applied: list[float] = []
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
@ -1973,11 +2068,11 @@ def _fetch_dkp_corridor(
city_rows = (
db.execute(
text(
"""
f"""
SELECT d.price_per_m2, d.deal_date
FROM deals d
LEFT JOIN deal_city_price_bands b
ON b.region_code = d.region_code AND b.city = d.city
-- #3051: та же двухступенчатая полоса — округ (b), затем город (bc).
{_DEAL_CITY_BAND_JOIN_D}
WHERE d.source = 'rosreestr'
AND d.region_code = CAST(:region_code AS int)
AND d.city IS NOT NULL
@ -1987,8 +2082,7 @@ def _fetch_dkp_corridor(
AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
AND d.price_per_m2 > 0
AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int))
AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int))
AND d.price_per_m2 {_DEAL_CITY_BAND_BOUNDS}
"""
),
{
@ -2046,7 +2140,7 @@ def _fetch_dkp_corridor(
len(factors_applied),
min(factors_applied),
max(factors_applied),
SBER_TIME_ADJUST_REGION,
sber_region,
)
# #1520: используем P10/P90 вместо абсолютных min/max, чтобы коридор был
# устойчив к выбросам (один нерыночный ДКП не сдвигает границу).
@ -4084,6 +4178,28 @@ def _price_from_inputs(
# ── Public ───────────────────────────────────────────────────────────────────
def _request_region_code(payload: TradeInEstimateInput) -> int:
"""Регион ЗАПРОСА на оценку (#3051) — чем ограничивать геокодер.
Приоритет: координаты payload (фронт уже разрезолвил адрес
автокомплитом/картой самый надёжный источник) `city_hint`, если это
узнаваемое имя города какого-то региона реестра дефолт 66.
Регресс-нейтральность 66: точка вне охвата, неизвестный/пустой city_hint и
любой свердловский city_hint одинаково дают 66 ровно прежнее поведение
(geocode() и так ходил с region_code=66). Отличие есть ТОЛЬКО для московских
данных, которые раньше молча отвергались провайдерами.
"""
if payload.lat is not None and payload.lon is not None:
region = regions_mod.region_for_point(payload.lat, payload.lon)
if region is not None:
return region.code
region = regions_mod.region_by_city(payload.city_hint)
if region is not None:
return region.code
return regions_mod.DEFAULT_REGION_CODE
async def estimate_quality(
payload: TradeInEstimateInput,
db: Session,
@ -4141,10 +4257,15 @@ async def estimate_quality(
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
# not a correctness gate — narrower fast-path just meant more oblast requests fell
# through to geocode() (still correct, just slower).
# #3051: проверка стала региононезависимой — координаты принимаются, если
# попадают в bbox_region ЛЮБОГО региона реестра (regions.region_for_point).
# Для обл.66 это тот же bbox (OBLAST66_BBOX == REGIONS[66].bbox_region), т.е.
# поведение байт-в-байт прежнее; добавились только координаты Москвы,
# которые раньше зря платили round-trip в geocode() со свердловской рамкой.
if (
payload.lat is not None
and payload.lon is not None
and is_within_oblast66_bbox(payload.lat, payload.lon)
and regions_mod.region_for_point(payload.lat, payload.lon) is not None
):
geo = GeocodeResult(
lat=payload.lat,
@ -4163,8 +4284,17 @@ async def estimate_quality(
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
# честно сигнализирует об этом ниже.
# #3051: региональный скоуп геокодера. Оба тира geocode() ограничены
# регионом ЖЁСТКО (DaData locations.region, Nominatim viewbox+bounded=1),
# поэтому без region_code московский адрес возвращал None → пустая оценка
# с reason='address_not_geocoded'. Для 66 значение прежнее (дефолт).
geo = await _with_budget(
geocode(payload.address, db, city_hint=payload.city_hint),
geocode(
payload.address,
db,
city_hint=payload.city_hint,
region_code=_request_region_code(payload),
),
settings.estimate_geocode_budget_s,
label="geocode",
)
@ -6554,6 +6684,36 @@ def _fetch_analogs(
return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W"
# ── #3051: вторая ступень полосы обязательна, когда есть чем судить ──────────
#
# ДЕФЕКТ, который чинится. city_fallback был обычным именованным параметром со
# значением None: забывший его вызывающий молча получал ОДНОСТУПЕНЧАТЫЙ поиск —
# ровно тот дефект, ради которого вводилась вторая ступень. Московская сделка по
# 30 000 ₽/м² (легитимный рынок Некрасовки, внутри городской полосы 22475..772165)
# не находила строку своего округа, проваливалась в глобальные DEAL_MIN_PPM2=50000
# / DEAL_MAX_PPM2=800000 — калибровку Екатеринбурга — и отбрасывалась. Тихо, без
# единого признака в коде вызова.
#
# ВЫБОР: параметр обязателен УСЛОВНО (bands is not None), а не безусловно.
# Безусловно обязательный (keyword-only без дефолта) сломал бы 30 позиционных
# вызовов вида _is_plausible_deal(150_000, 5, 9) в tests/test_deals_sanitize.py,
# и сломал бы их без пользы: при bands=None таблицы полос нет, resolve_city_band
# на обеих ступенях промахивается и всё равно отдаёт глобальные константы —
# пропуск второй ступени там не наблюдаем и вреда не несёт. Дефект существует
# ровно тогда, когда полосы переданы, — там пропуск и ловится, TypeError'ом.
# Явный city_fallback=None разрешён: отказ от второй ступени виден в коде вызова.
class _CityFallbackRequired:
"""Часовой: отличает «параметр не передан» от переданного None."""
__slots__ = ()
def __repr__(self) -> str: # pragma: no cover - только для текста ошибки
return "<city_fallback не передан>"
_CITY_FALLBACK_REQUIRED = _CityFallbackRequired()
def _is_plausible_deal(
price_per_m2: float | None,
floor: int | None,
@ -6563,6 +6723,7 @@ def _is_plausible_deal(
city: str | None = None,
bands: dict[tuple[int, str], tuple[int, int]] | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
city_fallback: str | None | _CityFallbackRequired = _CITY_FALLBACK_REQUIRED,
) -> bool:
"""#699 + Mera-audit fix-2 + #2478 + #3051 (298): True если ДКП-сделка правдоподобна.
@ -6577,13 +6738,32 @@ def _is_plausible_deal(
вызовы не ломаются. (region_code, city) не в bands (в т.ч. Екатеринбург
region_code=66 НАМЕРЕННО не в таблице) или bands=None fallback на
глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical сегодняшнему
поведению).
поведению). #3051: между ними — ВТОРАЯ ступень city_fallback (deals.city),
чтобы округ без своей строки (таблицу наполняет ночная задача) деградировал
в городскую полосу, а не в ЕКБ-константы. Регион 66: city == city_fallback,
обе ступени дают одну строку. city_fallback ОБЯЗАТЕЛЕН, когда передан bands:
его пропуск там TypeError, а не тихий одноступенчатый поиск.
- floor < 1 или floor > DEAL_MAX_FLOOR drop (битый парсер: floor=-5/999)
- floor > total_floors физически невозможен drop
- area_m2 задана и <= 0 drop (битый парсер)
- price_rub задана и <= 0 drop (нерыночная/технческая сделка)
"""
ppm_min, ppm_max = (bands or {}).get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2))
# #3051: пропуск второй ступени при переданных полосах — ошибка вызывающего,
# а не «поведение по умолчанию» (обоснование выбора — над _CityFallbackRequired).
if isinstance(city_fallback, _CityFallbackRequired):
if bands is not None:
raise TypeError(
"_is_plausible_deal: при переданных bands обязателен city_fallback "
"(вторая ступень полосы — deals.city). Передайте city_fallback=None "
"явно, если второй ступени намеренно нет."
)
# bands=None: обе ступени всё равно промахиваются → глобальные константы.
city_fallback = None
# #3051: ступень 1 — city (сюда приходит ключ-округ), ступень 2 — city_fallback
# (deals.city), ступень 3 — глобальные константы. Тот же порядок, что в SQL.
ppm_min, ppm_max = resolve_city_band(
bands, region_code, city, city_fallback, (DEAL_MIN_PPM2, DEAL_MAX_PPM2)
)
if price_per_m2 is not None and not (ppm_min <= price_per_m2 <= ppm_max):
return False
if floor is not None:
@ -6604,11 +6784,15 @@ def _fetch_deals(
rows = (
db.execute(
text(
"""
f"""
SELECT
source, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2, city, region_code,
-- #3051: ключ полосы цен (округ Москвы при наличии src_city, иначе
-- city). Считаем в SQL, чтобы не тянуть raw_payload целиком; ниже
-- колонка выкусывается из dict сделки и в ответ API не попадает.
{_DEAL_CITY_KEY_SQL_PLAIN} AS {DEAL_CITY_KEY_COLUMN},
deal_date, days_on_market,
cadastral_number,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
@ -6645,20 +6829,26 @@ def _fetch_deals(
# lookup не падал на None-ключе.
bands = _load_city_price_bands(db)
deals = [dict(r) for r in rows]
clean = [
d
for d in deals
# #3051 (округа Москвы): ключ полосы — deal_city_key (округ из src_city, иначе
# city), тот же, по которому derivation строит строки таблицы. Служебную
# колонку city_key выкусываем ДО фильтра: форма возвращаемых сделок (и, значит,
# ответа API actual_deals) остаётся прежней.
clean: list[dict[str, Any]] = []
for d in deals:
city_key = deal_city_key(d)
d.pop(DEAL_CITY_KEY_COLUMN, None)
if _is_plausible_deal(
d.get("price_per_m2"),
d.get("floor"),
d.get("total_floors"),
d.get("area_m2"),
d.get("price_rub"),
city=d.get("city"),
city=city_key,
city_fallback=d.get("city"),
bands=bands,
region_code=d.get("region_code") or regions_mod.DEFAULT_REGION_CODE,
)
]
):
clean.append(d)
if len(clean) < len(deals):
logger.info("deals sanitize #699: %d%d (dropped outliers)", len(deals), len(clean))
return clean

View file

@ -757,6 +757,30 @@ def _region_viewbox(region: Region) -> str:
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
def _viewbox_for_region(region_code: int) -> str:
"""Nominatim `viewbox` по коду региона — ЕДИНАЯ точка для всех тиров.
`region_code=66` литеральная `OBLAST66_VIEWBOX["viewbox"]`: значение
историческое, из bbox не выводится, поэтому byte-identical прежнему
поведению. Прочие регионы рамка из реестра (`_region_viewbox`).
"""
if region_code == 66:
return OBLAST66_VIEWBOX["viewbox"]
return _region_viewbox(_ALL_REGIONS[region_code])
def _region_default_city(region_code: int) -> str:
"""Главный город региона — текстовый суффикс запроса, когда город не назван.
`region_code=66` литеральный "Екатеринбург" (byte-identical dual-query
#2580/C2). Прочие — `canonical_city` реестра, иначе `city_token` с заглавной.
"""
if region_code == 66:
return "Екатеринбург"
region = _ALL_REGIONS[region_code]
return region.canonical_city or region.city_token.capitalize()
async def _nominatim_query(
client: httpx.AsyncClient, address: str, region_code: int = 66
) -> dict | None:
@ -775,7 +799,7 @@ async def _nominatim_query(
поведению (те же bbox-значения и та же viewbox-строка).
"""
region = _ALL_REGIONS[region_code]
viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region)
viewbox = _viewbox_for_region(region_code)
await _nominatim_throttle()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -933,8 +957,24 @@ class GeocodeSuggestion:
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# region_code → значение поля DaData `region` (БЕЗ типа: «Свердловская», а не
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
def _dadata_region_name(region_code: int) -> str:
"""Имя региона для hard-констрейнта DaData. Неизвестный код → ValueError."""
try:
return _DADATA_REGION_NAMES[region_code]
except KeyError as exc:
raise ValueError(f"dadata suggest: unknown region_code={region_code!r}") from exc
async def _dadata_suggest(
query: str, limit: int = 8, region_code: int = 66
) -> list[GeocodeSuggestion]:
"""Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
Дроп candidate'ов без координат (DaData возвращает их для широких categories
@ -945,9 +985,8 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
внутри `suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/
Серов/etc никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
region_name = _dadata_region_name(region_code)
raw = await dadata.suggest_addresses(query, limit=limit, city=None, region=region_name)
if not raw:
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
@ -957,7 +996,7 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
"dadata suggest: 0 кандидатов для %r при region=%r"
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
query[:60],
SVERDLOVSK_OBLAST_REGION,
region_name,
)
out: list[GeocodeSuggestion] = []
for s in raw:
@ -980,8 +1019,14 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
return out
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
async def _nominatim_query_multi(
client: httpx.AsyncClient, query: str, limit: int, region_code: int = 66
) -> list[dict]:
"""Один Nominatim search с рамкой региона `region_code`. Возвращает up to N items.
`region_code=66` (дефолт) byte-identical прежнему поведению: та же
viewbox-строка `OBLAST66_VIEWBOX` (см. `_viewbox_for_region`).
"""
await _nominatim_throttle()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -990,7 +1035,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
"format": "json",
"limit": str(limit),
"countrycodes": "ru",
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"viewbox": _viewbox_for_region(region_code),
"bounded": "1",
"addressdetails": "1",
},
@ -1027,7 +1072,12 @@ def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]:
async def _nominatim_query_city_aware(
client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int
client: httpx.AsyncClient,
query: str,
city: str | None,
city_specified: bool,
limit: int,
region_code: int = 66,
) -> list[dict]:
"""Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город.
@ -1050,18 +1100,25 @@ async def _nominatim_query_city_aware(
ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок).
"""
if city:
return await _nominatim_query_multi(client, f"{query}, {city}", limit)
return await _nominatim_query_multi(
client, f"{query}, {city}", limit, region_code=region_code
)
if city_specified:
return await _nominatim_query_multi(client, query, limit)
ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
bare_data = await _nominatim_query_multi(client, query, limit)
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Город неизвестен — dual-query с суффиксом главного города региона
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi(
client, f"{query}, {default_city}", limit, region_code=region_code
)
bare_data = await _nominatim_query_multi(client, query, limit, region_code=region_code)
return _dedupe_nominatim_items(city_data, bare_data)[:limit]
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
query: str, limit: int = 8, city_hint: str | None = None, region_code: int = 66
) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
запрос ничего не находит).
@ -1079,17 +1136,26 @@ async def _nominatim_suggest(
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
}
city, city_specified = _resolve_city_for_geocode(query, city_hint)
city, city_specified = _resolve_city_for_geocode(query, city_hint, region_code)
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query
data = await _nominatim_query_city_aware(client, query, city, city_specified, limit)
data = await _nominatim_query_city_aware(
client, query, city, city_specified, limit, region_code=region_code
)
# Tier 2: typo-варианты если оригинал пустой
if not data:
for variant in _typo_variants(query, limit=3):
variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint)
variant_city, variant_specified = _resolve_city_for_geocode(
variant, city_hint, region_code
)
data = await _nominatim_query_city_aware(
client, variant, variant_city, variant_specified, limit
client,
variant,
variant_city,
variant_specified,
limit,
region_code=region_code,
)
if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
@ -1765,7 +1831,11 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
async def suggest(
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
query: str,
db: Session | None = None,
limit: int = 8,
city_hint: str | None = None,
region_code: int = 66,
) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Nominatim [].
@ -1779,10 +1849,22 @@ async def suggest(
(#2593: Yandex Geocoder, который был primary external provider до DaData,
удалён). DaData region-constraint уже охватывает всю область (не только
ЕКБ) city_hint ей не нужен.
region_code: регион покрытия (дефолт 66, #3051) — какой регион уходит в
hard-констрейнты провайдеров: DaData `region` (`_dadata_region_name`) и
Nominatim `viewbox`+bounded (`_viewbox_for_region`). БЕЗ него московский
адрес молча схлопывался в пустой список: оба констрейнта ФИЛЬТРЫ, а не
boost, и «не тот регион» неотличимо от «адрес не найден». Локальные
ЕКБ-тиры (кадастр) для region_code != 66 пропускаются целиком данных
по другим регионам в FDW физически нет. Дефолт byte-identical
прежнему поведению по Свердловской области.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
"""
if not query or len(query.strip()) < 2:
return []
try:
_ALL_REGIONS[region_code]
except KeyError as exc:
raise ValueError(f"suggest: unknown region_code={region_code!r}") from exc
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
# EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно
@ -1792,7 +1874,11 @@ async def suggest(
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
# они уже oblast-aware.
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
# #3051: `region_code != 66` закрывает кадастровый тир ДО `_ekb_local_tiers_allowed`
# — gendesign_cad_buildings содержит только ЕКБ, звать его для Москвы значит
# платить FDW-round-trip ради гарантированного нуля (тот же гейт в
# `_geocode_resolve`; сигнатуру `_ekb_local_tiers_allowed` умышленно не трогаем).
if db is not None and region_code == 66 and _ekb_local_tiers_allowed(query, city_hint):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
parsed = _parse_street_house(query.strip())
@ -1810,7 +1896,7 @@ async def suggest(
# лучший fit для РФ адресов.
if settings.dadata_api_token:
try:
dadata_results = await _dadata_suggest(query, limit)
dadata_results = await _dadata_suggest(query, limit, region_code)
if dadata_results:
return dadata_results
except Exception:
@ -1818,7 +1904,7 @@ async def suggest(
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
try:
return await _nominatim_suggest(query, limit, city_hint=city_hint)
return await _nominatim_suggest(query, limit, city_hint=city_hint, region_code=region_code)
except Exception:
logger.exception("nominatim suggest failed")
return []

View file

@ -14,10 +14,16 @@ kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refres
asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же
свежему срезу deals, что и ratio-таблица того же дня.
SQL derivation ниже БАЙТ-В-БАЙТ та же логика, что seed в
SQL derivation ниже держит ту же трёхуровневую схему, что seed в
data/sql/298_deal_city_price_bands_region.sql (region_stats / city_stats / tiered:
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
комментарий в 194/298 для полного обоснования тиров и hard floor/ceiling клампов).
full N>=30 / rough N 10-29 / region_fallback N 1-9, см. комментарий в 194/298 для
полного обоснования тиров и hard floor'а 8000), но РАСХОДИТСЯ с ним в двух местах
(#3051, округа Москвы): ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''),
city) вместо голого city, и потолок ppm² региональный region_ppm2_max вместо
литерала 800000. Полное обоснование обоих в комментарии над _REDERIVE_SQL.
Для региона 66 обе правки тождественны прежнему поведению (src_city пуст у всех
его сделок, региональный потолок вырождается ровно в 800000) проверено на проде
2026-09-10 пересчётом: 383 строки, все совпадают с текущими.
#3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и
city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице
@ -25,8 +31,10 @@ deals целиком. Без этого пул для tier='region_fallback' о
подмешивал бы сделки другого (Москва в deals region_code=77 иначе тянула бы
p1-floor малых городов Свердловской обл. region_code=66 вверх). Для
region_code=66 derivation байт-в-байт прежняя (194): фильтр
NOT (region_code = 66 AND city = 'Екатеринбург') тот же инвариант, что
раньше `city <> 'Екатеринбург'`; region_stats/city_stats для региона 66 видят
NOT (region_code = 66 AND <ключ> = 'Екатеринбург'), где <ключ> то же
выражение, по которому идёт GROUP BY (прод 2026-09-11: у региона 66 ключ == city
у всех 108 623 сделок, обе формы исключают одни и те же 55 749 строк);
region_stats/city_stats для региона 66 видят
ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals.
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
@ -35,7 +43,7 @@ NOT (region_code = 66 AND city = 'Екатеринбург') — тот же и
сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город,
перешедший в другой tier, просто перезаписывается на следующем refresh.
Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND
city = 'Екатеринбург')) estimator.py fallback на глобальные
<ключ> = 'Екатеринбург')) estimator.py fallback на глобальные
DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из
178/194/298 сохранён).
"""
@ -48,63 +56,179 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.deal_city_key import deal_city_key_sql
logger = logging.getLogger(__name__)
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 298, region-aware) ──────────────────
_REDERIVE_SQL = text(
# Ключ города сделки — ОДНО выражение на derivation и на читающую сторону
# (estimator.py), см. app/services/deal_city_key.py. Здесь alias пустой:
# в запросе ниже `FROM deals` без алиаса.
_CITY_KEY_SQL = deal_city_key_sql(alias="")
# ── Числа формулы регионального потолка ppm² (#3051) ─────────────────────────
# ОДИН источник и для SQL (_REGION_CEILING_SQL ниже), и для питоновского
# эквивалента region_ppm2_max(). Раньше формула жила двумя копиями (SQL +
# локальная копия в тесте): подмена множителя 6 на 3 оставляла ВСЕ тесты
# зелёными, тихо роняя потолок Москвы с 1766742 до 883371 и снова срезая дорогие
# округа. Теперь правка любого из этих чисел автоматически едет в обе стороны.
REGION_CEILING_FLOOR = 800_000 # исторический якорь: ниже потолок не падает нигде
REGION_CEILING_MEDIAN_MULT = 6 # шесть медианных ₽/м² региона — заведомо не рынок
REGION_CEILING_MEDIAN_Q = 0.5 # медиана региона
REGION_CEILING_CAP_Q = 0.9999 # шапка: одиночный мусорный выброс не раздувает потолок
def region_ppm2_max(p_cap: int, p_median: int) -> int:
"""Региональный потолок ppm²: GREATEST(floor, LEAST(p99.99, mult * медиана)).
Питоновский эквивалент _REGION_CEILING_SQL собран из ТЕХ ЖЕ констант, а не
из своих чисел. Замеры прода 2026-09-10: регион 66 (615312, 52706) = 800000
(тот же прежний литерал), регион 77 (1944535, 294457) = 1766742.
"""
return max(REGION_CEILING_FLOOR, min(p_cap, REGION_CEILING_MEDIAN_MULT * p_median))
_REGION_CEILING_CAP_SQL = (
f"round(percentile_cont({REGION_CEILING_CAP_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int"
)
_REGION_CEILING_MEDIAN_SQL = (
f"{REGION_CEILING_MEDIAN_MULT} * "
f"round(percentile_cont({REGION_CEILING_MEDIAN_Q}) WITHIN GROUP (ORDER BY price_per_m2))::int"
)
_REGION_CEILING_SQL = (
f"GREATEST({REGION_CEILING_FLOOR}, "
f"LEAST({_REGION_CEILING_CAP_SQL}, {_REGION_CEILING_MEDIAN_SQL}))"
)
# ── Derivation + re-seed (region-aware; #3051 округа Москвы + региональный потолок) ──
#
# #3051 (округа). Ключ города — COALESCE(NULLIF(raw_payload->>'src_city',''), city)
# вместо голого city: у московских сделок src_city несёт муниципальный округ
# (заполнен у 93.27% из 212 937), и вместо ОДНОЙ полосы 'Москва' 34221..718870
# получается 197 ключей — 152 в тире full, 8 rough, 37 region_fallback. Остаточные
# 6.73% сделок без src_city дают собственную строку 'Москва' (n=14376, tier full,
# 22475..772165) — они не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2,
# откалиброванные под ЕКБ. Регион 66 не меняется: src_city пуст у всех его сделок
# (прод 2026-09-10: 0 строк, где ключ != city).
#
# #3051 (потолок). Литерал 800000 был калибровкой Свердловской области, а в Москве
# p99 округов доходит до 1 405 882 ₽/м² — 15 округов из 197 упирались в потолок,
# т.е. он резал не опечатки, а легитимный рынок. Потолок стал РЕГИОНАЛЬНЫМ
# (region_ppm2_max в region_stats):
# GREATEST(800000, LEAST(p9999_региона, 6 * медиана_региона))
# Три множителя, каждый со своим смыслом: 800000 — исторический якорь, ниже
# которого потолок не опускается нигде (страхует и от обвала цен); 6 * медиана —
# привязка к масштабу региона (шесть медианных ₽/м² — заведомо не рынок, а
# опечатка или доля); p99.99 — жёсткая шапка, чтобы одиночный мусорный выброс не
# раздул потолок. Замеры: регион 66 → GREATEST(800000, LEAST(615312, 316236)) =
# 800000, тот же литерал; регион 77 → GREATEST(800000, LEAST(1944535, 1766742)) =
# 1766742.
#
# Инвариант региона 66 проверен на проде 2026-09-10 пересчётом по этому же
# выражению: 383 строки против 383 текущих, все совпадают по
# (ppm2_min, ppm2_max, n_deals, tier). Запас прочности: чтобы потолок 66 сдвинулся,
# нужно ОДНОВРЕМЕННО медиане перевалить 133 333 (сейчас 52 706, x2.53) и p99.99
# перевалить 800 000 (сейчас 615 312, x1.3).
#
# Тиры (full N>=30 / rough N 10-29 / region_fallback N<10) и обоснование floor'а
# 8000 — без изменений, см. миграции 194/298.
_REDERIVE_SQL = text(
f"""
WITH region_stats AS (
SELECT
region_code,
GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
) AS region_ppm2_min,
{_REGION_CEILING_SQL} AS region_ppm2_max
FROM deals
WHERE source = 'rosreestr'
AND doc_type = 'ДКП'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
-- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY
-- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city
-- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком
-- и из её собственной городской строки, и из региональной статистики, молча
-- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11:
-- в популяции 321 560 сделок, city IS NULL 0 строк, поэтому обе формы
-- сегодня тождественны: регион 66 52 874 строки, p1=15345, p50=52706,
-- p99.99=615312; регион 77 212 937 строк, 34221 / 294457 / 1944535;
-- расхождение 0 по всем регионам. Совпадение больше не держится на данных.
AND {_CITY_KEY_SQL} IS NOT NULL
AND region_code IS NOT NULL
AND NOT (region_code = 66 AND city = 'Екатеринбург')
-- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт
-- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ
-- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623
-- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых
-- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0).
-- Появись источник с src_city='Екатеринбург' у сделки с другим city
-- старая форма пропустила бы её в derivation и завела строку полосы
-- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок,
-- сломав намеренное исключение. Теперь исключение и ключ одно выражение.
AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург')
GROUP BY region_code
),
city_stats AS (
SELECT
region_code,
city,
{_CITY_KEY_SQL} AS city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_p1,
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
-- p99 сырой: клампится региональным потолком в ветке full ниже,
-- а не литералом 800000 (см. шапку).
round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int
AS ppm2_p99,
count(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND doc_type = 'ДКП'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
-- #3051: непустоту ключа судим ТЕМ ЖЕ выражением, по которому идут GROUP BY
-- и предикат ЕКБ ниже (было: сырая колонка city). Сделка с непустым src_city
-- и NULL в city даёт ВАЛИДНЫЙ ключ, но сырой фильтр выбрасывал её целиком
-- и из её собственной городской строки, и из региональной статистики, молча
-- занижая n_deals и перцентили региона, в том числе потолок. Прод 2026-09-11:
-- в популяции 321 560 сделок, city IS NULL 0 строк, поэтому обе формы
-- сегодня тождественны: регион 66 52 874 строки, p1=15345, p50=52706,
-- p99.99=615312; регион 77 212 937 строк, 34221 / 294457 / 1944535;
-- расхождение 0 по всем регионам. Совпадение больше не держится на данных.
AND {_CITY_KEY_SQL} IS NOT NULL
AND region_code IS NOT NULL
AND NOT (region_code = 66 AND city = 'Екатеринбург')
GROUP BY region_code, city
-- #3051: исключение ЕКБ судится ТЕМ ЖЕ выражением ключа, по которому идёт
-- GROUP BY ниже (было: голая колонка city). Разъехавшиеся предикат и ключ
-- держались на данных: сегодня у региона 66 src_city пуст у всех 108 623
-- сделок, ключ == city, и обе формы дают одни и те же 55 749 исключённых
-- строк (прод 2026-09-11: by_city=55749, by_key=55749, расхождение 0).
-- Появись источник с src_city='Екатеринбург' у сделки с другим city
-- старая форма пропустила бы её в derivation и завела строку полосы
-- 'Екатеринбург', которую ступень 1 нашла бы для настоящих ЕКБ-сделок,
-- сломав намеренное исключение. Теперь исключение и ключ одно выражение.
AND NOT (region_code = 66 AND {_CITY_KEY_SQL} = 'Екатеринбург')
GROUP BY region_code, {_CITY_KEY_SQL}
),
tiered AS (
SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
SELECT c.region_code, c.city, c.ppm2_p1 AS ppm2_min,
LEAST(c.ppm2_p99, r.region_ppm2_max) AS ppm2_max, c.n_deals,
'full'::text AS tier
FROM city_stats
WHERE n_deals >= 30
AND ppm2_p99 >= 8000
FROM city_stats c
JOIN region_stats r ON r.region_code = c.region_code
WHERE c.n_deals >= 30
AND c.ppm2_p99 >= 8000
UNION ALL
SELECT region_code, city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max,
n_deals, 'rough'::text AS tier
FROM city_stats
WHERE n_deals BETWEEN 10 AND 29
SELECT c.region_code, c.city,
LEAST(c.ppm2_p1, r.region_ppm2_max - 100000) AS ppm2_min,
r.region_ppm2_max AS ppm2_max,
c.n_deals, 'rough'::text AS tier
FROM city_stats c
JOIN region_stats r ON r.region_code = c.region_code
WHERE c.n_deals BETWEEN 10 AND 29
UNION ALL
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max,
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min,
r.region_ppm2_max AS ppm2_max,
c.n_deals, 'region_fallback'::text AS tier
FROM city_stats c
JOIN region_stats r ON r.region_code = c.region_code

View file

@ -0,0 +1,685 @@
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся. Замер по проду (60 464 карточки): с округом 35 551, ВСЕ внутри
bbox региона 77; без округа внутри bbox 17 576 (это Московская область, регион
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» артефакт
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр.
Два сигнала, и оба нужны ни один по отдельности не годится.
1. Город по версии самого Авито слаг в `source_url`
(`avito.ru/<slug>/kvartiry/...`). Заполнен у 100% карточек, стоит 0 вызовов:
`moskva` 21 841 карточка, остальное муниципалитеты области (balashiha,
himki, podolsk, ). Это единственный ТОЧНЫЙ признак населённого пункта, но
регион по нему не выводится: Троицк/Щербинка/Коммунарка/Московский/
Зеленоград свои слаги, а регион у них 77 (Новая Москва и ЗелАО).
2. Координаты и регион DaData /suggest/address с `locations`-констрейнтом.
Слаг сужает констрейнт (`moskva` только регион «Москва», иначе «Москва»
И «Московская» разом), но РЕГИОН БЕРЁТСЯ ИЗ ОТВЕТА (`region_kladr_id`), а не
из слага иначе Новая Москва уехала бы в область.
Почему не наоборот (только геокод, без слага). Замер на 200 случайных адресах
Авито с констрейнтом «Москва + Московская»: дом с координатами нашёлся у 184
(92%), но верхний кандидат разошёлся со слагом у 28 из 184 (15%) и почти
всегда в пользу Москвы («пр-т Мира,19» при слаге fryazino «г Москва, пр-кт
Мира, 19»). Голый адрес без города DaData тянет в столицу; 15% чужих домов в
регионе 77 ровно та ошибка, ради которой стоял `--allow-unfiltered`.
С сужением по слагу (`moskva` регион «Москва») резолв 77 из 82 (94%),
qc_geo=0 у 97% найденных.
Что куда едет:
* регион 77 в `listings`, С координатами (geom есть сразу, radius-подбор
аналогов работает без ожидания `geocode_missing`);
* регион 50 НЕ пишется, ждёт появления региона 50 в реестре; лежит не в
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) когда
регион появится, прогон по этой полке уже не потребует внешних вызовов;
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33»)
свой счётчик, карточка не пишется.
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
то есть по ссылке на ЖК, а не по продаже застройщиком. Замер по всем 60 464:
`raw_payload.is_from_developer` = true у НУЛЯ карточек, false у 29 000,
отсутствует у 31 464 застройщик в этом корпусе не продаёт ничего, это вся
вторичка. В estimator'е стоит гвард (`estimator.py:5992-5995`): в аналоги идут
только строки с `listing_segment IS NULL` или 'vtorichka'. Скопируй мы метку
кита 29 000 карточек выпали бы из подбора. Поэтому метка считается заново:
is_from_developer is True 'novostroyki', иначе 'vtorichka'.
Идемпотентность на стороне `save_listings`: он делает upsert
`ON CONFLICT (dedup_hash) DO UPDATE` плюс reconcile-UPDATE по
`(source, source_id)` на случай дрейфа хеша. `dedup_hash` = sha256(source +
source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), цена в ключ не
входит. Повторный прогон поэтому обновляет те же строки, а не плодит дубли;
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы.
Запуск:
python -m app.tasks.msk_raw_import --dry-run
python -m app.tasks.msk_raw_import --limit 500
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import re
from dataclasses import dataclass
from urllib.parse import urlsplit
from pydantic import ValidationError
from scraper_kit.base import ScrapedLot, save_listings
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services import dadata
from app.services.geocoder import normalize_address
from app.services.scraper_adapters import RealMatcherAdapter
logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77
MOSCOW_CITY = "Москва"
# Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён —
# карточки области не пишутся, а откладываются (см. докстринг модуля).
OBLAST_REGION_CODE = 50
DEFAULT_BATCH_SIZE = 500
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
AVITO_MOSCOW_SLUG = "moskva"
# Имена регионов для DaData-констрейнта `locations`. DaData хранит имя БЕЗ типа
# («Москва», «Московская»), тип лежит отдельно в `region_type` — с типом
# hard-фильтр молча схлопывает выдачу в ноль (та же грабля, что в
# `geocoder.SVERDLOVSK_OBLAST_REGION`).
DADATA_MOSCOW_REGION = "Москва"
DADATA_OBLAST_REGION = "Московская"
# Первые две цифры КЛАДР региона в ответе DaData → код региона.
_KLADR_TO_REGION = {"77": MOSCOW_REGION_CODE, "50": OBLAST_REGION_CODE}
# Дневной потолок внешних вызовов. Free tier DaData /suggest — 10 000/сутки на
# аккаунт, и тот же аккаунт обслуживает автокомплит формы оценки; берём с запасом.
DEFAULT_GEOCODE_LIMIT = 9000
# Сколько запросов к DaData держим в полёте одновременно. Больше смысла нет:
# упираемся не в нас, а в квоту.
_GEOCODE_CONCURRENCY = 6
# Префиксы административных округов Москвы — единственный признак города в адресе
# карточки Циана (сам город в адрес не попадает).
MOSCOW_OKRUGS = (
"ЦАО",
"САО",
"СВАО",
"ВАО",
"ЮВАО",
"ЮАО",
"ЮЗАО",
"ЗАО",
"СЗАО",
"ЗелАО",
"НАО",
"ТАО",
)
# Lookahead вместо \b: следом за округом идёт запятая/пробел, но НЕ буква — иначе
# «ЗАО» матчило бы начало гипотетического «ЗАОзёрная».
_MOSCOW_OKRUG_RE = re.compile(
r"^(?:" + "|".join(MOSCOW_OKRUGS) + r")(?![А-Яа-яЁёA-Za-z])",
)
# Артефакт парсера: адрес вида «Екатеринбург (Cian)» в московском корпусе.
_ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE)
# Вью-источники. Только whitelist: имя подставляется в SQL текстом, параметром
# идентификатор не передать.
SOURCE_VIEWS = {
"cian": "msk_raw.cian_latest",
"avito": "msk_raw.avito_latest",
}
_PAGE_SQL = """
SELECT id, payload
FROM {view}
WHERE id > :after
ORDER BY id
LIMIT :limit
"""
# ── Пред-геокод Авито ────────────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class GeoPoint:
"""Разрешённая точка адреса Авито. `region_code` — из ответа, не из слага."""
lat: float
lon: float
region_code: int
full_address: str
@dataclass
class GeocodeBudget:
"""Дневной потолок внешних вызовов на прогон.
Кончился не падаем, а перестаём спрашивать: карточки без точки просто не
пишутся в этот заход, а на следующем подхватятся с того же места (уже
разрешённые адреса лежат в кэше и квоты не стоят).
"""
remaining: int
spent: int = 0
exhausted: bool = False
def take(self, n: int) -> int:
"""Сколько из `n` запросов позволено сделать сейчас."""
allowed = max(0, min(n, self.remaining))
if allowed < n:
self.exhausted = True
self.remaining -= allowed
self.spent += allowed
return allowed
# Кэш пред-геокода. Живёт в `msk_raw` (рядом с сырьём, а не в прикладной схеме):
# это свойство КОРПУСА, а не приложения, и переживает пересбор listings.
# `geocode_cache` приложения сознательно не трогаем — там другой ключ (адрес +
# city_hint) и другой TTL, а нам нужен слаг в ключе и код региона в значении.
# Он же — «полка ожидания» для области: строки с region_code=50 никуда не
# пишутся, но остаются разрешёнными, и когда регион 50 появится в реестре,
# прогон по ним не потратит ни одного внешнего вызова.
#
# Строка с lat IS NULL — ОТРИЦАТЕЛЬНЫЙ результат («DaData дома не знает»), и он
# тоже кэшируется: без этого каждый повторный прогон заново тратил бы квоту на
# те же ~8% неразрешимых адресов (ЖК без улицы, «Мкр-н имени В.Н. Махалина,
# 33»). Передумать можно руками:
# DELETE FROM msk_raw.avito_geocode WHERE lat IS NULL.
_GEO_CACHE_DDL = """
CREATE TABLE IF NOT EXISTS msk_raw.avito_geocode (
cache_key text PRIMARY KEY,
slug text,
address text NOT NULL,
lat double precision,
lon double precision,
region_code smallint,
full_address text,
resolved_at timestamptz NOT NULL DEFAULT NOW()
)
"""
_GEO_CACHE_EXISTS = "SELECT to_regclass('msk_raw.avito_geocode')"
_GEO_CACHE_SELECT = """
SELECT cache_key, lat, lon, region_code, full_address
FROM msk_raw.avito_geocode
WHERE cache_key = ANY(:keys)
"""
_GEO_CACHE_UPSERT = """
INSERT INTO msk_raw.avito_geocode
(cache_key, slug, address, lat, lon, region_code, full_address)
VALUES (:key, :slug, :address, :lat, :lon, :region, :full)
ON CONFLICT (cache_key) DO UPDATE
SET lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
region_code = EXCLUDED.region_code,
full_address = EXCLUDED.full_address,
resolved_at = NOW()
"""
def avito_city_slug(payload: dict) -> str | None:
"""Слаг города из `source_url`: `avito.ru/<slug>/kvartiry/...` → `<slug>`.
Единственный признак населённого пункта, который Авито отдаёт честно и
даром. Регион из него НЕ выводится (см. докстринг модуля) он лишь сужает
констрейнт геокодера.
"""
url = payload.get("source_url")
if not isinstance(url, str) or not url:
return None
parts = [p for p in urlsplit(url).path.split("/") if p]
return parts[0] if parts else None
def geo_cache_key(address: str, slug: str | None) -> str:
"""Ключ кэша.
Слаг ЧАСТЬ ключа, не украшение: один и тот же текст адреса встречается в
разных муниципалитетах (замер: 1110 адресов из 21 570 живут сразу под
несколькими слагами), и это РАЗНЫЕ дома.
"""
return f"{slug or '-'}|{normalize_address(address)}"
def _dadata_regions_for(slug: str | None) -> list[str]:
"""Констрейнт `locations` по слагу.
Москва только столица; иначе оба региона, потому что слаг может оказаться
Новой Москвой (Троицк/Щербинка/Коммунарка/Московский) или Зеленоградом у
них свои слаги, а регион 77.
"""
if slug == AVITO_MOSCOW_SLUG:
return [DADATA_MOSCOW_REGION]
return [DADATA_MOSCOW_REGION, DADATA_OBLAST_REGION]
def _point_from_suggestions(suggestions: list) -> GeoPoint | None:
"""Лучший ДОМ с координатами из выдачи DaData, иначе None.
Берём только house-level (`kind == 'house'`) с координатами: улица/город
дают точку в середине улицы или в центре НП для radius-подбора аналогов
это хуже, чем отсутствие точки (сосед через квартал попадёт в выборку, а
настоящий сосед нет). Регион первые две цифры КЛАДР ответа.
"""
for s in suggestions:
if s.kind != "house" or s.lat is None or s.lon is None:
continue
region = _KLADR_TO_REGION.get((s.kladr_id or "")[:2])
if region is None:
continue # ни 77, ни 50 — констрейнт пробит, такой ответ не берём
return GeoPoint(
lat=float(s.lat),
lon=float(s.lon),
region_code=region,
full_address=s.unrestricted_value,
)
return None
async def _geocode_many(items: list[tuple[str, str, str | None]]) -> dict[str, GeoPoint | None]:
"""`[(cache_key, address, slug)]` → `{cache_key: GeoPoint | None}`.
Параллелим с потолком `_GEOCODE_CONCURRENCY` упираемся в квоту, а не в нас.
Отказ DaData (сеть/429/401) выглядит как пустая выдача: `suggest_addresses`
гасит исключения сам и возвращает []. Такой адрес получит None и он, как и
честное «дома не знаю», уедет в кэш отрицательным. Отсюда правило прогона:
увидел в итоге всплеск `не разрешён` сначала проверь логи DaData, потом
чисти отрицательные строки кэша (SQL выше), иначе разовый 429 замолчит
адреса до ручной чистки.
"""
sem = asyncio.Semaphore(_GEOCODE_CONCURRENCY)
async def one(key: str, address: str, slug: str | None) -> tuple[str, GeoPoint | None]:
async with sem:
found = await dadata.suggest_addresses(
address, limit=10, regions=_dadata_regions_for(slug)
)
return key, _point_from_suggestions(found)
done = await asyncio.gather(*(one(k, a, sl) for k, a, sl in items))
return dict(done)
def _resolve_points(
db: Session,
rows: list,
budget: GeocodeBudget,
*,
dry_run: bool,
) -> dict[str, GeoPoint | None]:
"""Точки для всех адресов страницы: сначала кэш, остаток — у DaData.
Ключ дедуплицируется в пределах страницы: одна и та же связка слаг+адрес
(несколько квартир в одном доме обычное дело, 50 335 карточек на 21 570
адресов) стоит ОДИН внешний вызов.
"""
wanted: dict[str, tuple[str, str | None]] = {}
for row in rows:
payload = row["payload"] or {}
address = payload.get("address")
if not address or is_artifact_address(address):
continue
slug = avito_city_slug(payload)
wanted.setdefault(geo_cache_key(address, slug), (address, slug))
if not wanted:
return {}
points: dict[str, GeoPoint | None] = {}
# Кэш создаётся только боевым прогоном (`geocode and not dry_run`), поэтому на
# первом `--dry-run` таблицы ещё нет и SELECT по ней роняет весь замер — то
# есть ломается ровно та репетиция, ради которой сухой прогон и существует.
# Проверяем наличие отношения, а не ловим исключение: в Postgres упавший
# оператор кладёт транзакцию целиком, и except потребовал бы rollback.
cache_exists = db.execute(text(_GEO_CACHE_EXISTS)).scalar() is not None
cached = (
db.execute(text(_GEO_CACHE_SELECT), {"keys": list(wanted)}).mappings().all()
if cache_exists
else ()
)
for row in cached:
points[row["cache_key"]] = (
None
if row["lat"] is None
else GeoPoint(
lat=row["lat"],
lon=row["lon"],
region_code=row["region_code"],
full_address=row["full_address"] or "",
)
)
misses = [(key, *wanted[key]) for key in wanted if key not in points]
if not misses:
return points
allowed = budget.take(len(misses))
if allowed == 0:
return points
fresh = asyncio.run(_geocode_many(misses[:allowed]))
points.update(fresh)
if dry_run:
return points # замер не пишет даже кэш — прогон остаётся повторяемым
for key, point in fresh.items():
address, slug = wanted[key]
db.execute(
text(_GEO_CACHE_UPSERT),
{
"key": key,
"slug": slug,
"address": address,
"lat": None if point is None else point.lat,
"lon": None if point is None else point.lon,
"region": None if point is None else point.region_code,
"full": None if point is None else point.full_address,
},
)
return points
@dataclass
class ImportCounters:
"""Разбор прогона. Числа обязаны сходиться, см. `check()`."""
read: int = 0
skipped_artifact: int = 0
skipped_not_moscow: int = 0
skipped_invalid: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область —
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше
# домов».
skipped_oblast: int = 0
skipped_ungeocoded: int = 0
selected: int = 0
inserted: int = 0
updated: int = 0
geocode_calls: int = 0 # фактически потраченных внешних вызовов
geocode_budget_exhausted: bool = False
@property
def written(self) -> int:
return self.inserted + self.updated
@property
def writer_skipped(self) -> int:
"""Отобрано, но писатель строку не тронул.
`save_listings` возвращает только (inserted, updated); неизменные строки,
уже виденные сегодня, он пропускает своим гейтом (#2992). Остаток честно
показываем отдельно, а не растворяем в «записано».
"""
return self.selected - self.written
def check(self) -> bool:
return (
self.read
== self.selected
+ self.skipped_artifact
+ self.skipped_not_moscow
+ self.skipped_oblast
+ self.skipped_ungeocoded
+ self.skipped_invalid
)
def is_artifact_address(address: str | None) -> bool:
"""Адрес чужого города в московском корпусе (артефакт парсера)."""
return bool(address) and _ARTIFACT_RE.search(address) is not None
def is_moscow_address(address: str | None) -> bool:
"""Москва опознаётся префиксом административного округа."""
if not address:
return False
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
def recompute_listing_segment(payload: dict) -> str:
"""Заново считаем сегмент: 'novostroyki' только при продаже застройщиком.
Обоснование в докстринге модуля: метка кита означает лишь ссылку на ЖК.
"""
raw = payload.get("raw_payload") or {}
if not isinstance(raw, dict):
return "vtorichka"
return "novostroyki" if raw.get("is_from_developer") is True else "vtorichka"
def build_lot(payload: dict) -> ScrapedLot:
"""`payload` → `ScrapedLot` с пересчитанным сегментом.
Ключи, которых в модели нет, отбрасываем явно (по `model_fields`), а не
полагаемся на настройку extra у pydantic-модели.
"""
known = {k: v for k, v in payload.items() if k in ScrapedLot.model_fields}
known["listing_segment"] = recompute_listing_segment(payload)
return ScrapedLot(**known)
def _iter_pages(db: Session, view: str, *, batch_size: int, limit: int | None):
"""Keyset-пагинация по `id` — весь корпус в память не тянем."""
after = 0
taken = 0
sql = text(_PAGE_SQL.format(view=view))
while True:
page_size = batch_size
if limit is not None:
page_size = min(batch_size, limit - taken)
if page_size <= 0:
return
rows = db.execute(sql, {"after": after, "limit": page_size}).mappings().all()
if not rows:
return
after = rows[-1]["id"]
taken += len(rows)
yield rows
def import_msk_raw(
db: Session,
*,
source: str = "cian",
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
dry_run: bool = False,
allow_unfiltered: bool = False,
geocode: bool = False,
geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
view = SOURCE_VIEWS[source]
counters = ImportCounters()
matcher = RealMatcherAdapter()
budget = GeocodeBudget(remaining=max(0, geocode_limit))
filter_by_okrug = source == "cian"
if geocode and filter_by_okrug:
# У Циана город опознаётся префиксом округа даром и без ошибок —
# тратить на него внешнюю квоту незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito")
if not filter_by_okrug and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области:
# ни города в адресе, ни координат. Пишем только по явному разрешению.
if not (dry_run or allow_unfiltered):
raise SystemExit(
f"source={source}: адрес не содержит признака города, Москву от "
"области не отличить. Нужен --geocode (штатный путь), "
"--allow-unfiltered (аварийный) или --dry-run."
)
logger.warning(
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; "
"строки лягут без geom и вперемешку с областью",
source,
)
if geocode and not dry_run:
db.execute(text(_GEO_CACHE_DDL))
db.commit()
for rows in _iter_pages(db, view, batch_size=batch_size, limit=limit):
points = _resolve_points(db, rows, budget, dry_run=dry_run) if geocode else {}
lots: list[ScrapedLot] = []
for row in rows:
counters.read += 1
payload = row["payload"] or {}
address = payload.get("address")
if is_artifact_address(address):
counters.skipped_artifact += 1
continue
if filter_by_okrug and not is_moscow_address(address):
counters.skipped_not_moscow += 1
continue
if geocode:
point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
if point is None:
counters.skipped_ungeocoded += 1
continue
if point.region_code != MOSCOW_REGION_CODE:
counters.skipped_oblast += 1
continue
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
# трогаем, пересбор корпуса от этого не зависит. geom появляется
# сразу — карточка идёт в radius-подбор аналогов, не дожидаясь
# ночного `geocode_missing`.
payload = {
**payload,
"lat": point.lat,
"lon": point.lon,
"geo_precision": "house",
}
try:
lots.append(build_lot(payload))
except ValidationError as exc:
counters.skipped_invalid += 1
logger.warning("msk_raw id=%s не собрался в ScrapedLot: %s", row["id"], exc)
counters.selected += len(lots)
if dry_run or not lots:
continue
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=MOSCOW_REGION_CODE,
city=MOSCOW_CITY,
)
counters.inserted += inserted
counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
logger.info(
"msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
source,
counters.read,
counters.selected,
counters.written,
counters.inserted,
counters.updated,
)
counters.geocode_calls = budget.spent
counters.geocode_budget_exhausted = budget.exhausted
if budget.exhausted:
logger.warning(
"msk_raw %s: дневной бюджет геокода (%d) исчерпан — остаток корпуса "
"подхватит следующий прогон, разрешённые адреса уже в кэше",
source,
geocode_limit,
)
logger.info(
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"геокод-вызовов=%d | сходится=%s",
source,
" (dry-run)" if dry_run else "",
counters.read,
counters.selected,
counters.written,
counters.inserted,
counters.updated,
counters.writer_skipped if not dry_run else 0,
counters.skipped_not_moscow,
counters.skipped_oblast,
counters.skipped_ungeocoded,
counters.skipped_artifact,
counters.skipped_invalid,
counters.geocode_calls,
counters.check(),
)
return counters
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)")
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
parser.add_argument(
"--allow-unfiltered",
action="store_true",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom",
)
parser.add_argument(
"--geocode",
action="store_true",
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
"в listings уходит только регион 77, область откладывается",
)
parser.add_argument(
"--geocode-limit",
type=int,
default=DEFAULT_GEOCODE_LIMIT,
help=f"потолок внешних вызовов за прогон (по умолчанию {DEFAULT_GEOCODE_LIMIT}; "
"free tier DaData — 10 000/сутки на аккаунт, его же ест автокомплит формы)",
)
args = parser.parse_args()
db = SessionLocal()
try:
import_msk_raw(
db,
source=args.source,
batch_size=args.batch_size,
limit=args.limit,
dry_run=args.dry_run,
allow_unfiltered=args.allow_unfiltered,
geocode=args.geocode,
geocode_limit=args.geocode_limit,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -54,6 +54,50 @@ dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблиц
т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
имеет смысл после 3 наблюдённых публикаций (ориентир ноябрь 2026).
РЯДОВ ТЕПЕРЬ НЕСКОЛЬКО (#3051). sber_price_index ключуется текстовой колонкой city,
и с #3051 оценщик выбирает ряд по region_code сделки: 66 → «Свердловская область»,
77 «Москва», остальное «Россия» (estimator.SBER_MONITORED_REGIONS). Монитор,
следивший ровно за свердловским рядом, пропустил бы пропажу московского а под
ним 212 937 сделок региона 77. Теперь опрашиваются ВСЕ ряды из того же кортежа.
КОМПРОМИСС, честно. Второго независимого вердикта тут нет и быть не может: stale
считается по такту ЗАГРУЗКИ (sber_index_pull тянет все 3 табло × 3 региона одним
прогоном, errors счётчик по всему прогону), поэтому для всех рядов он ОДИН И ТОТ ЖЕ
по построению. Многорядность ловит другое ПРОПАЖУ ряда. Ранний выход с mark_failed
остался РОВНО за прежним случаем: нет ряда региона по умолчанию (свердловского)
вердикт считать не из чего. Пропажа ЛЮБОГО другого ряда его больше не подавляет:
иначе переименование «Москва» «г. Москва» отключало бы мониторинг Екатеринбурга
(latest_*=0, age_days=0, alert=0 свежесть 66 не считалась вовсе), да ещё и с ложным
текстом «sber_price_index empty», хотя таблица непуста. Теперь пропажа обязательного
ряда 77 свой ERROR с ИМЕНЕМ ряда при done-прогоне; нет фолбэчного «Россия»
WARNING (по нему сегодня не считается ни одна сделка).
ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ (честно, не путать со счётчиком). Канал тревоги в проекте
ровно один и тот же у всех соседей ERROR-запись логгера, которую LoggingIntegration
(event_level=ERROR) превращает в событие GlitchTip; это и проверяется в
tests/test_alerts_become_events.py по ФАКТУ СОБЫТИЯ, а не по levelno. Счётчиков
прогона (scrape_runs.counters) не читает ни одно правило алертинга: единственный их
потребитель, стрик-алерт, смотрит на status прогона, а не на ключи counters. Поэтому
`alert_regions_missing` НАБЛЮДЕНИЕ для ретроспективы по scrape_runs (как
regions_missing и age_days_max), а НЕ канал тревоги; обещание «свой алерт по счётчику»
было неправдой и убрано. Тревога по пропавшему ряду держится на ERROR выше, и именно
это проверяется тестом через тот же харнесс событий, что у соседей.
Расхождение latest-периодов между регионами кладётся в counters
(age_days_max) и в лог как НАБЛЮДЕНИЕ, но алертом не становится: источник вправе
публиковать регионы вразнобой, а частоту таких расхождений мы не мерили заводить
порог без замера значит повторить дефект #2846 (порог внутри рабочего диапазона).
Семантика вердикта и ключи counters свердловского ряда не изменились.
Изоляция чужих рядов доведена до конца: не только пустая выборка, но и ИСКЛЮЧЕНИЕ
на запросе чужого ряда (таймаут, обрыв соединения посреди обхода) больше не уходит
во внешний except с mark_failed каждый чужой ряд опрашивается в своём try, сбой
попадает в лог и в regions_missing. Наружу поднимается только сбой на ряде региона
по умолчанию: вердикт всё равно не из чего считать. Свой try без ОТКАТА эту изоляцию
не давал: ошибка драйвера деактивирует транзакцию Session, и следующий же запрос
(за интервалом загрузки) падает с PendingRollbackError исключение не
распространялось, зато сессия оставалась испорченной, и вердикт по свердловскому ряду
терялся ровно как раньше. Поэтому в per-region except стоит db.rollback().
ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
@ -75,7 +119,12 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION
from app.services.estimator import (
SBER_COEFF_DASHBOARDS,
SBER_MONITORED_REGIONS,
SBER_REQUIRED_REGIONS,
SBER_TIME_ADJUST_REGION,
)
logger = logging.getLogger(__name__)
@ -170,16 +219,19 @@ def evaluate_sber_freshness(
)
def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None:
"""Табло, которое возьмёт оценщик, и его latest период.
def _load_estimator_dashboard(
db: Session, city: str = SBER_TIME_ADJUST_REGION
) -> tuple[str, date] | None:
"""Табло, которое возьмёт оценщик для ряда `city`, и его latest период.
Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
#3051: `city` — имя ряда (sber_price_index.city), дефолт — свердловский, чтобы
вызов без аргумента остался прежним.
"""
for dash in SBER_COEFF_DASHBOARDS:
row = db.execute(
_LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash}
).first()
row = db.execute(_LATEST_PERIOD_SQL, {"city": city, "dash": dash}).first()
latest = row.latest if row is not None else None
if latest is not None:
return dash, latest
@ -230,25 +282,177 @@ def check_sber_freshness(
"pull_lag_days": -1,
"max_pull_lag_days": 0,
"alert": 0,
# #3051: сколько рядов из SBER_MONITORED_REGIONS не нашлось и каков худший
# возраст среди найденных (наблюдение, не критерий тревоги).
"regions_missing": 0,
"age_days_max": 0,
# Обязательный ряд не наблюдался этим прогоном: пропал из таблицы либо запрос
# по нему сорвался. В раннем выходе (нет свердловского) счётчик тоже заполнен.
# НАБЛЮДЕНИЕ, а не канал тревоги (см. «ЧЕМ ИМЕННО ЗДЕСЬ АЛЕРТЯТ» в шапке):
# counters не читает ни одно правило алертинга, тревогу поднимает ERROR-лог.
"alert_regions_missing": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
found = _load_estimator_dashboard(db)
if found is None:
# #3051: опрашиваем ВСЕ ряды, которые способен прочитать оценщик, а не один.
# ДЕДУПЛИКАЦИЯ обязательна: SBER_MONITORED_REGIONS — кортеж ИМЁН рядов, а карта
# оценщика вправе свести два region_code на одно имя (заведём регион, чей ряд
# совпал с фолбэчной «Россией» — кортеж станет длиннее на элемент, а РАЗЛИЧНЫХ
# рядов останется столько же). Считать пропажи по длине кортежа значило бы
# залипнуть на regions_missing=1 навсегда при всех живых рядах.
monitored_regions = tuple(dict.fromkeys(SBER_MONITORED_REGIONS))
found_by_region: dict[str, tuple[str, date]] = {}
probe_failed: list[str] = [] # ряд не удалось СПРОСИТЬ (не то же, что «нет ряда»)
for region in monitored_regions:
# КАЖДЫЙ ЧУЖОЙ РЯД — В СВОЁМ try. Раньше весь обход шёл под общим except:
# таймаут или обрыв соединения на запросе московского ряда улетал наружу,
# давал mark_failed и повторный подъём — и вердикт по свердловскому ряду
# снова не считался, хотя сам ряд на месте. Это тот же дефект, что чинили
# ранним выходом по ПУСТОЙ выборке, только по ветке ИСКЛЮЧЕНИЯ.
try:
got = _load_estimator_dashboard(db, region)
except Exception:
if region == SBER_TIME_ADJUST_REGION:
# Ряд региона по умолчанию — единственный источник вердикта:
# его сбой подавлять нечего и незачем, отдаём во внешний except
# (там и откат, и mark_failed).
raise
# ОТКАТ, А НЕ ПРОСТО continue. Прошлый круг изолировал РАСПРОСТРАНЕНИЕ
# исключения, но не ПОРЧУ СЕССИИ — это разные вещи, и второго мало.
# Настоящая ошибка драйвера (таймаут инструкции, обрыв соединения)
# ДЕАКТИВИРУЕТ транзакцию Session: следующий запрос падает с
# PendingRollbackError, даже не дойдя до БД. Без отката изоляция была
# мнимой — цикл шёл дальше, но первый же запрос ЗА ИНТЕРВАЛОМ ЗАГРУЗКИ
# падал на испорченной сессии, улетал во внешний except, давал
# mark_failed и подъём: вердикт по свердловскому ряду опять не доезжал.
# Пустая выборка этого не воспроизводит вовсе — тест обязан имитировать
# именно ошибку драйвера (tests/test_sber_region_series_3051.py).
try:
db.rollback()
except Exception:
# Откат не прошёл — мертво соединение целиком, а не один запрос:
# вердикт всё равно считать не из чего, отдаём наружу.
logger.exception(
"sber freshness: откат сессии после сбоя на ряде %s не прошёл — "
"соединение непригодно, вердикт по %s не считаем",
region,
SBER_TIME_ADJUST_REGION,
)
raise
probe_failed.append(region)
logger.exception(
"sber freshness: запрос ряда СберИндекса %s сорвался — сессия "
"откачена, ряд помечен ненаблюдённым, вердикт по %s считаем дальше",
region,
SBER_TIME_ADJUST_REGION,
)
continue
if got is not None:
found_by_region[region] = got
counters["regions_missing"] = len(monitored_regions) - len(found_by_region)
# Обязательный ряд может быть не наблюдён по двум разным причинам: его нет в
# таблице (переименование в источнике) или запрос по нему сорвался. Причину
# разделяет ЛОГ; в counters она не ветвится — новых ключей не заводим, а для
# тревоги оба случая равнозначны: обязательного ряда за этот прогон нет.
missing_required = [
r for r in SBER_REQUIRED_REGIONS if r not in found_by_region and r not in probe_failed
]
# НЕ то же самое, что missing_required: сюда попадает и ряд, который не удалось
# СПРОСИТЬ. Для тревоги эти случаи равнозначны («обязательного ряда за этот
# прогон нет»), поэтому счётчик считается по required_unseen, а не по
# missing_required; причину разделяет лог (ERROR ниже vs logger.exception в цикле).
required_unseen = [r for r in SBER_REQUIRED_REGIONS if r not in found_by_region]
# ДО РАННЕГО ВЫХОДА, а не после. Раньше этот блок стоял ниже возврата, то есть
# в ветке раннего выхода не выполнялся НИКОГДА: при одновременной пропаже
# свердловского и московского рядов в мониторинг уходило сообщение только про
# свердловский, хотя под московским 212 937 сделок региона 77 и это отдельный
# дефект с отдельной починкой. Теперь про КАЖДЫЙ пропавший обязательный ряд
# сообщение уходит всегда, а ранний выход остаётся только вопросом вердикта.
#
# Почему ERROR при done-прогоне, а не mark_failed (когда свердловский на месте):
# (а) вердикт по 66 уже посчитан и обязан доехать до дашборда, а counters
# упавшего прогона там не читаются — ровно эта подмена и превращала
# пропажу Москвы в отключение мониторинга Екатеринбурга;
# (б) mark_failed виден только стрик-алерту, т.е. на третьи сутки, а ERROR
# уходит в GlitchTip тем же прогоном (#2674);
# (в) отдельный ключ counters не перегружает `alert`, который значит «загрузка
# отстала»: это другой дефект, чинится в другом месте (имя ряда).
# Ряд региона ПО УМОЛЧАНИЮ разбирается отдельной веткой ниже (ранний выход),
# и у неё свой ERROR. Без этого условия одновременная пропажа обоих рядов
# давала ДВА события об одном факте — лишняя issue в GlitchTip, не сигнал.
default_found = found_by_region.get(SBER_TIME_ADJUST_REGION)
if missing_required and default_found is not None:
logger.error(
"sber freshness: пропал обязательный ряд СберИндекса %s%s. "
"По этим регионам есть сделки, а time-поправку взять неоткуда: "
"проверь имя ряда в источнике (переименование city) и карту "
"estimator._SBER_REGION_SERIES",
missing_required,
# «Таблица НЕ пуста» — утверждение о факте, поэтому только когда хоть
# один ряд действительно прочитан: прежний безусловный текст врал.
f"таблица НЕ пуста, остальные ряды на месте ({sorted(found_by_region)})"
if found_by_region
else "ни одного ряда прочитать не удалось",
)
missing_optional = [
r
for r in monitored_regions
if r not in found_by_region and r not in SBER_REQUIRED_REGIONS and r not in probe_failed
]
if missing_optional:
# WARNING (не ERROR): это фолбэчный ряд для региона вне карты оценщика —
# сегодня по нему не считается ни одна сделка, ронять монитор незачем.
logger.warning(
"sber freshness: нет фолбэчной серии %s — регион вне "
"estimator._SBER_REGION_SERIES останется без time-поправки",
missing_optional,
)
# Ранний выход — ТОЛЬКО ради того случая, ради которого он и заводился:
# у оценщика нет серии по региону ПО УМОЛЧАНИЮ, считать вердикт не из чего.
# Пропажа любого другого ряда его больше не подавляет (см. шапку).
if default_found is None:
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
# Единственное событие этой ветки: называет и ряд по умолчанию, и все
# прочие пропавшие обязательные ряды — блок missing_required выше
# здесь намеренно молчит, чтобы не дублировать issue.
logger.error(
"sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
"для region=%s (вторичка); оценить нечего",
"для region=%s (вторичка); оценить нечего. Пропавшие обязательные "
"ряды целиком: %s",
list(SBER_COEFF_DASHBOARDS),
SBER_TIME_ADJUST_REGION,
missing_required or [SBER_TIME_ADJUST_REGION],
)
# Новые счётчики заполняем и ЗДЕСЬ. Нули по ним делали ранний выход слепым:
# одновременная пропажа свердловского и московского рядов выглядела ровно
# как пропажа одного свердловского (alert_regions_missing=0, age_days_max=0),
# хотя второй дефект — отдельный и по нему 212 937 сделок региона 77.
counters["alert_regions_missing"] = int(bool(required_unseen))
counters["age_days_max"] = max(
((now.date() - d).days for _, d in found_by_region.values()), default=0
)
runs_mod.mark_failed(
db,
run_id,
# Текст называет КОНКРЕТНЫЙ ряд: «таблица пуста» было ложью — в ней
# могут лежать все остальные регионы.
f"sber_price_index: нет серии '{SBER_TIME_ADJUST_REGION}' "
f"в табло {list(SBER_COEFF_DASHBOARDS)}",
counters,
)
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
return counters
dashboard, latest = found
# Вердикт — по свердловскому ряду, как и до #3051 (см. КОМПРОМИСС в шапке:
# такт загрузки общий для всех рядов, второго независимого вердикта нет).
dashboard, latest = default_found
last_pull_row = db.execute(
_LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
).first()
@ -267,8 +471,23 @@ def check_sber_freshness(
"pull_lag_days": verdict.pull_lag_days,
"max_pull_lag_days": verdict.max_pull_lag_days,
"alert": int(verdict.stale),
"regions_missing": counters["regions_missing"],
"age_days_max": max((now.date() - d).days for _, d in found_by_region.values()),
"alert_regions_missing": int(bool(required_unseen)),
}
# #3051: наблюдение по всем рядам — расхождение latest между регионами видно
# в логе, но алертом не становится (порог без замера = дефект #2846).
logger.info(
"sber freshness: ряды оценщика — %s",
"; ".join(
f"{r}: {found_by_region[r][1]} ({found_by_region[r][0]})"
if r in found_by_region
else (f"{r}: СБОЙ ЗАПРОСА" if r in probe_failed else f"{r}: НЕТ")
for r in monitored_regions
),
)
if verdict.stale:
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
logger.error(

View file

@ -0,0 +1,171 @@
-- 299_msk_raw_cian_domclick_yandex_cards.sql
--
-- Контекст: msk_raw.avito_cards уже хранит сырые SERP-карточки Авито (Москва+МО,
-- вторичка), заливаемые ручным локальным сборщиком scripts/local-avito-msk/collect.py.
-- Эта миграция заводит зеркальные raw-таблицы под ЦИАН, Домклик и Яндекс.Недвижимость
-- по той же структуре и с тем же набором индексов (префикс площадки вместо avito_),
-- под будущие аналогичные локальные сборщики для этих площадок.
--
-- batch_id у всех трёх таблиц ссылается на существующую msk_raw.batches(batch_id)
-- (как и у avito_cards) — сборщик обязан сначала вставить строку в batches,
-- потом заливать карточки в рамках этого batch_id.
--
-- Тип source_id решён отдельно на каждую площадку по факту из парсеров
-- (packages/scraper-kit/src/scraper_kit/providers/{cian,domclick,yandex}/serp.py):
-- * cian -> bigint: source_id = str(offer.get("cianId") or offer.get("id")),
-- cianId у ЦИАН всегда числовой, используется как есть в URL
-- (/sale/flat/<cianId>/).
-- * domclick -> bigint: source_id = str(item.get("id")), id у Домклика — числовой
-- offer id (как в URL карточки), аналогично Авито/ЦИАН.
-- * yandex -> text: source_id = offer_id = str(entity.get("offerId") or ""),
-- в парсере есть отдельный _to_bigint() для price-полей, но offerId
-- сознательно НЕ приводится через него и остаётся строкой — числовой
-- формат offerId у Яндекс.Недвижимости не гарантирован.
--
-- id — именно bigserial, а НЕ 'GENERATED ALWAYS AS IDENTITY'. Сборщик заливает
-- карточки через CREATE TEMP TABLE _stg (LIKE msk_raw.<table> INCLUDING DEFAULTS INCLUDING IDENTITY)
-- и \copy без колонки id: LIKE ... INCLUDING DEFAULTS переносит в _stg обычный
-- DEFAULT (nextval у bigserial), но НЕ переносит identity-свойство, а NOT NULL
-- переносится всегда — с identity каждый батч падал бы на not-null violation по id.
-- Прод-таблица msk_raw.avito_cards заведена так же:
-- id bigint NOT NULL DEFAULT nextval('msk_raw.avito_cards_id_seq'::regclass).
--
-- Идемпотентно: повторный запуск безопасен (IF NOT EXISTS везде).
BEGIN;
SET LOCAL lock_timeout = '5s';
-- Схема и две первые таблицы заводились на проде РУКАМИ 08.09, мимо линейки
-- миграций: сбор сырья стартовал раньше, чем модель данных, и msk_raw сознательно
-- жила вне приложения (её никто в проде не читает, переезжает одним
-- `pg_dump -n msk_raw`). Из-за этого миграция роняла CI на чистой базе —
-- «schema "msk_raw" does not exist», а до тестов дело не доходило вовсе.
--
-- Поэтому DDL продовских объектов повторён здесь идемпотентно: на проде это
-- no-op (всё уже есть), на чистой базе — единственное место, где msk_raw
-- появляется. Определения сняты с прода `pg_dump -s -n msk_raw`, чтобы CI и
-- прод не разъехались молча; `batches` нужна и по существу — на неё ссылается
-- внешний ключ всех трёх таблиц ниже.
CREATE SCHEMA IF NOT EXISTS msk_raw;
CREATE TABLE IF NOT EXISTS msk_raw.batches (
batch_id text PRIMARY KEY,
kind text NOT NULL DEFAULT 'serp',
query text,
started_at timestamptz,
finished_at timestamptz,
rows_sent integer,
rows_new integer,
notes text,
uploaded_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS msk_raw.avito_cards (
id bigserial PRIMARY KEY,
source_id bigint NOT NULL,
observed_at timestamptz NOT NULL,
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
kind text NOT NULL DEFAULT 'serp',
url text,
price numeric,
payload jsonb NOT NULL,
UNIQUE (source_id, batch_id, kind)
);
CREATE INDEX IF NOT EXISTS avito_cards_observed_idx
ON msk_raw.avito_cards (observed_at);
CREATE INDEX IF NOT EXISTS avito_cards_payload_gin
ON msk_raw.avito_cards USING gin (payload jsonb_path_ops);
CREATE INDEX IF NOT EXISTS avito_cards_source_observed_idx
ON msk_raw.avito_cards (source_id, observed_at DESC);
CREATE OR REPLACE VIEW msk_raw.avito_latest AS
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
FROM msk_raw.avito_cards ORDER BY source_id, observed_at DESC;
CREATE TABLE IF NOT EXISTS msk_raw.cian_cards (
id bigserial PRIMARY KEY,
source_id bigint NOT NULL,
observed_at timestamptz NOT NULL,
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
kind text NOT NULL DEFAULT 'serp',
url text,
price numeric,
payload jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS cian_cards_observed_at_idx
ON msk_raw.cian_cards (observed_at);
CREATE INDEX IF NOT EXISTS cian_cards_payload_gin_idx
ON msk_raw.cian_cards USING gin (payload jsonb_path_ops);
CREATE UNIQUE INDEX IF NOT EXISTS cian_cards_source_id_batch_id_kind_uidx
ON msk_raw.cian_cards (source_id, batch_id, kind);
CREATE INDEX IF NOT EXISTS cian_cards_source_id_observed_at_idx
ON msk_raw.cian_cards (source_id, observed_at DESC);
CREATE TABLE IF NOT EXISTS msk_raw.domclick_cards (
id bigserial PRIMARY KEY,
source_id bigint NOT NULL,
observed_at timestamptz NOT NULL,
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
kind text NOT NULL DEFAULT 'serp',
url text,
price numeric,
payload jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS domclick_cards_observed_at_idx
ON msk_raw.domclick_cards (observed_at);
CREATE INDEX IF NOT EXISTS domclick_cards_payload_gin_idx
ON msk_raw.domclick_cards USING gin (payload jsonb_path_ops);
CREATE UNIQUE INDEX IF NOT EXISTS domclick_cards_source_id_batch_id_kind_uidx
ON msk_raw.domclick_cards (source_id, batch_id, kind);
CREATE INDEX IF NOT EXISTS domclick_cards_source_id_observed_at_idx
ON msk_raw.domclick_cards (source_id, observed_at DESC);
CREATE TABLE IF NOT EXISTS msk_raw.yandex_cards (
id bigserial PRIMARY KEY,
source_id text NOT NULL,
observed_at timestamptz NOT NULL,
batch_id text NOT NULL REFERENCES msk_raw.batches(batch_id),
kind text NOT NULL DEFAULT 'serp',
url text,
price numeric,
payload jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS yandex_cards_observed_at_idx
ON msk_raw.yandex_cards (observed_at);
CREATE INDEX IF NOT EXISTS yandex_cards_payload_gin_idx
ON msk_raw.yandex_cards USING gin (payload jsonb_path_ops);
CREATE UNIQUE INDEX IF NOT EXISTS yandex_cards_source_id_batch_id_kind_uidx
ON msk_raw.yandex_cards (source_id, batch_id, kind);
CREATE INDEX IF NOT EXISTS yandex_cards_source_id_observed_at_idx
ON msk_raw.yandex_cards (source_id, observed_at DESC);
-- Вью «последнее наблюдение по объявлению» — по образцу существующей
-- msk_raw.avito_latest (тот же DISTINCT ON и тот же порядок колонок).
CREATE OR REPLACE VIEW msk_raw.cian_latest AS
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
FROM msk_raw.cian_cards ORDER BY source_id, observed_at DESC;
CREATE OR REPLACE VIEW msk_raw.domclick_latest AS
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
FROM msk_raw.domclick_cards ORDER BY source_id, observed_at DESC;
CREATE OR REPLACE VIEW msk_raw.yandex_latest AS
SELECT DISTINCT ON (source_id) id, source_id, observed_at, batch_id, kind, url, price, payload
FROM msk_raw.yandex_cards ORDER BY source_id, observed_at DESC;
COMMIT;

View file

@ -0,0 +1,132 @@
"""#3051: скоуп региона в estimate — fast-path и вызов geocode().
ПОЧЕМУ ЭТО ТЕСТ. Оба тира geocode() ограничены регионом ЖЁСТКИМ фильтром
(DaData `locations.region`, Nominatim `viewbox`+`bounded=1`), а не бустом:
промах региона не даёт ошибки выдача схлопывается в пустую, и estimate
возвращает `_empty_estimate(reason='address_not_geocoded')`, неотличимую от
«такого адреса нет». Значит проверять надо не результат, а ЧТО именно уходит
в geocode(). Второй предмет регресс-нейтральность 66: без московских данных
поведение обязано быть прежним.
Сеть и БД не дёргаем: geocode и _empty_estimate мокаются, db MagicMock.
"""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import _request_region_code, estimate_quality
pytestmark = pytest.mark.anyio
# Тверская 6 (Москва) и Малышева 30 (Екатеринбург) — точки внутри bbox_region
# соответствующих регионов реестра.
MSK = (55.7605, 37.6100)
EKB = (56.8380, 60.6000)
def _payload(**kw) -> TradeInEstimateInput:
base = {"address": "Тверская 6", "area_m2": 50.0, "rooms": 2}
base.update(kw)
return TradeInEstimateInput(**base)
# ── _request_region_code: приоритет координаты → city_hint → 66 ──────────────
def test_region_from_moscow_coords() -> None:
assert _request_region_code(_payload(lat=MSK[0], lon=MSK[1])) == 77
def test_region_from_ekb_coords() -> None:
assert _request_region_code(_payload(lat=EKB[0], lon=EKB[1])) == 66
def test_region_without_coords_defaults_to_66() -> None:
"""Нет координат и нет узнаваемого города — прежний дефолт 66."""
assert _request_region_code(_payload()) == 66
assert _request_region_code(_payload(city_hint="Урюпинск")) == 66
def test_region_from_city_hint() -> None:
assert _request_region_code(_payload(city_hint="Москва")) == 77
assert _request_region_code(_payload(city_hint="Нижний Тагил")) == 66
def test_region_coords_outside_any_region_default_66() -> None:
"""Точка вне охвата (Сочи) → 66, а НЕ None: NULL обнулил бы фильтр."""
assert _request_region_code(_payload(lat=43.6, lon=39.7)) == 66
# ── fast-path клиентских координат: региононезависимость ─────────────────────
async def _run_estimate(payload: TradeInEstimateInput):
"""estimate_quality до первой развилки: geocode → None → _empty_estimate.
Если fast-path принял клиентские координаты, функция идёт дальше, в счёт по
БД, и спотыкается о MagicMock-сессию это ожидаемо и подавляется: предмет
проверки здесь ровно один, БЫЛ ли вызван geocode() и с каким регионом.
"""
geocode_mock = AsyncMock(return_value=None)
empty_mock = MagicMock(return_value="EMPTY")
result = None
with (
patch("app.services.estimator.geocode", new=geocode_mock),
patch("app.services.estimator._empty_estimate", new=empty_mock),
):
try:
result = await estimate_quality(payload, MagicMock())
except Exception: # дальше по функции живая БД, см. докстринг
pass
return geocode_mock, empty_mock, result
async def test_fast_path_accepts_moscow_coords() -> None:
"""Московские координаты принимаются как клиентские — geocode не зовём."""
geocode_mock, _, _ = await _run_estimate(_payload(lat=MSK[0], lon=MSK[1]))
geocode_mock.assert_not_awaited()
async def test_fast_path_accepts_ekb_coords_unchanged() -> None:
"""Регресс 66: координаты области по-прежнему минуют geocode()."""
geocode_mock, _, _ = await _run_estimate(_payload(lat=EKB[0], lon=EKB[1]))
geocode_mock.assert_not_awaited()
async def test_fast_path_ignores_coords_outside_coverage() -> None:
"""Точка вне охвата (Сочи) — как и раньше, честный geocode()."""
geocode_mock, _, _ = await _run_estimate(_payload(lat=43.6, lon=39.7))
geocode_mock.assert_awaited_once()
# ── geocode() получает регион запроса ────────────────────────────────────────
async def test_geocode_gets_region_77_for_moscow() -> None:
geocode_mock, empty_mock, result = await _run_estimate(
_payload(address="Тверская 6", city_hint="Москва")
)
assert geocode_mock.await_args.kwargs["region_code"] == 77
assert result == "EMPTY"
assert empty_mock.call_args.kwargs["reason"] == "address_not_geocoded"
async def test_geocode_gets_region_66_for_ekb() -> None:
geocode_mock, _, _ = await _run_estimate(
_payload(address="Малышева 30", city_hint="Екатеринбург")
)
assert geocode_mock.await_args.kwargs["region_code"] == 66
async def test_geocode_default_region_66_without_hints() -> None:
"""Регресс-нейтральность: без координат и city_hint — прежний скоуп 66."""
geocode_mock, _, _ = await _run_estimate(_payload(address="Малышева 30"))
assert geocode_mock.await_args.kwargs["region_code"] == 66
assert geocode_mock.await_args.kwargs["city_hint"] is None

View file

@ -0,0 +1,483 @@
"""#3051 — полосы цен по округам Москвы + региональный потолок ppm².
Что проверяется:
1. Ключ города сделки (deal_city_key / deal_city_key_sql) один на derivation
полос и на все читающие места estimator'а.
2. Регион 66 не двигается: src_city у его сделок пуст ключ равен city.
3. Новый потолок не срезает дорогой московский округ, старый глобальный
срезал бы.
4. Полосы региона 66 по новой формуле потолка совпадают со старым литералом.
Живого Postgres нет SQL-инварианты проверяются по тексту запроса, поведение
фильтра на подменённой Session (паттерн из tests/test_dkp_corridor_as_of_2846.py).
Авторитетная проверка инварианта региона 66 сделана на проде 2026-09-10 прогоном
новой derivation в режиме SELECT: 383 строки против 383 текущих, все совпали по
(ppm2_min, ppm2_max, n_deals, tier); сделок региона 66, где ключ != city, 0.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator as est
from app.services.deal_city_key import (
DEAL_CITY_KEY_COLUMN,
deal_city_band_bounds_sql,
deal_city_band_join_sql,
deal_city_key,
deal_city_key_sql,
resolve_city_band,
)
from app.tasks.deal_city_price_bands_refresh import (
_REDERIVE_SQL,
REGION_CEILING_CAP_Q,
REGION_CEILING_FLOOR,
REGION_CEILING_MEDIAN_MULT,
REGION_CEILING_MEDIAN_Q,
region_ppm2_max,
)
# Замеры прода 2026-09-10, на которых калибровался потолок.
_R66_P50 = 52_706
_R66_P9999 = 615_312
_R77_P50 = 294_457
_R77_P9999 = 1_944_535
_R77_CEILING = 1_766_742 # ПИН прода: множитель * p50 региона 77 при текущих константах
_MAX_OKRUG_P99 = 1_405_882 # самый дорогой округ Москвы
def _sql_code(sql: str) -> str:
"""Текст запроса без строк-комментариев — числа из комментариев не считаем."""
return "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--"))
# ── 1. Ключ города ───────────────────────────────────────────────────────────
def test_key_region66_equals_city() -> None:
"""Свердловская область: src_city пуст у всех сделок → ключ равен city."""
assert deal_city_key({"city": "Асбест", "raw_payload": {}}) == "Асбест"
assert deal_city_key({"city": "Асбест", "raw_payload": None}) == "Асбест"
assert deal_city_key({"city": "Асбест"}) == "Асбест"
# NULLIF(x, '') в SQL: пустая строка — то же, что отсутствие значения.
assert deal_city_key({"city": "Асбест", "raw_payload": {"src_city": ""}}) == "Асбест"
def test_key_moscow_with_src_city_is_okrug() -> None:
row = {"city": "Москва", "raw_payload": {"src_city": "муниципальный округ Хамовники"}}
assert deal_city_key(row) == "муниципальный округ Хамовники"
def test_key_moscow_without_src_city_is_moscow() -> None:
"""6.73% московских сделок без src_city → собственная строка-фолбэк 'Москва'."""
assert deal_city_key({"city": "Москва", "raw_payload": {"src_city": None}}) == "Москва"
assert deal_city_key({"city": "Москва", "raw_payload": {}}) == "Москва"
def test_key_column_wins_over_raw_payload() -> None:
"""Готовую колонку city_key (её считает SQL) не переопределяем питоном."""
row = {
DEAL_CITY_KEY_COLUMN: "муниципальный округ Метрогородок",
"city": "Москва",
"raw_payload": {"src_city": "муниципальный округ Хамовники"},
}
assert deal_city_key(row) == "муниципальный округ Метрогородок"
def test_key_sql_shape() -> None:
assert deal_city_key_sql("d") == "COALESCE(NULLIF(d.raw_payload->>'src_city', ''), d.city)"
assert deal_city_key_sql("") == "COALESCE(NULLIF(raw_payload->>'src_city', ''), city)"
# ── 2. SQL-инварианты derivation и читающих запросов ─────────────────────────
def test_rederive_sql_groups_by_key_and_has_regional_ceiling() -> None:
sql = str(_REDERIVE_SQL)
key = deal_city_key_sql("")
assert f"GROUP BY region_code, {key}" in sql
assert f"{key} AS city" in sql
# Потолок стал региональным: литерала-константы 800000 в ветках tiered больше
# нет, он остался только якорем внутри GREATEST в region_stats.
assert "region_ppm2_max" in sql
assert f"percentile_cont({REGION_CEILING_CAP_Q})" in sql
code = _sql_code(sql)
# Якорь GREATEST в region_stats — единственное место с этим числом.
assert code.count(str(REGION_CEILING_FLOOR)) == 1
assert "LEAST(c.ppm2_p99, r.region_ppm2_max)" in sql
# p99 города больше не клампится литералом на этапе city_stats.
assert "LEAST(round(percentile_cont(0.99)" not in sql
def test_estimator_reads_bands_by_same_key() -> None:
"""Все три читающих места используют ТО ЖЕ выражение ключа, что derivation."""
src = open(est.__file__, encoding="utf-8").read()
# Оба SQL-джойна ДКП-коридора (street и city-wide widen) подставляют ОДНУ
# константу, а не свою копию выражения; сама константа — из deal_city_key.
assert src.count("{_DEAL_CITY_BAND_JOIN_D}") == 2
assert src.count("{_DEAL_CITY_BAND_BOUNDS}") == 2
assert est._DEAL_CITY_BAND_JOIN_D == deal_city_band_join_sql("d", indent=" " * 20)
assert est._DEAL_CITY_BAND_BOUNDS == deal_city_band_bounds_sql(indent=" " * 22)
assert est._DEAL_CITY_KEY_SQL_PLAIN == deal_city_key_sql("")
# Питоновский путь: ключ считает SQL в SELECT сделок.
assert "{_DEAL_CITY_KEY_SQL_PLAIN} AS {DEAL_CITY_KEY_COLUMN}" in src
# Одноступенчатых джойна и границ (только b) не осталось.
assert "b.city = d.city" not in src
assert "COALESCE(b.ppm2_min, CAST(" not in src
# ── 3. Потолок ───────────────────────────────────────────────────────────────
def test_ceiling_formula_is_single_source() -> None:
"""СТОРОЖ: SQL-выражение потолка собрано из ТЕХ ЖЕ констант, что region_ppm2_max().
Раньше формула жила двумя копиями (SQL + локальная копия в этом файле), и
подмена множителя 6 на 3 оставляла все тесты зелёными, роняя потолок Москвы
с 1766742 до 883371. Теперь каждая константа обязана встретиться в собранном
SQL ровно столько раз, сколько её кладёт сборка, а питоновская сторона та
же функция region_ppm2_max из модуля derivation, а не копия.
"""
code = _sql_code(str(_REDERIVE_SQL))
assert code.count(str(REGION_CEILING_FLOOR)) == 1 # якорь GREATEST
assert code.count(f"percentile_cont({REGION_CEILING_CAP_Q})") == 1 # шапка p99.99
median_sql = f"{REGION_CEILING_MEDIAN_MULT} * round(percentile_cont({REGION_CEILING_MEDIAN_Q})"
assert code.count(median_sql) == 1 # множитель медианы
# Обе ветви питоновской функции наблюдаемы и построены из тех же чисел.
assert region_ppm2_max(10**9, 10**6) == REGION_CEILING_MEDIAN_MULT * 10**6
assert region_ppm2_max(0, 0) == REGION_CEILING_FLOOR
def test_ceiling_region66_unchanged() -> None:
"""Регион 66: формула вырождается ровно в прежний литерал 800000."""
assert region_ppm2_max(_R66_P9999, _R66_P50) == 800_000
# Запас: чтобы потолок сдвинулся, нужны ОДНОВРЕМЕННО медиана > 133 333 и
# p99.99 > 800 000 (сейчас 52 706 и 615 312).
assert REGION_CEILING_MEDIAN_MULT * _R66_P50 < REGION_CEILING_FLOOR
assert _R66_P9999 < REGION_CEILING_FLOOR
def test_ceiling_region77_lifts_and_is_guarded() -> None:
assert region_ppm2_max(_R77_P9999, _R77_P50) == _R77_CEILING
# Шапка p99.99 не даёт множителю медианы разогнать потолок бесконечно.
assert region_ppm2_max(900_000, _R77_P50) == 900_000
# Якорь 800000 не даёт потолку упасть ниже исторического даже при обвале цен.
assert region_ppm2_max(100_000, 10_000) == REGION_CEILING_FLOOR
def test_new_ceiling_does_not_cut_expensive_okrug() -> None:
"""Самый дорогой округ (p99=1 405 882) целиком помещается под потолок 77."""
assert _MAX_OKRUG_P99 < region_ppm2_max(_R77_P9999, _R77_P50)
assert _MAX_OKRUG_P99 > 800_000 # старый литерал резал бы его
def test_plausible_deal_uses_okrug_band() -> None:
bands = {
(77, "муниципальный округ Хамовники"): (100_000, _MAX_OKRUG_P99),
(77, "Москва"): (22_475, 772_165),
(66, "Асбест"): (8_000, 254_831),
}
# Дорогой округ: 1.3 М ₽/м² — легитимный рынок, проходит.
assert est._is_plausible_deal(
1_300_000,
5,
12,
city="муниципальный округ Хамовники",
city_fallback="Москва",
bands=bands,
region_code=77,
)
# Тот же ppm² под глобальным потолком (DEAL_MAX_PPM2=800000) был бы отброшен.
assert not est._is_plausible_deal(
1_300_000, 5, 12, city="Москва", city_fallback="Москва", bands={}, region_code=77
)
# Строка-фолбэк 'Москва' — осмысленная полоса, а не ЕКБ-константы:
# 30 000 ₽/м² ниже DEAL_MIN_PPM2=50000, но внутри московского фолбэка.
assert est._is_plausible_deal(
30_000, 5, 12, city="Москва", city_fallback="Москва", bands=bands, region_code=77
)
assert not est._is_plausible_deal(
30_000, 5, 12, city="Москва", city_fallback="Москва", bands={}, region_code=77
)
# Регион 66 — прежнее поведение.
assert est._is_plausible_deal(
41_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=bands, region_code=66
)
assert not est._is_plausible_deal(
300_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=bands, region_code=66
)
# Екатеринбург намеренно не в таблице → глобальные константы.
assert est._is_plausible_deal(
120_000,
3,
5,
city="Екатеринбург",
city_fallback="Екатеринбург",
bands=bands,
region_code=66,
)
# ── 4. _fetch_deals: ключ доезжает до фильтра и не течёт в ответ ─────────────
def _deal_row(**over: Any) -> dict[str, Any]:
row = {
"source": "rosreestr",
"address": "Москва, ул. Остоженка, 1",
"lat": 55.74,
"lon": 37.6,
"rooms": 2,
"area_m2": 80.0,
"floor": 5,
"total_floors": 12,
"price_rub": 104_000_000.0,
"price_per_m2": 1_300_000.0,
"city": "Москва",
"region_code": 77,
DEAL_CITY_KEY_COLUMN: "муниципальный округ Хамовники",
"deal_date": None,
"days_on_market": None,
"cadastral_number": None,
"distance_m": 100.0,
}
row.update(over)
return row
def _db(deal_rows: list[dict[str, Any]], band_rows: list[dict[str, Any]]) -> Any:
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
result = MagicMock()
sql = str(query)
if "FROM deal_city_price_bands" in sql:
result.mappings.return_value.all.return_value = band_rows
else:
result.mappings.return_value.all.return_value = deal_rows
return result
db = MagicMock()
db.execute.side_effect = _execute
return db
_BANDS_ROWS = [
{
"region_code": 77,
"city": "муниципальный округ Хамовники",
"ppm2_min": 100_000,
"ppm2_max": _MAX_OKRUG_P99,
},
{"region_code": 77, "city": "Москва", "ppm2_min": 22_475, "ppm2_max": 772_165},
{"region_code": 66, "city": "Асбест", "ppm2_min": 8_000, "ppm2_max": 254_831},
]
def _fetch(
rows: list[dict[str, Any]], band_rows: list[dict[str, Any]] | None = None
) -> list[dict[str, Any]]:
est._city_price_bands_cache = None
try:
return est._fetch_deals(
_db(rows, _BANDS_ROWS if band_rows is None else band_rows),
lat=55.74,
lon=37.6,
rooms=2,
area=80.0,
radius_m=1000,
)
finally:
est._city_price_bands_cache = None
def test_fetch_deals_keeps_expensive_okrug_and_hides_key_column() -> None:
out = _fetch([_deal_row()])
assert len(out) == 1
# Служебная колонка не должна утечь в actual_deals ответа API.
assert DEAL_CITY_KEY_COLUMN not in out[0]
assert out[0]["city"] == "Москва"
def test_fetch_deals_drops_same_price_under_moscow_fallback_band() -> None:
"""Та же сделка без src_city ключуется как 'Москва' → 1.3 М ₽/м² вне полосы."""
assert _fetch([_deal_row(**{DEAL_CITY_KEY_COLUMN: "Москва"})]) == []
def test_fetch_deals_region66_unaffected() -> None:
row = _deal_row(
city="Асбест",
region_code=66,
price_per_m2=41_000.0,
price_rub=3_280_000.0,
**{DEAL_CITY_KEY_COLUMN: "Асбест"},
)
out = _fetch([row])
assert len(out) == 1
assert DEAL_CITY_KEY_COLUMN not in out[0]
# ── 5. Двухступенчатый поиск полосы (#3051, окно между деплоем и рефрешем) ────
def test_band_lookup_order_okrug_then_city_then_globals() -> None:
"""Ступени: полоса округа → полоса города сделки → глобальные ЕКБ-константы."""
default = (est.DEAL_MIN_PPM2, est.DEAL_MAX_PPM2)
bands = {
(77, "муниципальный округ Хамовники"): (100_000, _MAX_OKRUG_P99),
(77, "Москва"): (22_475, 772_165),
}
okrug = "муниципальный округ Хамовники"
assert resolve_city_band(bands, 77, okrug, "Москва", default) == (100_000, _MAX_OKRUG_P99)
# Строки округа ещё нет (ночной рефреш не прогонялся) → городская полоса.
assert resolve_city_band(bands, 77, "муниципальный округ Некрасовка", "Москва", default) == (
22_475,
772_165,
)
# Нет ни округа, ни города — только тогда глобальные константы.
assert resolve_city_band(bands, 77, "округ Некрасовка", "Тверь", default) is default
assert resolve_city_band({}, 77, okrug, "Москва", default) is default
assert resolve_city_band(None, 77, okrug, "Москва", default) is default
# Регион 66: ключ ступени 1 равен ключу ступени 2 → один и тот же результат.
r66 = {(66, "Асбест"): (8_000, 254_831)}
assert resolve_city_band(r66, 66, "Асбест", "Асбест", default) == (8_000, 254_831)
assert resolve_city_band(r66, 66, "Екатеринбург", "Екатеринбург", default) is default
def test_sql_band_join_and_bounds_are_two_step() -> None:
"""В SQL двухступенчатость — второй LEFT JOIN и трёхаргументный COALESCE."""
join = est._DEAL_CITY_BAND_JOIN_D
assert join.count("LEFT JOIN deal_city_price_bands") == 2
assert "AND b.city = " + deal_city_key_sql("d") in join # ступень 1 — округ
assert "AND bc.city = d.city" in join # ступень 2 — город сделки
bounds = est._DEAL_CITY_BAND_BOUNDS
assert "COALESCE(b.ppm2_min, bc.ppm2_min, CAST(:ppm_min AS int))" in bounds
assert "COALESCE(b.ppm2_max, bc.ppm2_max, CAST(:ppm_max AS int))" in bounds
def test_plausible_deal_degrades_to_city_band_not_ekb_constants() -> None:
"""Окно деплоя: по региону 77 в таблице ровно одна строка 'Москва'."""
bands = {(77, "Москва"): (22_475, 772_165)}
okrug = "муниципальный округ Некрасовка"
# 30 000 ₽/м² ниже DEAL_MIN_PPM2=50000, но внутри московского фолбэка → keep.
assert est._is_plausible_deal(
30_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77
)
# Явный отказ от второй ступени: та же сделка проваливается в ЕКБ-калибровку
# и отбрасывается. Пропуск city_fallback здесь дал бы TypeError — см. тест ниже.
assert not est._is_plausible_deal(
30_000, 5, 12, city=okrug, city_fallback=None, bands=bands, region_code=77
)
# Городской потолок при этом продолжает работать.
assert not est._is_plausible_deal(
900_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77
)
# Регион 66: обе ступени — один ключ, поведение прежнее.
r66 = {(66, "Асбест"): (8_000, 254_831)}
assert est._is_plausible_deal(
41_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=r66, region_code=66
)
assert not est._is_plausible_deal(
300_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=r66, region_code=66
)
_MOSCOW_ONLY_BANDS = [
{"region_code": 77, "city": "Москва", "ppm2_min": 22_475, "ppm2_max": 772_165}
]
def test_fetch_deals_before_first_refresh_uses_city_band() -> None:
"""_fetch_deals в том же окне: округ без своей строки судится полосой 'Москва'."""
cheap = _deal_row(price_per_m2=30_000.0, price_rub=2_400_000.0)
assert len(_fetch([cheap], _MOSCOW_ONLY_BANDS)) == 1 # ЕКБ-пол 50000 отбросил бы
assert _fetch([cheap], []) == [] # без полос вообще — те самые ЕКБ-константы
# Дорогая сделка того же округа пока отсекается городским потолком 772165 —
# окружная полоса (до 1 405 882) появится после первого ночного рефреша.
assert _fetch([_deal_row()], _MOSCOW_ONLY_BANDS) == []
def test_fetch_deals_region66_two_step_is_identical() -> None:
"""Регион 66: src_city пуст → обе ступени дают 'Асбест', результат прежний."""
row = _deal_row(
city="Асбест",
region_code=66,
price_per_m2=41_000.0,
price_rub=3_280_000.0,
**{DEAL_CITY_KEY_COLUMN: "Асбест"},
)
r66 = [{"region_code": 66, "city": "Асбест", "ppm2_min": 8_000, "ppm2_max": 254_831}]
assert len(_fetch([row], r66)) == 1
assert _fetch([row], []) == [] # 41 000 < DEAL_MIN_PPM2 — глобальная ступень жива
# ── 6. Предикат исключения ЕКБ и обязательность второй ступени ───────────────
def test_rederive_excludes_ekb_by_city_key_not_raw_column() -> None:
"""Исключение ЕКБ судится тем же выражением, по которому идёт GROUP BY.
Раньше предикат смотрел на СЫРУЮ колонку city, а группировка уже на ключ.
Совпадение держалось на данных: прод 2026-09-11, регион 66 108 623 сделки,
исключено по city 55 749, по ключу 55 749, расхождение 0. Появись источник с
src_city='Екатеринбург' у сделки с другим city сырой предикат пропустил бы
её в derivation, завёл строку полосы 'Екатеринбург', и ступень 1 нашла бы её
для настоящих ЕКБ-сделок, сломав намеренное исключение.
"""
sql = str(_REDERIVE_SQL)
key = deal_city_key_sql("")
assert sql.count(f"NOT (region_code = 66 AND {key} = 'Екатеринбург')") == 2
assert "region_code = 66 AND city = 'Екатеринбург'" not in sql
def test_rederive_requires_nonnull_city_key_not_raw_column() -> None:
"""Непустоту судит выражение КЛЮЧА, а не сырая колонка city.
Раньше оба CTE фильтровали `AND city IS NOT NULL`, хотя GROUP BY и предикат
ЕКБ уже жили на ключе. Сделка с непустым src_city и NULL в city даёт валидный
ключ, но выпадала из derivation ЦЕЛИКОМ и из своей городской строки, и из
региональной статистики, молча занижая n_deals и перцентили региона, включая
потолок. Прод 2026-09-11: в популяции 321 560 сделок, city IS NULL 0 строк,
поэтому сегодняшний результат не меняется ни по одному региону (66 52 874
строки, p1=15345, p50=52706, p99.99=615312; 77 212 937 строк,
34221 / 294457 / 1944535; обе формы дают одно и то же, расхождение 0).
"""
code = _sql_code(str(_REDERIVE_SQL))
key = deal_city_key_sql("")
assert code.count(f"AND {key} IS NOT NULL") == 2
assert "AND city IS NOT NULL" not in code
def test_plausible_deal_requires_city_fallback_when_bands_given() -> None:
"""Пропуск второй ступени при переданных полосах — TypeError, а не тихий дефект.
До правки такой вызов молча возвращал False: московская сделка по 30 000 /м²
(внутри городской полосы 22475..772165) судилась ЕКБ-полом DEAL_MIN_PPM2=50000.
"""
bands = {(77, "Москва"): (22_475, 772_165)}
okrug = "муниципальный округ Некрасовка"
with pytest.raises(TypeError, match="city_fallback"):
est._is_plausible_deal(30_000, 5, 12, city=okrug, bands=bands, region_code=77)
# Явный None разрешён — отказ от второй ступени виден в коде вызова.
assert not est._is_plausible_deal(
30_000, 5, 12, city=okrug, city_fallback=None, bands=bands, region_code=77
)
assert est._is_plausible_deal(
30_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77
)
def test_plausible_deal_without_bands_keeps_positional_calls() -> None:
"""bands=None: судить нечем, обе ступени вырождаются в глобальные константы.
Поэтому параметр обязателен УСЛОВНО: безусловный сломал бы 30 позиционных
вызовов tests/test_deals_sanitize.py, не поймав ни одного реального дефекта.
"""
assert est._is_plausible_deal(150_000, 5, 9)
assert not est._is_plausible_deal(39_700, 5, 9)
assert est._is_plausible_deal(est.DEAL_MIN_PPM2, 1, None)
assert not est._is_plausible_deal(est.DEAL_MAX_PPM2 + 1, 5, 9)

View file

@ -0,0 +1,181 @@
"""#3051: `suggest()` умеет регион — и по умолчанию остаётся свердловским.
ПОЧЕМУ ЭТО ВООБЩЕ ТЕСТ. Оба внешних тира подсказок ограничены регионом
ЖЁСТКИМ фильтром, а не бустом: DaData `locations.region` и Nominatim
`viewbox`+`bounded=1`. Промах региона не даёт ни ошибки, ни warning'а от
провайдера выдача схлопывается в ПУСТОЙ список, неотличимый от «такого
адреса нет». Ровно так московский адрес молча возвращал ноль подсказок при
свердловском констрейнте. Значит проверять надо не результат, а то, ЧТО
именно уходит провайдеру.
Второй, более важный предмет проверки регресс-нейтральность: вызов без
`region_code` обязан слать провайдерам те же самые значения, что и до правки.
Сеть не дёргаем: тиры мокаются по образцу `test_geocoder_city_hint`.
"""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
import pytest
from app.services.geocoder import (
OBLAST66_VIEWBOX,
SVERDLOVSK_OBLAST_REGION,
_dadata_suggest,
_nominatim_suggest,
_viewbox_for_region,
suggest,
)
from app.services.regions import REGIONS
pytestmark = pytest.mark.anyio
# ── DaData-тир: имя региона в hard-констрейнте ───────────────────────────────
async def test_dadata_suggest_default_region_unchanged() -> None:
"""Без region_code — прежняя константа «Свердловская» (БЕЗ типа)."""
mock = AsyncMock(return_value=[])
with patch("app.services.geocoder.dadata.suggest_addresses", new=mock):
assert await _dadata_suggest("Малышева 30", limit=5) == []
assert mock.await_args.kwargs["region"] == SVERDLOVSK_OBLAST_REGION
assert mock.await_args.kwargs["region"] == "Свердловская"
assert mock.await_args.kwargs["city"] is None
async def test_dadata_suggest_region_77_sends_moscow() -> None:
"""region_code=77 — в DaData уходит «Москва», а не свердловский констрейнт."""
mock = AsyncMock(return_value=[])
with patch("app.services.geocoder.dadata.suggest_addresses", new=mock):
assert await _dadata_suggest("Тверская 6", limit=5, region_code=77) == []
assert mock.await_args.kwargs["region"] == "Москва"
async def test_dadata_suggest_unknown_region_raises() -> None:
"""Регион вне реестра — явная ошибка, а не молчаливый пустой список."""
with patch("app.services.geocoder.dadata.suggest_addresses", new=AsyncMock(return_value=[])):
with pytest.raises(ValueError, match="unknown region_code"):
await _dadata_suggest("Ленина 1", limit=5, region_code=99)
# ── Nominatim-тир: рамка региона ─────────────────────────────────────────────
def test_viewbox_for_region_66_is_literal_constant() -> None:
"""Для 66 рамка — историческая константа, не пересчёт из bbox реестра."""
assert _viewbox_for_region(66) == OBLAST66_VIEWBOX["viewbox"]
def test_viewbox_for_region_77_covers_moscow() -> None:
"""Рамка 77 строится из bbox_region реестра: lon_min,lat_max,lon_max,lat_min."""
lat_min, lat_max, lon_min, lon_max = REGIONS[77].bbox_region
assert _viewbox_for_region(77) == f"{lon_min},{lat_max},{lon_max},{lat_min}"
assert _viewbox_for_region(77) != OBLAST66_VIEWBOX["viewbox"]
async def test_nominatim_suggest_default_viewbox_and_suffix_unchanged() -> None:
"""Дефолтный вызов: свердловская рамка + ЕКБ-суффикс dual-query (#2580 C2)."""
seen: list[tuple[str, str]] = []
async def fake_get(url, params=None, **_kw):
seen.append((params["q"], params["viewbox"]))
response = MagicMock()
response.json.return_value = []
response.raise_for_status.return_value = None
return response
with (
patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)),
patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()),
):
await _nominatim_suggest("Ленина, 1", limit=5)
queries = [q for q, _ in seen]
assert "Ленина, 1, Екатеринбург" in queries
assert "Ленина, 1" in queries
assert {vb for _, vb in seen} == {OBLAST66_VIEWBOX["viewbox"]}
async def test_nominatim_suggest_region_77_sends_moscow_frame() -> None:
"""region_code=77: московская рамка и московский суффикс, ЕКБ не упоминается."""
seen: list[tuple[str, str]] = []
async def fake_get(url, params=None, **_kw):
seen.append((params["q"], params["viewbox"]))
response = MagicMock()
response.json.return_value = []
response.raise_for_status.return_value = None
return response
with (
patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)),
patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()),
):
await _nominatim_suggest("Тверская, 6", limit=5, region_code=77)
queries = [q for q, _ in seen]
assert "Тверская, 6, Москва" in queries
assert not any("Екатеринбург" in q for q in queries)
assert {vb for _, vb in seen} == {_viewbox_for_region(77)}
# ── suggest(): прокидывание региона и гейт локальных ЕКБ-тиров ───────────────
async def test_suggest_passes_region_to_both_tiers() -> None:
"""region_code доезжает и до DaData, и до Nominatim-фолбэка."""
dadata_mock = AsyncMock(return_value=[])
nominatim_mock = AsyncMock(return_value=[])
with (
patch("app.services.geocoder._dadata_suggest", new=dadata_mock),
patch("app.services.geocoder._nominatim_suggest", new=nominatim_mock),
patch("app.services.geocoder.settings") as mock_settings,
):
mock_settings.dadata_api_token = "token"
await suggest("Тверская 6", db=None, limit=5, region_code=77)
assert dadata_mock.await_args.args[2] == 77
assert nominatim_mock.await_args.kwargs["region_code"] == 77
async def test_suggest_region_77_skips_cadastral_tier() -> None:
"""Кадастровый тир (ЕКБ-FDW) для 77 не зовётся вовсе — данных там нет."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_house_match") as house_mock,
patch("app.services.geocoder._cadastral_forward_sync") as forward_mock,
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])),
patch("app.services.geocoder.settings") as mock_settings,
):
mock_settings.dadata_api_token = None
await suggest("Тверская 6", db=db, limit=5, region_code=77)
house_mock.assert_not_called()
forward_mock.assert_not_called()
async def test_suggest_default_still_uses_cadastral_tier() -> None:
"""Регресс-контроль: дефолтный (66) вызов кадастровый тир по-прежнему зовёт."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_house_match", return_value=None) as house_mock,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as forward_mock,
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])),
patch("app.services.geocoder.settings") as mock_settings,
):
mock_settings.dadata_api_token = None
await suggest("Малышева 30", db=db, limit=5)
assert house_mock.called or forward_mock.called
async def test_suggest_unknown_region_raises() -> None:
"""Неизвестный регион — ValueError до похода к провайдерам."""
with pytest.raises(ValueError, match="unknown region_code"):
await suggest("Ленина 1", db=None, limit=5, region_code=99)

View file

@ -304,6 +304,775 @@ def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
assert data["threshold"] == 0
# ── Москва: сетка из 67 центроидов, одно display-имя, жёлтый порог ───────────
# Кремль — исторический центр; в сетке ему соответствует точка «Тверской».
_MOSCOW_CENTER = {"lat": 55.7558, "lon": 37.6173}
# Ватутинки, ТиНАО: ~32 км от Кремля. Точка существует, чтобы ловить дальнюю
# от центра часть Новой Москвы, а не центральные ячейки сетки.
_NEW_MOSCOW = {"lat": 55.5008, "lon": 37.3800}
# Зеленоград: эксклав в ~37 км от Кремля и в ~46 км от ТиНАО — без собственных
# точек сетки был бы not_covered.
_ZELENOGRAD = {"lat": 55.9825, "lon": 37.1814}
# Контрольные точки ВНУТРИ МКАД (замер 11.09.2026). До перехода на сетку
# половина из них проигрывала подмосковному центроиду и отдавала «город не
# определён» — это и был дефект, ради которого сетка заведена. Проверяем
# ПОВЕДЕНИЕ резолвера на координатах, а не содержимое константы.
_INSIDE_MKAD = {
"Новогиреево": (55.7520, 37.8140),
"Некрасовка": (55.7020, 37.9200),
"Тушино": (55.8267, 37.4358),
"Северное Бутово": (55.5680, 37.5700),
"Солнцево": (55.6350, 37.3900),
"Ясенево": (55.6060, 37.5330),
"Митино": (55.8450, 37.3630),
"Марьино": (55.6500, 37.7440),
"Измайлово": (55.7890, 37.7510),
"Бирюлёво Восточное": (55.5900, 37.6800),
"Печатники": (55.6900, 37.7300),
"Куркино": (55.8925, 37.3810),
}
# Владимирская область, ~180 км от Кремля и ~200 км от ТиНАО: «посреди поля»
# уже с московской стороны страны — новые центроиды не должны были расширить
# покрытие вглубь ЦФО.
_FAR_FROM_MOSCOW = {"lat": 55.5, "lon": 40.5}
def test_moscow_center_ok_at_yellow_threshold(trade_in_app: FastAPI) -> None:
"""Центр Москвы → city='Москва', порог 12 (жёлтый тир), n=12 → ok."""
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["city"] == "Москва"
assert data["threshold"] == 12
assert data["status"] == "ok"
def test_moscow_is_yellow_not_green(trade_in_app: FastAPI) -> None:
"""n=8 в Москве — это thin, а не ok: тир жёлтый (замер 10.09.2026, медиана
когорты 14 против 37 у зелёного Екатеринбурга, доля когорт >= 12 0.57)."""
db = _db_mock_returning(_row(8, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER})
data = resp.json()
assert data["status"] == "thin"
assert data["threshold"] == 12
def test_new_moscow_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None:
"""ТиНАО ловится своей точкой сетки, но наружу уходит «Москва».
Ключ центроида («Москва (Ватутинки, НАО)») наружу не просачивается иначе
повторился бы баг Берёзовского: человек видит в ответе чужое название.
"""
db = _db_mock_returning(_row(12, 25.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NEW_MOSCOW})
data = resp.json()
assert data["city"] == "Москва"
assert data["threshold"] == 12
assert data["status"] == "ok"
def test_zelenograd_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None:
"""Зеленоград — свои точки сетки, тоже с display-именем «Москва»."""
db = _db_mock_returning(_row(12, 25.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_ZELENOGRAD})
data = resp.json()
assert data["city"] == "Москва"
assert data["threshold"] == 12
def test_inside_mkad_points_resolve_to_moscow(trade_in_app: FastAPI) -> None:
"""Все контрольные точки внутри МКАД → «Москва» с жёлтым порогом 12.
Регрессия на дефект «сетки нет»: с тремя центроидами Митино, Марьино,
Печатники, Некрасовка и Новогиреево проигрывали подмосковным точкам
(Химки, Реутов, Развилка) и отдавали «город не определён».
"""
from app.api.v1.trade_in import _resolve_coverage_city
for name, (lat, lon) in _INSIDE_MKAD.items():
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), (
f"{name} внутри МКАД, но резолвится не в Москву"
)
def test_inside_mkad_points_answer_through_the_endpoint(trade_in_app: FastAPI) -> None:
"""Те же точки через ручку: 200, city='Москва', threshold=12."""
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for name, (lat, lon) in _INSIDE_MKAD.items():
resp = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["city"] == "Москва", name
assert data["threshold"] == 12, name
assert data["status"] == "ok", name
def test_every_moscow_grid_point_resolves_to_moscow() -> None:
"""В каждой точке сетки резолвер отдаёт «Москву» — мёртвых точек нет.
Точка, у которой ближайшим оказался подмосковный центроид, была бы мёртвой
строкой: её кластер объявлений всё равно ушёл бы в «город не определён».
"""
from app.api.v1.trade_in import _MOSCOW_GRID_DEG, _resolve_coverage_city
assert len(_MOSCOW_GRID_DEG) == 67
for key, (lat, lon) in _MOSCOW_GRID_DEG.items():
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), key
def test_far_from_moscow_centroids_still_not_covered(trade_in_app: FastAPI) -> None:
"""Точка посреди поля в 180 км от Москвы → not_covered, city=""."""
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_FROM_MOSCOW})
data = resp.json()
assert data["status"] == "not_covered"
assert data["city"] == ""
assert data["threshold"] == 0
def test_every_centroid_key_has_a_threshold() -> None:
"""Центроид без порога — это KeyError и 500 в ручке, а не «город не найден».
Инвариант появился вместе с московскими центроидами: ключ центроида и
display-имя города теперь разные вещи, и промах в одном из трёх ключей
молча превратился бы в падение ручки на реальном московском адресе.
"""
from app.api.v1.trade_in import (
_CITY_CENTROIDS_DEG,
_COVERAGE_CITY_THRESHOLDS,
_fold_city,
)
for key in _CITY_CENTROIDS_DEG:
assert _fold_city(key) in _COVERAGE_CITY_THRESHOLDS, (
f"центроид {key} не имеет порога — резолв по координатам упадёт с KeyError"
)
def test_all_moscow_centroids_share_one_display_and_threshold() -> None:
"""Все 56 ключей сетки → одно имя «Москва» и один порог 12.
Проверяем не список координат, а результат резолва в каждой точке сетки:
ключ («Москва (Митино)» и т.п.) наружу просочиться не должен.
"""
from app.api.v1.trade_in import (
_CITY_CENTROIDS_DEG,
_COVERAGE_CITY_THRESHOLDS,
COVERAGE_MOSCOW_CENTROID_KEYS,
COVERAGE_MOSCOW_MIN_N,
_fold_city,
_resolve_coverage_city,
)
assert COVERAGE_MOSCOW_MIN_N == 12
assert len(COVERAGE_MOSCOW_CENTROID_KEYS) == 67
assert len(set(COVERAGE_MOSCOW_CENTROID_KEYS)) == 67, "ключи сетки должны быть уникальны"
for key in COVERAGE_MOSCOW_CENTROID_KEYS:
assert _COVERAGE_CITY_THRESHOLDS[_fold_city(key)] == ("Москва", 12)
lat, lon = _CITY_CENTROIDS_DEG[key]
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True)
def test_sverdlovsk_resolution_untouched_by_moscow_centroids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Свердловская область не изменилась ни на байт после добавления Москвы.
Сравниваем резолв на полной таблице центроидов с резолвом на той же
таблице БЕЗ московских ключей по всем свердловским центроидам, по
нескольким точкам между ними и по заведомо далёкой точке.
"""
from app.api.v1 import trade_in as trade_in_module
full = trade_in_module._CITY_CENTROIDS_DEG
moscow_keys = {
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
}
without_moscow = {
k: v for k, v in full.items() if trade_in_module._fold_city(k) not in moscow_keys
}
assert len(without_moscow) == len(full) - 67
points = [
*without_moscow.values(),
(56.75, 60.70), # южная окраина Екатеринбурга
(56.95, 60.65), # между ЕКБ и Верхней Пышмой
(57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих
(58.0424, 65.2711), # Тавда, далеко от всего
]
after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", without_moscow)
before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
assert after == before
# ── Подмосковье: отрицательные центроиды забирают область у Москвы ────────────
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
_MOSCOW_OBLAST_CITIES = {
"Химки": (55.8894, 37.4450),
"Мытищи": (55.9116, 37.7308),
"Люберцы": (55.6767, 37.8931),
"Балашиха": (55.7969, 37.9386),
"Красногорск": (55.8317, 37.3300),
"Одинцово": (55.6789, 37.2639),
"Подольск": (55.4312, 37.5450),
"Реутов": (55.7614, 37.8564),
"Королёв": (55.9142, 37.8256),
"Долгопрудный": (55.9386, 37.5100),
"Видное": (55.5519, 37.7133),
"Железнодорожный": (55.7444, 38.0128),
"Лобня": (56.0100, 37.4750),
"Дзержинский": (55.6294, 37.8500),
"Котельники": (55.6553, 37.8619),
"Истра": (55.9167, 36.8667),
}
def test_moscow_oblast_cities_resolve_to_no_city() -> None:
"""Подмосковье → «город не определён»: данных по области мы не собираем."""
from app.api.v1.trade_in import _resolve_coverage_city
for name, (lat, lon) in _MOSCOW_OBLAST_CITIES.items():
assert _resolve_coverage_city(lat, lon) == ("", 0, False), (
f"{name} — Московская область, но резолвится в город"
)
def test_every_negative_centroid_resolves_to_no_city() -> None:
"""Каждая точка списка отрицательных центроидов — не город.
Проверяем ВЕСЬ список, а не выборку: точка, случайно оказавшаяся ближе
к московскому центроиду, чем к себе самой, была бы мёртвой строкой.
"""
from app.api.v1.trade_in import (
_COVERAGE_NEGATIVE_CENTROIDS_DEG,
_resolve_coverage_city,
)
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
def test_negative_centroids_have_no_threshold_and_no_key_collision() -> None:
"""Отрицательные центроиды не должны попасть в таблицу порогов.
И не должны пересекаться по ключу с настоящими городами иначе один
словарь молча перекрыл бы другой в конкурсе ближайшего.
"""
from app.api.v1.trade_in import (
_CITY_CENTROIDS_DEG,
_COVERAGE_CITY_THRESHOLDS,
_COVERAGE_NEGATIVE_CENTROIDS_DEG,
_fold_city,
)
negative = {_fold_city(k) for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG}
positive = {_fold_city(k) for k in _CITY_CENTROIDS_DEG}
assert negative & positive == set()
assert negative & set(_COVERAGE_CITY_THRESHOLDS) == set()
def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> None:
"""Опорные московские точки по-прежнему дают «Москву» и жёлтый порог."""
from app.api.v1.trade_in import _resolve_coverage_city
for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD):
assert _resolve_coverage_city(point["lat"], point["lon"]) == ("Москва", 12, True)
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD):
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **point})
assert resp.status_code == 200, resp.text
assert resp.json()["city"] == "Москва"
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
"""Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу."""
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
lat, lon = _MOSCOW_OBLAST_CITIES["Химки"]
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["status"] == "not_covered"
assert data["city"] == ""
assert data["threshold"] == 0
def test_nearest_centroid_without_threshold_does_not_break_the_endpoint(
trade_in_app: FastAPI,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ближайший центроид без порога → «город не определён», а не 500.
Раньше порог брался прямым индексированием `_COVERAGE_CITY_THRESHOLDS`,
и любой центроид без порога (отрицательный или просто забытый при
добавлении города) ронял ручку на живом адресе.
"""
from app.api.v1 import trade_in as trade_in_module
monkeypatch.setattr(
trade_in_module,
"_CITY_CENTROIDS_DEG",
{
**trade_in_module._CITY_CENTROIDS_DEG,
"Город Без Порога": (_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]),
},
)
assert trade_in_module._resolve_coverage_city(_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]) == (
"",
0,
False,
)
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
resp = TestClient(trade_in_app).post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "not_covered"
assert resp.json()["city"] == ""
def test_sverdlovsk_resolution_untouched_by_negative_centroids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Свердловская область не изменилась ни на байт от подмосковных точек.
Сравниваем резолв на полной таблице с резолвом БЕЗ отрицательных
центроидов по всем свердловским центроидам, точкам между ними и заведомо
далёкой точке. Плюс прямая проверка расстояний: ближайшая подмосковная
точка к любому свердловскому центроиду дальше, чем сам центроид (0 км).
"""
from app.api.v1 import trade_in as trade_in_module
sverdlovsk = {
k: v
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
if trade_in_module._fold_city(k)
not in {
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
}
}
assert len(sverdlovsk) == 9
points = [
*sverdlovsk.values(),
(56.75, 60.70), # южная окраина Екатеринбурга
(56.95, 60.65), # между ЕКБ и Верхней Пышмой
(57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих
(58.0424, 65.2711), # Тавда, далеко от всего
]
after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
monkeypatch.setattr(trade_in_module, "_COVERAGE_NEGATIVE_CENTROIDS_DEG", {})
before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
assert after == before
negatives = dict(_MOSCOW_OBLAST_CITIES)
for city, (clat, clon) in sverdlovsk.items():
nearest_negative_km = min(
trade_in_module._haversine_km(clat, clon, nlat, nlon)
for nlat, nlon in negatives.values()
)
assert nearest_negative_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM, (
f"подмосковная точка в {nearest_negative_km:.0f} км от {city}"
)
# ── Радиус как свойство центроида: контрольные наборы замера 11.09.2026 ──────
# Московский контрольный набор (29 адресов). Не выборка «для красоты»: сюда
# входят все четыре точки, которые в замере оказались худшими по запасу до
# сетки (Рублёво 4.05 км, Внуково 3.64, Медведково 2.78, Бибирево 2.75),
# анклавы (Зеленоград, Восточный, Куркино, Капотня, Новокосино) и по одному
# адресу с каждой стороны МКАД. Проверяется ПОВЕДЕНИЕ резолвера на
# координатах, а не содержимое констант.
_MOSCOW_CONTROLS = {
"Тверской": (55.7558, 37.6173),
"Рублёво": (55.7530, 37.3680),
"Внуково": (55.6270, 37.2860),
"Северное Медведково": (55.8880, 37.6480),
"Бибирево": (55.8930, 37.5900),
"Капотня": (55.6390, 37.7950),
"Новокосино": (55.7400, 37.8600),
"Куркино": (55.8925, 37.3810),
"Восточный": (55.8130, 37.8600),
"Ховрино": (55.8730, 37.4870),
"Северное Тушино": (55.8560, 37.4390),
"Митино": (55.8450, 37.3630),
"Зеленоград": (55.9825, 37.1814),
"Ватутинки (НАО)": (55.5008, 37.3800),
"Некрасовка": (55.7020, 37.9200),
"Новогиреево": (55.7520, 37.8140),
"Ивановское": (55.7750, 37.8330),
"Жулебино": (55.6900, 37.8550),
"Марьино": (55.6500, 37.7440),
"Братеево": (55.6350, 37.7400),
"Орехово-Борисово Южное": (55.6000, 37.7250),
"Бирюлёво Восточное": (55.5900, 37.6800),
"Северное Бутово": (55.5680, 37.5700),
"Ясенево": (55.6060, 37.5330),
"Солнцево": (55.6350, 37.3900),
"Можайский": (55.7050, 37.4000),
"Строгино": (55.8020, 37.4020),
"Северный": (55.9250, 37.5450),
"Троицк (ТАО)": (55.4853, 37.3050),
}
# Областной контрольный набор (32 города). 16 из них имеют собственный
# отрицательный центроид и выигрывают им; остальные 16 держатся только
# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей
# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км
# резолвились как «Москва». Это и есть дефект #1.
_MOSCOW_OBLAST_CONTROLS = {
**_MOSCOW_OBLAST_CITIES,
"Наро-Фоминск": (55.3869, 36.7331),
"Кубинка": (55.5794, 36.7000),
"Чехов": (55.1477, 37.4770),
"Голицыно": (55.6167, 36.9833),
"Лыткарино": (55.5833, 37.9000),
"Домодедово": (55.4400, 37.7594),
"Жуковский": (55.5952, 38.1200),
"Щёлково": (55.9200, 38.0000),
"Пушкино": (56.0100, 37.8500),
"Звенигород": (55.7300, 36.8500),
"Апрелевка": (55.5500, 37.0700),
"Селятино": (55.5081, 36.9825),
"Немчиновка": (55.7050, 37.3450),
"Развилка": (55.5842, 37.7392),
"Томилино": (55.6528, 37.9472),
"Барвиха": (55.7333, 37.2333),
}
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
# ключ здесь — только адрес строки.
_ADDED_GRID_KEYS = (
"Москва (Восточный)",
"Москва (Левобережный)",
"Москва (Орехово-Борисово Южное)",
"Москва (Северный)",
"Москва (Митино-запад)",
"Москва (Ивановское)",
"Москва (Жулебино)",
"Москва (Можайский-запад)",
"Москва (Новокосино)",
"Москва (Капотня)",
"Москва (Куркино)",
"Москва (Некрасовка-восток)",
)
# Артефакт геокода, выброшенный из сетки: адрес «ВАО, р-н Восточный, 4»,
# посаженный геокодером на 22 км мимо настоящего Восточного и раздававший
# имя «Москва» Пушкино в 3.06 км.
_PHANTOM_EXCLAVE = (56.0087, 37.7960)
def _legacy_resolve(lat: float, lon: float, radius_km: float = 25.0) -> tuple[str, int, bool]:
"""Прежний резолвер: тот же конкурс ближайшего, но ОДИН глобальный радиус.
Нужен, чтобы тесты сравнивали ПОВЕДЕНИЕ, а не пересказывали константы:
им проверяется и что дефект #1 действительно существовал, и что
Свердловская область от перехода на радиус-свойство не сдвинулась.
"""
from app.api.v1 import trade_in as trade_in_module
nearest, nearest_km = None, float("inf")
for city, (clat, clon) in (
*trade_in_module._CITY_CENTROIDS_DEG.items(),
*trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
):
distance_km = trade_in_module._haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km, nearest = distance_km, city
if nearest is None or nearest_km > radius_km:
return "", 0, False
entry = trade_in_module._COVERAGE_CITY_THRESHOLDS.get(trade_in_module._fold_city(nearest))
return ("", 0, False) if entry is None else (entry[0], entry[1], True)
def test_whole_moscow_control_set_resolves_to_moscow() -> None:
"""Все 29 московских контролей → «Москва», жёлтый порог 12."""
from app.api.v1.trade_in import _resolve_coverage_city
assert len(_MOSCOW_CONTROLS) == 29
failed = {
name: _resolve_coverage_city(lat, lon)
for name, (lat, lon) in _MOSCOW_CONTROLS.items()
if _resolve_coverage_city(lat, lon) != ("Москва", 12, True)
}
assert failed == {}
def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: FastAPI) -> None:
"""Те же 29 адресов через ручку: 200, city='Москва', threshold=12."""
db = _db_mock_returning(_row(12, 20.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for name, (lat, lon) in _MOSCOW_CONTROLS.items():
resp = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
)
assert resp.status_code == 200, resp.text
assert resp.json()["city"] == "Москва", name
assert resp.json()["threshold"] == 12, name
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
"""Все 32 областных контроля → «город не определён», и ручка отдаёт 200."""
from app.api.v1.trade_in import _resolve_coverage_city
assert len(_MOSCOW_OBLAST_CONTROLS) == 32
failed = {
name: _resolve_coverage_city(lat, lon)
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
if _resolve_coverage_city(lat, lon) != ("", 0, False)
}
assert failed == {}
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items():
resp = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "not_covered", name
assert resp.json()["city"] == "", name
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
"""Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов.
С одним глобальным радиусом 25 км круги плотной сетки складывались, и все
трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и
22.3 км. С радиусом-свойством (8 км у московских точек) «не определён».
"""
from app.api.v1.trade_in import _resolve_coverage_city
for name in ("Наро-Фоминск", "Кубинка", "Чехов"):
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
)
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
Наро-Фоминск в 9.95 км от ближайшей точки московской сетки «не
определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ)
«Екатеринбург». Глобальной константой такое поведение невыразимо.
"""
from app.api.v1 import trade_in as trade_in_module
lat, lon = _MOSCOW_OBLAST_CONTROLS["Наро-Фоминск"]
km_to_grid = min(
trade_in_module._haversine_km(lat, lon, glat, glon)
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values()
)
assert 8.0 < km_to_grid < 25.0, f"{km_to_grid:.2f} км — контроль потерял смысл"
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False)
ekb_lat, ekb_lon = trade_in_module._CITY_CENTROIDS_DEG["Екатеринбург"]
south_lat = ekb_lat - 20.0 / 111.19
km_to_ekb = trade_in_module._haversine_km(south_lat, ekb_lon, ekb_lat, ekb_lon)
assert km_to_ekb > km_to_grid, "свердловская проверка должна быть ДАЛЬШЕ московской"
assert trade_in_module._resolve_coverage_city(south_lat, ekb_lon) == ("Екатеринбург", 8, True)
def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None:
"""Свердловская область байт-в-байт: 25 км ей никто не менял.
Сравнение поведения, а не констант: густая решётка по всей области плюс
кольца по 24.5 и 25.5 км вокруг каждого свердловского центра (там, где
граница радиуса и проходит) ответ нового резолвера обязан совпасть
с ответом прежнего, у которого радиус был один глобальный.
"""
import math
from app.api.v1 import trade_in as trade_in_module
points: list[tuple[float, float]] = []
lat = 55.0
while lat <= 60.5:
lon = 57.0
while lon <= 66.0:
points.append((round(lat, 4), round(lon, 4)))
lon += 0.25
lat += 0.25
moscow_keys = {
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
}
sverdlovsk = {
k: v
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
if trade_in_module._fold_city(k) not in moscow_keys
}
assert len(sverdlovsk) == 9
for clat, clon in sverdlovsk.values():
for km in (24.5, 25.5):
dlat = km / 111.19
dlon = km / (111.19 * math.cos(math.radians(clat)))
points += [
(clat + dlat, clon),
(clat - dlat, clon),
(clat, clon + dlon),
(clat, clon - dlon),
]
mismatched = [
(lat, lon)
for lat, lon in points
if trade_in_module._resolve_coverage_city(lat, lon) != _legacy_resolve(lat, lon)
]
assert mismatched == [], f"{len(mismatched)} точек Свердловской изменили резолв"
assert len(points) > 800
def test_phantom_exclave_no_longer_gives_moscow_to_pushkino(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
Возвращаем призрак в таблицу Пушкино снова резолвится в «Москву»: это
доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё.
"""
from app.api.v1 import trade_in as trade_in_module
pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"]
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False)
ghost_key = "Москва (Восточный, эксклав)"
monkeypatch.setattr(
trade_in_module,
"_CITY_CENTROIDS_DEG",
{**trade_in_module._CITY_CENTROIDS_DEG, ghost_key: _PHANTOM_EXCLAVE},
)
# Возвращаем призрак целиком, как он жил: координаты, порог «Москвы»
# и московский радиус — иначе проверялась бы не сетка, а таблица порогов.
monkeypatch.setattr(
trade_in_module,
"_COVERAGE_CITY_THRESHOLDS",
{
**trade_in_module._COVERAGE_CITY_THRESHOLDS,
trade_in_module._fold_city(ghost_key): (
trade_in_module.COVERAGE_MOSCOW_DISPLAY,
trade_in_module.COVERAGE_MOSCOW_MIN_N,
),
},
)
monkeypatch.setattr(
trade_in_module,
"_CENTROID_RADIUS_KM",
{
**trade_in_module._CENTROID_RADIUS_KM,
trade_in_module._fold_city(ghost_key): trade_in_module.COVERAGE_MOSCOW_MATCH_RADIUS_KM,
},
)
assert trade_in_module._resolve_coverage_city(*pushkino) == ("Москва", 12, True)
def test_added_grid_points_return_districts_lost_to_negative_centroids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать
её из таблицы «город не определён» (район уходит подмосковному центру).
Мёртвых строк в добавке нет.
"""
from app.api.v1 import trade_in as trade_in_module
full = trade_in_module._CITY_CENTROIDS_DEG
for key in _ADDED_GRID_KEYS:
lat, lon = full[key]
assert trade_in_module._resolve_coverage_city(lat, lon) == ("Москва", 12, True), key
monkeypatch.setattr(
trade_in_module,
"_CITY_CENTROIDS_DEG",
{k: v for k, v in full.items() if k != key},
)
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), (
f"{key} ничего не возвращает — строка мёртвая"
)
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it(
trade_in_app: FastAPI,
) -> None:
"""У отрицательной точки радиус бесконечный, и ручка на этом не падает.
Отрицательный центроид действует ровно там, где он ближайший, и на любом
расстоянии означает «город не определён»: точка в глухом углу области,
где ближайший центр подмосковный и дальше 25 км, отдаёт not_covered
и 200, а не 500 по отсутствующему порогу.
"""
from app.api.v1 import trade_in as trade_in_module
far_lat, far_lon = 55.30, 38.75
nearest_km, nearest_name = min(
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
for name, (clat, clon) in (
*trade_in_module._CITY_CENTROIDS_DEG.items(),
*trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
)
)
assert nearest_name in trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG
assert nearest_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM
assert trade_in_module._centroid_radius_km(nearest_name) == float("inf")
assert trade_in_module._resolve_coverage_city(far_lat, far_lon) == ("", 0, False)
db = _db_mock_returning(_row(40, 30.0))
_override(trade_in_app, db)
resp = TestClient(trade_in_app).post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": far_lat, "lon": far_lon}
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "not_covered"
assert resp.json()["city"] == ""
def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None:
"""city_hint — чисто информационное поле (повторная проверка #2894): точка в
Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский

View file

@ -90,11 +90,16 @@ def test_client_coords_in_ekb_skip_geocode() -> None:
def test_out_of_bbox_coords_ignored_geocode_called() -> None:
"""Moscow coords are outside the EKB bbox → ignored, geocode() IS called."""
"""Coords outside EVERY covered region → ignored, geocode() IS called.
#3051: Moscow (55.75/37.61) used to be the out-of-bbox sample here, but region
77 is now part of the coverage registry and its coords go through the same
client-coords fast path as oblast 66 ones. Sochi is outside both.
"""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload(lat=55.75, lon=37.61) # Moscow — out of EKB bbox
payload = _make_payload(lat=43.60, lon=39.73) # Sochi — outside all regions
geocode_mock = AsyncMock(return_value=_make_fake_geo())
async def _run() -> None:

View file

@ -129,7 +129,7 @@ async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None
"""
calls: list[str] = []
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
@ -150,7 +150,7 @@ async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None
async def test_nominatim_suggest_city_hint_suffix() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
@ -165,7 +165,7 @@ async def test_nominatim_suggest_city_hint_suffix() -> None:
async def test_nominatim_suggest_explicit_ekb_unchanged() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
@ -202,7 +202,7 @@ async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None:
"address": {"road": "улица Победы", "house_number": "20"},
}
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
if query.endswith(", Екатеринбург"):
return [ekb_item]
return [pyshma_item]
@ -237,7 +237,7 @@ async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
"address": {"road": "ул. Малышева", "house_number": "30"},
}
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
return [same_item]
with (

View file

@ -0,0 +1,402 @@
"""Тесты импорта московского сырья `msk_raw` в listings.
Изоляция как у соседей (tests/tasks/test_backfill_house_coords_from_listings.py):
DATABASE_URL проставляется до импорта модуля, в сеть и в прод-БД тест не ходит.
Сессия заглушка, отдающая заранее заготовленные payload'ы; писатель
(`save_listings`) подменяется monkeypatch'ем, так что проверяется ровно наш отбор
и пересчёт метки, а не поведение кита.
Главные риски задачи, которые тут и караулятся:
* в регион 77 уедет Московская область (адрес без округа);
* артефакт парсера («Екатеринбург (Cian)») растворится в «не Москва»;
* метка кита 'novostroyki' будет скопирована как есть и выкинет 29 000 карточек
из подбора аналогов (гвард estimator'а берёт только NULL/'vtorichka').
"""
from __future__ import annotations
import os
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks import msk_raw_import as mod
def _payload(**over: Any) -> dict[str, Any]:
"""Минимальный сериализованный ScrapedLot, как он лежит в msk_raw.payload."""
base: dict[str, Any] = {
"source": "cian",
"source_url": "https://www.cian.ru/sale/flat/330200428/",
"source_id": "330200428",
"address": "ЦАО, р-н Арбат, улица Арбат, 10",
"price_rub": 25_000_000,
"rooms": 2,
"area_m2": 55.0,
"listing_segment": "novostroyki", # метка кита — её и пересчитываем
"raw_payload": {"is_from_developer": False},
}
base.update(over)
return base
# ── отбор по адресу ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("okrug", mod.MOSCOW_OKRUGS)
def test_every_okrug_prefix_is_moscow(okrug: str) -> None:
assert mod.is_moscow_address(f"{okrug}, р-н Хамовники, улица Льва Толстого, 16")
def test_oblast_address_without_okrug_is_not_moscow() -> None:
"""Адрес без округа — Московская область (регион 50), его в импорт не берём."""
assert not mod.is_moscow_address("Химки, микрорайон Новогорск, Ивановская улица, 5")
assert not mod.is_moscow_address("городской округ Мытищи, Олимпийский проспект, 29")
def test_okrug_must_be_a_whole_token() -> None:
"""«ЗАО» в начале улицы — не округ; иначе область просочилась бы в регион 77."""
assert not mod.is_moscow_address("ЗАОзёрная улица, 25")
def test_ekaterinburg_artifact_is_its_own_reason() -> None:
assert mod.is_artifact_address("Екатеринбург (Cian)")
assert not mod.is_moscow_address("Екатеринбург (Cian)")
# ── пересчёт listing_segment ─────────────────────────────────────────────────
def test_segment_recomputed_to_vtorichka_when_developer_false() -> None:
lot = mod.build_lot(_payload())
assert lot.listing_segment == "vtorichka"
def test_segment_recomputed_to_vtorichka_when_flag_absent() -> None:
"""Отсутствие флага — не признак новостройки (у 31 464 карточек его нет вовсе)."""
lot = mod.build_lot(_payload(raw_payload={}))
assert lot.listing_segment == "vtorichka"
assert mod.build_lot(_payload(raw_payload=None)).listing_segment == "vtorichka"
def test_segment_is_novostroyki_only_when_developer_sells() -> None:
lot = mod.build_lot(_payload(raw_payload={"is_from_developer": True}))
assert lot.listing_segment == "novostroyki"
def test_unknown_payload_keys_are_dropped() -> None:
"""В payload может приехать ключ, которого в модели нет — это не должно ронять импорт."""
lot = mod.build_lot(_payload(some_future_field="x"))
assert lot.source_id == "330200428"
# ── прогон целиком ───────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _FakeResult:
return self
def all(self) -> list[dict[str, Any]]:
return self._rows
def scalar(self) -> Any:
"""Проба существования кэш-таблицы: None = таблицы ещё нет."""
return self._rows[0] if self._rows else None
class _FakeSession:
"""Отдаёт строки постранично по тому же keyset-условию, что и настоящая вью."""
def __init__(self, rows: list[dict[str, Any]]) -> None:
self.rows = rows
self.commits = 0
def execute(self, _sql: Any, params: dict[str, Any] | None = None) -> _FakeResult:
if not params or "after" not in params:
return _FakeResult([]) # DDL кэша пред-геокода — строк не отдаёт
after, limit = params["after"], params["limit"]
return _FakeResult([r for r in self.rows if r["id"] > after][:limit])
def commit(self) -> None:
self.commits += 1
def _rows() -> list[dict[str, Any]]:
return [
{"id": 1, "payload": _payload(source_id="1")},
{"id": 2, "payload": _payload(source_id="2", address="Химки, Ивановская улица, 5")},
{"id": 3, "payload": _payload(source_id="3", address="Екатеринбург (Cian)")},
{"id": 4, "payload": _payload(source_id="4", address="СВАО, улица Милашенкова, 7")},
{"id": 5, "payload": _payload(source_id="5", price_rub=0)}, # невалидный: цена > 0
]
@pytest.fixture()
def captured(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
calls: list[dict[str, Any]] = []
def _fake_save(db: Any, lots: list[Any], **kwargs: Any) -> tuple[int, int]:
calls.append({"lots": lots, **kwargs})
return len(lots), 0
monkeypatch.setattr(mod, "save_listings", _fake_save)
return calls
def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> None:
db = _FakeSession(_rows())
counters = mod.import_msk_raw(db, batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
assert counters.read == 5
assert counters.selected == 2
assert counters.skipped_not_moscow == 1
assert counters.skipped_artifact == 1
assert counters.skipped_invalid == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_writer_gets_region_77_and_city(captured: list[dict[str, Any]]) -> None:
mod.import_msk_raw(_FakeSession(_rows()), batch_size=10)
assert captured[0]["region_code"] == 77
assert captured[0]["city"] == "Москва"
assert all(lot.listing_segment == "vtorichka" for lot in captured[0]["lots"])
def test_commit_per_batch(captured: list[dict[str, Any]]) -> None:
"""Обрыв на середине не должен терять уже перелитые батчи."""
db = _FakeSession(_rows())
mod.import_msk_raw(db, batch_size=1)
assert db.commits == 2 # ровно столько батчей содержали московские строки
def test_dry_run_writes_nothing(captured: list[dict[str, Any]]) -> None:
db = _FakeSession(_rows())
counters = mod.import_msk_raw(db, batch_size=10, dry_run=True)
assert captured == []
assert db.commits == 0
assert counters.selected == 2 and counters.written == 0
def test_limit_caps_rows_read(captured: list[dict[str, Any]]) -> None:
counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=2, limit=3)
assert counters.read == 3
def test_avito_refuses_to_write_without_explicit_opt_in(captured: list[dict[str, Any]]) -> None:
"""У Авито в адресе нет города, а координат нет ни у одной карточки — молча
залить область в регион 77 нельзя."""
rows = [{"id": 1, "payload": _payload(source="avito", address="Варшавское ш.,62к1")}]
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(rows), source="avito", batch_size=10)
counters = mod.import_msk_raw(
_FakeSession(rows), source="avito", batch_size=10, allow_unfiltered=True
)
assert counters.selected == 1 # префиксный фильтр к Авито не применяется
# ── пред-геокод Авито ────────────────────────────────────────────────────────
#
# Риски этой ветки, которые тут и караулятся:
# * регион возьмут из слага, а не из ответа — Новая Москва (Троицк/Щербинка/
# Коммунарка) уедет в область, хотя это регион 77;
# * область (регион 50) просочится в listings;
# * слаг выпадет из ключа кэша — адреса-тёзки из разных муниципалитетов
# («Центральная ул.,5») схлопнутся в один дом;
# * повторный прогон заново потратит квоту по уже разрешённым адресам.
def _avito(**over: Any) -> dict[str, Any]:
base = _payload(
source="avito",
source_url="https://www.avito.ru/moskva/kvartiry/2-k._kvartira_56_m_56_et._46317006",
address="Варшавское ш.,62к1",
)
base.update(over)
return base
class _Sugg:
"""Минимальный DadataSuggestion — нужны только поля, которые читает отбор."""
def __init__(self, kind: str, lat: float | None, lon: float | None, kladr: str | None) -> None:
self.kind, self.lat, self.lon, self.kladr_id = kind, lat, lon, kladr
self.unrestricted_value = "г Москва, ш Варшавское, д 62 к 1"
def test_slug_comes_from_source_url() -> None:
assert mod.avito_city_slug(_avito()) == "moskva"
himki = _avito(source_url="https://www.avito.ru/himki/kvartiry/1")
assert mod.avito_city_slug(himki) == "himki"
assert mod.avito_city_slug(_avito(source_url="")) is None
assert mod.avito_city_slug({}) is None
def test_cache_key_separates_same_address_in_different_towns() -> None:
"""1110 адресов из 21 570 живут под несколькими слагами — это разные дома."""
assert mod.geo_cache_key("Центральная ул.,5", "moskva") != mod.geo_cache_key(
"Центральная ул.,5", "himki"
)
# нормализация адреса общая с геокодером — регистр и пробелы ключ не множат
assert mod.geo_cache_key(" Варшавское Ш.,62к1 ", "moskva") == mod.geo_cache_key(
"варшавское ш.,62к1", "moskva"
)
def test_moscow_slug_narrows_constraint_others_keep_both_regions() -> None:
"""Не-московский слаг может оказаться Новой Москвой — оба региона в констрейнте."""
assert mod._dadata_regions_for("moskva") == [mod.DADATA_MOSCOW_REGION]
assert mod._dadata_regions_for("moskovskaya_oblast_troitsk") == [
mod.DADATA_MOSCOW_REGION,
mod.DADATA_OBLAST_REGION,
]
assert mod._dadata_regions_for(None) == [
mod.DADATA_MOSCOW_REGION,
mod.DADATA_OBLAST_REGION,
]
def test_region_is_read_from_the_answer_not_from_the_slug() -> None:
"""КЛАДР ответа 77… → регион 77, даже если слаг был подмосковным."""
point = mod._point_from_suggestions([_Sugg("house", 55.6, 37.6, "77000000000000000")])
assert point is not None and point.region_code == 77
oblast = mod._point_from_suggestions([_Sugg("house", 55.9, 37.4, "50000000000000000")])
assert oblast is not None and oblast.region_code == 50
def test_only_house_level_answers_become_points() -> None:
"""Улица/город дают точку в середине улицы — для radius-подбора это хуже, чем ничего."""
assert mod._point_from_suggestions([_Sugg("street", 55.6, 37.6, "77000000000000000")]) is None
assert mod._point_from_suggestions([_Sugg("house", None, None, "77000000000000000")]) is None
# чужой регион (констрейнт пробит) — тоже не берём
assert mod._point_from_suggestions([_Sugg("house", 56.8, 60.6, "66000000000000000")]) is None
def test_budget_stops_asking_instead_of_failing() -> None:
budget = mod.GeocodeBudget(remaining=2)
assert budget.take(5) == 2 and budget.exhausted and budget.spent == 2
assert budget.take(1) == 0 # квота кончилась — просто больше не спрашиваем
def _geo_rows() -> list[dict[str, Any]]:
return [
{"id": 1, "payload": _avito(source_id="1")}, # Москва
{
"id": 2,
"payload": _avito(
source_id="2",
source_url="https://www.avito.ru/himki/kvartiry/2",
address="Ивановская ул.,5",
),
}, # область
{
"id": 3,
"payload": _avito(source_id="3", address="Мкр-н имени В.Н. Махалина, 33"),
}, # не разрешился
]
@pytest.fixture()
def geocoded(monkeypatch: pytest.MonkeyPatch) -> list[list[dict[str, Any]]]:
"""Подменяет резолвер целиком — в сеть и в кэш-таблицу тест не ходит."""
seen: list[list[dict[str, Any]]] = []
def _fake_resolve(db: Any, rows: list[Any], budget: Any, **kwargs: Any) -> dict[str, Any]:
seen.append(list(rows))
return {
mod.geo_cache_key("Варшавское ш.,62к1", "moskva"): mod.GeoPoint(
55.6412, 37.6089, 77, "г Москва, ш Варшавское, д 62 к 1"
),
mod.geo_cache_key("Ивановская ул.,5", "himki"): mod.GeoPoint(
55.8970, 37.4297, 50, "Московская обл, г Химки, ул Ивановская, д 5"
),
}
monkeypatch.setattr(mod, "_resolve_points", _fake_resolve)
return seen
def test_geocode_lets_avito_through_without_allow_unfiltered(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
"""Пред-геокод — штатный путь: аварийный флаг больше не нужен."""
counters = mod.import_msk_raw(
_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_oblast == 1
assert counters.skipped_ungeocoded == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_coordinates_reach_the_writer(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
"""Ради geom всё и затевалось — карточка не должна лечь без координат."""
mod.import_msk_raw(_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True)
lot = captured[0]["lots"][0]
assert (lot.lat, lot.lon) == (55.6412, 37.6089)
assert lot.geo_precision == "house"
assert captured[0]["region_code"] == 77
def test_oblast_is_never_written_as_region_77(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
mod.import_msk_raw(_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True)
written = [lot.source_id for call in captured for lot in call["lots"]]
assert "2" not in written, "химкинская карточка не должна уехать в регион 77"
def test_avito_without_geocode_demands_opt_in(captured: list[dict[str, Any]]) -> None:
rows = [{"id": 1, "payload": _avito()}]
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(rows), source="avito", batch_size=10)
def test_geocode_is_refused_for_cian(captured: list[dict[str, Any]]) -> None:
"""У Циана город виден по префиксу округа даром — квоту на него не тратим."""
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(_rows()), source="cian", batch_size=10, geocode=True)
def test_dry_run_survives_missing_geocode_cache(monkeypatch: pytest.MonkeyPatch) -> None:
"""Первый `--dry-run` идёт по проду, где кэш-таблицы ещё нет.
Таблицу создаёт только боевой прогон, поэтому SELECT по ней в сухом ронял
ровно ту репетицию, ради которой сухой прогон и существует (замер на проде
11.09: UndefinedTable msk_raw.avito_geocode). Резолвер обязан молча считать
кэш пустым и идти к DaData за остатком.
"""
class _NoCacheSession:
def __init__(self) -> None:
self.cache_reads = 0
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _FakeResult:
if "to_regclass" in str(sql):
return _FakeResult([]) # таблицы нет
self.cache_reads += 1
raise AssertionError("к отсутствующей кэш-таблице ходить нельзя")
point = mod.GeoPoint(55.6412, 37.6089, 77, "г Москва, ш Варшавское, д 62 к 1")
async def _fake_geocode(misses: list[Any]) -> dict[str, Any]:
return {misses[0][0]: point}
monkeypatch.setattr(mod, "_geocode_many", _fake_geocode)
db = _NoCacheSession()
points = mod._resolve_points(
db, [{"id": 1, "payload": _avito()}], mod.GeocodeBudget(10), dry_run=True
)
assert db.cache_reads == 0
assert list(points.values()) == [point]

View file

@ -0,0 +1,600 @@
"""#3051: поправка СберИндекса стала регион-зависимой (и монитор — многорядным).
ЧТО БЫЛО НЕ ТАК. sber_price_index ключуется ТЕКСТОВОЙ колонкой city, а оценщик
подставлял в неё константу «Свердловская область» для ЛЮБОЙ сделки включая
212 937 московских (region_code=77, все ДКП). Замер прода 2026-09-10:
средневзвешенный по 69 138 московским сделкам за 12 месяцев time-фактор равен
1.0313 по свердловскому ряду против 1.0917 по московскому московский коридор
занижен на ~5.9%. Коридор не advisory: он участвует в clamp headline.
Ряды в тестах синтетические, но их ОТНОШЕНИЯ взяты из этого замера (1.0313 / 1.0917),
чтобы промах ряда был виден числом, а не только именем.
"""
from __future__ import annotations
import contextlib
import logging
import os
from datetime import UTC, date, datetime
from typing import Any
from unittest.mock import MagicMock
import pytest
from sqlalchemy.exc import OperationalError, PendingRollbackError
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator as est
from app.services import regions as regions_mod
from app.tasks import sber_freshness_monitor as mon
# Харнесс соседей: настоящий sentry-клиент с прод-настройками контейнера скрапера.
# Импортируется, а не копируется, — тревога должна проверяться ТЕМ ЖЕ способом, каким
# её проверяют все соседние алерты (#2674).
from tests.test_alerts_become_events import event_texts, glitchtip_events
_BASE_MONTH = date(2025, 7, 1)
_LATEST_MONTH = date(2026, 7, 1)
# index_value подобран так, чтобы factor = latest/base дал ровно замеренные числа.
_SERIES_ROWS: dict[str, list[dict[str, Any]]] = {
"Свердловская область": [
{"period_month": _BASE_MONTH, "index_value_rub_m2": 100_000.0},
{"period_month": _LATEST_MONTH, "index_value_rub_m2": 103_130.0}, # ×1.0313
],
"Москва": [
{"period_month": _BASE_MONTH, "index_value_rub_m2": 100_000.0},
{"period_month": _LATEST_MONTH, "index_value_rub_m2": 109_170.0}, # ×1.0917
],
"Россия": [
{"period_month": _BASE_MONTH, "index_value_rub_m2": 100_000.0},
{"period_month": _LATEST_MONTH, "index_value_rub_m2": 106_000.0}, # ×1.06
],
}
# ── резолвер ────────────────────────────────────────────────────────────────
def test_resolver_maps_66_to_sverdlovsk() -> None:
assert est.sber_region_series_name(66) == "Свердловская область"
assert est.sber_region_series_name(66) == est.SBER_TIME_ADJUST_REGION
def test_resolver_maps_77_to_moscow() -> None:
"""КРАСНЫЙ НА main: там ряда для 77 нет вовсе, Москва получала свердловский."""
assert est.sber_region_series_name(77) == "Москва"
def test_resolver_unknown_region_falls_back_to_russia_not_sverdlovsk() -> None:
"""Неизвестный регион не падает и НЕ получает молча чужой региональный ряд."""
assert est.sber_region_series_name(99) == "Россия"
assert est.sber_region_series_name(None) == "Россия"
assert est.SBER_TIME_ADJUST_FALLBACK_REGION == "Россия"
def test_resolver_unknown_region_is_logged_not_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger=est.logger.name):
est.sber_region_series_name(1)
assert any("_SBER_REGION_SERIES" in r.getMessage() for r in caplog.records)
def test_series_names_agree_with_region_registry() -> None:
"""Карта заведена явно, но расходиться с реестром регионов она не должна."""
for code, name in est._SBER_REGION_SERIES.items():
assert regions_mod.REGIONS[code].name == name
def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None:
assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | {
est.SBER_TIME_ADJUST_FALLBACK_REGION
}
assert est.SBER_TIME_ADJUST_REGION in est.SBER_REQUIRED_REGIONS
assert "Москва" in est.SBER_REQUIRED_REGIONS
# ── коридор: какой ряд реально читается ─────────────────────────────────────
def _corridor_db(deal_rows: list[dict[str, Any]]) -> tuple[Any, list[str]]:
"""Session-двойник: sber-запрос отвечает ПО ЗАПРОШЕННОМУ region, deals — сделками."""
asked: list[str] = []
db = MagicMock()
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
params = params or {}
result = MagicMock()
sql = str(query)
if "sber_price_index" in sql:
asked.append(params["region"])
result.mappings.return_value.all.return_value = _SERIES_ROWS.get(params["region"], [])
elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql:
result.mappings.return_value.all.return_value = deal_rows
else:
result.mappings.return_value.all.return_value = []
return result
db.execute.side_effect = _execute
return db, asked
_DEALS = [{"price_per_m2": 200_000, "deal_date": _BASE_MONTH}]
def _fetch(region_code: int | None = None) -> tuple[dict[str, Any] | None, list[str]]:
db, asked = _corridor_db(_DEALS)
kwargs: dict[str, Any] = {}
if region_code is not None:
kwargs["region_code"] = region_code
raw = est._fetch_dkp_corridor(
db, address="Москва, улица Тверская, 10", rooms=2, area=60.0, **kwargs
)
return raw, asked
def test_moscow_corridor_reads_the_moscow_series() -> None:
"""КРАСНЫЙ НА main: там спрашивался ряд «Свердловская область»."""
raw, asked = _fetch(77)
assert asked == ["Москва"]
assert raw is not None
# 200 000 × 1.0917
assert abs(raw["median_ppm2"] - 218_340) <= 1
def test_sverdlovsk_corridor_unchanged() -> None:
raw, asked = _fetch(66)
assert asked == ["Свердловская область"]
assert raw is not None
# 200 000 × 1.0313 — ровно то же число, что давал main
assert abs(raw["median_ppm2"] - 206_260) <= 1
def test_default_region_is_still_sverdlovsk_byte_identical() -> None:
"""Вызов без region_code (дефолт 66) обязан дать то же, что явный 66."""
default_raw, default_asked = _fetch(None)
explicit_raw, explicit_asked = _fetch(66)
assert default_asked == explicit_asked == ["Свердловская область"]
assert default_raw == explicit_raw
def test_moscow_corridor_is_higher_than_the_sverdlovsk_one() -> None:
"""Тот самый недобор ~5.9%: направление промаха, а не только имя ряда."""
moscow, _ = _fetch(77)
sverdlovsk, _ = _fetch(66)
assert moscow is not None and sverdlovsk is not None
ratio = moscow["median_ppm2"] / sverdlovsk["median_ppm2"]
assert 1.055 < ratio < 1.065 # 1.0917 / 1.0313 = 1.0586
def test_unknown_region_corridor_uses_russia_series() -> None:
raw, asked = _fetch(99)
assert asked == ["Россия"]
assert raw is not None
assert abs(raw["median_ppm2"] - 212_000) <= 1 # 200 000 × 1.06
# ── монитор: следит за обоими рядами ────────────────────────────────────────
_NOW = datetime(2026, 8, 12, 9, 0, tzinfo=UTC)
_LAST_PULL = datetime(2026, 8, 6, 9, 0, tzinfo=UTC) # 6 суток < порога 14
_JUN_2026 = date(2026, 6, 1)
class _Row:
def __init__(self, **kw: Any) -> None:
self.__dict__.update(kw)
class _Result:
def __init__(self, row: _Row | None) -> None:
self._row = row
def first(self) -> _Row | None:
return self._row
class _MonDB:
"""Отвечает latest-периодом ПО РЕГИОНУ (main спрашивал только один)."""
def __init__(self, latest_by_region: dict[str, date]) -> None:
self._latest = latest_by_region
self.asked_regions: list[str] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
sql = str(stmt)
params = params or {}
if "scrape_runs" in sql:
return _Result(_Row(last_pull=_LAST_PULL))
if "scrape_schedules" in sql:
return _Result(_Row(interval_days="7"))
self.asked_regions.append(params["city"])
latest = self._latest.get(params["city"]) if params["dash"] == "real_estate_deals" else None
return _Result(_Row(latest=latest))
def rollback(self) -> None: # pragma: no cover — не используется
pass
def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
calls: dict[str, Any] = {"done": None, "failed": None}
monkeypatch.setattr(mon.runs_mod, "update_heartbeat", lambda *a, **k: None)
monkeypatch.setattr(
mon.runs_mod, "mark_done", lambda _db, _rid, c: calls.__setitem__("done", dict(c))
)
monkeypatch.setattr(
mon.runs_mod, "mark_failed", lambda _db, _rid, err, _c: calls.__setitem__("failed", err)
)
return calls
@contextlib.contextmanager
def _capture_errors() -> Any:
"""Собрать ERROR-записи монитора (caplog не годится: логгер модульный)."""
records: list[logging.LogRecord] = []
class _H(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
handler = _H(level=logging.ERROR)
mon.logger.addHandler(handler)
try:
yield records
finally:
mon.logger.removeHandler(handler)
def _all_present() -> _MonDB:
return _MonDB({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS})
def test_monitor_asks_every_series_the_estimator_can_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""КРАСНЫЙ НА main: спрашивался ровно один ряд, московский никто не видел."""
_patch_runs(monkeypatch)
db = _all_present()
mon.check_sber_freshness(db, run_id=1, params={}, now=_NOW) # type: ignore[arg-type]
assert set(db.asked_regions) >= set(est.SBER_MONITORED_REGIONS)
def test_monitor_verdict_for_sverdlovsk_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прежние ключи counters и прежние числа: вердикт идёт за тактом загрузки."""
calls = _patch_runs(monkeypatch)
out = mon.check_sber_freshness(_all_present(), run_id=2, params={}, now=_NOW) # type: ignore[arg-type]
assert (out["latest_year"], out["latest_month"]) == (2026, 6)
assert out["age_days"] == 72
assert out["pull_lag_days"] == 6
assert out["max_pull_lag_days"] == 14
assert out["alert"] == 0
assert out["regions_missing"] == 0
assert calls["failed"] is None
def _without_moscow() -> _MonDB:
"""Источник переименовал «Москва» → ряда с прежним именем больше нет."""
return _MonDB({"Свердловская область": _JUN_2026, "Россия": _JUN_2026})
def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пропажа чужого ряда НЕ имеет права отключать мониторинг 66.
КРАСНЫЙ ДО ПРАВКИ: ранний выход с mark_failed срабатывал раньше расчёта, и
свердловская свежесть не считалась вовсе (latest_*=0, age_days=0, alert=0),
хотя ряд был на месте и мог быть протухшим.
"""
calls = _patch_runs(monkeypatch)
out = mon.check_sber_freshness(_without_moscow(), run_id=3, params={}, now=_NOW) # type: ignore[arg-type]
# прежние ключи и прежние числа по свердловскому ряду — как при всех рядах
assert (out["latest_year"], out["latest_month"]) == (2026, 6)
assert out["age_days"] == 72
assert out["pull_lag_days"] == 6
assert out["max_pull_lag_days"] == 14
assert calls["done"] == out
assert calls["failed"] is None
def test_missing_moscow_is_visible_not_silently_green(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Прогон наблюдаем: свой счётчик + ERROR, где назван КОНКРЕТНЫЙ пропавший ряд."""
_patch_runs(monkeypatch)
with _capture_errors() as records:
out = mon.check_sber_freshness(_without_moscow(), run_id=3, params={}, now=_NOW) # type: ignore[arg-type]
assert out["regions_missing"] == 1
assert out["alert_regions_missing"] == 1 # КРАСНЫЙ ДО ПРАВКИ: ключа не было вовсе
assert out["alert"] == 0 # алерт о такте загрузки не перегружен пропажей ряда
msg = " ".join(r.getMessage() for r in records)
assert "Москва" in msg # назван КОНКРЕТНЫЙ пропавший ряд
assert "таблица НЕ пуста" in msg # прежний текст врал про пустую таблицу
def test_missing_sverdlovsk_still_marks_failed_with_truthful_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Прежнее поведение ради которого ранний выход и заведён: считать нечего.
Текст ошибки при этом называет ряд, а не врёт про пустую таблицу.
"""
calls = _patch_runs(monkeypatch)
db = _MonDB({"Москва": _JUN_2026, "Россия": _JUN_2026})
out = mon.check_sber_freshness(db, run_id=6, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["done"] is None
assert calls["failed"] is not None
assert "Свердловская область" in calls["failed"]
# КРАСНЫЙ ДО ПРАВКИ: было "sber_price_index empty or unavailable" — таблица непуста
assert "empty" not in calls["failed"]
assert out["latest_year"] == 0
assert out["alert"] == 0
def test_all_series_present_keeps_old_counters_and_adds_new(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Оба ряда на месте: прежние ключи те же, новые — нулевые."""
calls = _patch_runs(monkeypatch)
out = mon.check_sber_freshness(_all_present(), run_id=7, params={}, now=_NOW) # type: ignore[arg-type]
assert (out["latest_year"], out["latest_month"], out["age_days"]) == (2026, 6, 72)
assert (out["pull_lag_days"], out["max_pull_lag_days"], out["alert"]) == (6, 14, 0)
assert out["regions_missing"] == 0
assert out["alert_regions_missing"] == 0
assert out["age_days_max"] == 72
assert calls["failed"] is None
def test_missing_fallback_series_does_not_fail_the_monitor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой."""
calls = _patch_runs(monkeypatch)
db = _MonDB({"Свердловская область": _JUN_2026, "Москва": _JUN_2026})
out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["failed"] is None
assert out["regions_missing"] == 1
assert out["alert"] == 0
def test_lagging_region_shows_up_in_age_days_max(monkeypatch: pytest.MonkeyPatch) -> None:
"""Отставший ряд виден наблюдением, свердловский age_days при этом не сдвинут."""
_patch_runs(monkeypatch)
db = _MonDB(
{
"Свердловская область": _JUN_2026,
"Москва": date(2026, 3, 1),
"Россия": _JUN_2026,
}
)
out = mon.check_sber_freshness(db, run_id=5, params={}, now=_NOW) # type: ignore[arg-type]
assert out["age_days"] == 72
assert out["age_days_max"] == (_NOW.date() - date(2026, 3, 1)).days
# --- круг 3: изоляция чужих рядов по ИСКЛЮЧЕНИЮ, дедупликация, ранний выход -------
class _MonDBFlaky(_MonDB):
"""Соединение рвётся на ОДНОМ ряде, остальные запросы отвечают как обычно.
Это типовой прод-случай: таймаут/обрыв на середине обхода, а не «БД мертва».
"""
def __init__(self, latest_by_region: dict[str, date], broken_region: str) -> None:
super().__init__(latest_by_region)
self._broken = broken_region
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
params = params or {}
if params.get("city") == self._broken:
raise TimeoutError(f"connection reset while reading {self._broken}")
return super().execute(stmt, params)
def test_probe_failure_on_foreign_series_does_not_suppress_the_verdict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""КРАСНЫЙ ДО ПРАВКИ: обход шёл под ОБЩИМ try.
TimeoutError на московском ряде улетал во внешний except mark_failed и
повторный подъём, вердикт по Свердловской области снова не считался тот же
дефект, что чинили по ветке пустой выборки.
"""
calls = _patch_runs(monkeypatch)
db = _MonDBFlaky({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, "Москва")
with _capture_errors() as records:
out = mon.check_sber_freshness(db, run_id=8, params={}, now=_NOW) # type: ignore[arg-type]
# вердикт по свердловскому ряду посчитан и доехал до done-прогона
assert (out["latest_year"], out["latest_month"], out["age_days"]) == (2026, 6, 72)
assert (out["pull_lag_days"], out["max_pull_lag_days"], out["alert"]) == (6, 14, 0)
assert calls["failed"] is None
assert calls["done"] == out
# и при этом сбой не проглочен: ряд ненаблюдён, назван в ERROR
assert out["regions_missing"] == 1
assert out["alert_regions_missing"] == 1
assert "Москва" in " ".join(r.getMessage() for r in records)
def test_probe_failure_on_the_default_series_still_fails_the_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Обратная сторона: сбой на ряде РЕГИОНА ПО УМОЛЧАНИЮ прячется не должен."""
calls = _patch_runs(monkeypatch)
db = _MonDBFlaky(
{r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, est.SBER_TIME_ADJUST_REGION
)
with pytest.raises(TimeoutError):
mon.check_sber_freshness(db, run_id=9, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["done"] is None
assert calls["failed"] is not None
def test_duplicate_series_name_does_not_pin_regions_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""КРАСНЫЙ ДО ПРАВКИ: regions_missing = len(КОРТЕЖА) - len(найденных).
Заводим в карте оценщика ещё один регион, чей ряд совпал с фолбэчной «Россией»:
кортеж длиннее на элемент, различных рядов столько же счётчик залипал на 1
при всех живых рядах, т.е. вечный «пропал ряд» без пропажи.
"""
calls = _patch_runs(monkeypatch)
required = (*est.SBER_REQUIRED_REGIONS, est.SBER_TIME_ADJUST_FALLBACK_REGION)
monkeypatch.setattr(mon, "SBER_REQUIRED_REGIONS", required)
monkeypatch.setattr(
mon, "SBER_MONITORED_REGIONS", (*required, est.SBER_TIME_ADJUST_FALLBACK_REGION)
)
db = _MonDB({r: _JUN_2026 for r in required})
out = mon.check_sber_freshness(db, run_id=10, params={}, now=_NOW) # type: ignore[arg-type]
assert out["regions_missing"] == 0
assert out["alert_regions_missing"] == 0
assert calls["failed"] is None
def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None:
"""КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули.
Пропажа ДВУХ обязательных рядов была неотличима от пропажи одного свердловского,
а найденный ряд не попадал в age_days_max вовсе.
"""
calls = _patch_runs(monkeypatch)
db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026})
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["done"] is None
assert calls["failed"] is not None
assert out["regions_missing"] == 2
assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0
assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден
# прежняя семантика раннего выхода не тронута
assert (out["latest_year"], out["latest_month"], out["age_days"], out["alert"]) == (0, 0, 0, 0)
# --- круг 4: откат сессии, ERROR до раннего выхода, настоящий канал тревоги -------
class _MonDBDriverFault(_MonDB):
"""Сессия ведёт себя как НАСТОЯЩАЯ: ошибка драйвера деактивирует транзакцию.
Ровно семантика SQLAlchemy Session после сбоя инструкции любой следующий запрос
падает с PendingRollbackError, даже не дойдя до БД, пока кто-нибудь не позовёт
rollback(). _MonDBFlaky этого не показывает (там сбоит ровно спрошенный ряд),
поэтому зелёный тест на нём ничего не доказывал про испорченную сессию.
"""
def __init__(self, latest_by_region: dict[str, date], broken_region: str) -> None:
super().__init__(latest_by_region)
self._broken = broken_region
self._deactivated = False
self.rollbacks = 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result:
if self._deactivated:
raise PendingRollbackError(
"Can't reconnect until invalid transaction is rolled back"
)
params = params or {}
if params.get("city") == self._broken:
self._deactivated = True
raise OperationalError(
"SELECT max(period_month) FROM sber_price_index",
{},
TimeoutError("canceling statement due to statement timeout"),
)
return super().execute(stmt, params)
def rollback(self) -> None:
self.rollbacks += 1
self._deactivated = False
def test_driver_error_on_foreign_series_rolls_the_session_back(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""КРАСНЫЙ ДО ПРАВКИ: в per-region except не было ОТКАТА.
Прошлый круг изолировал распространение исключения, но не порчу сессии. Свердловский
ряд прочитан, на московском таймаут, цикл идёт дальше и первый же запрос ЗА
ИНТЕРВАЛОМ ЗАГРУЗКИ падает с PendingRollbackError на деактивированной сессии,
улетает во внешний except, даёт mark_failed и подъём: вердикт по Свердловской
области снова не доезжает.
"""
calls = _patch_runs(monkeypatch)
db = _MonDBDriverFault({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, "Москва")
with _capture_errors() as records:
out = mon.check_sber_freshness(db, run_id=12, params={}, now=_NOW) # type: ignore[arg-type]
assert db.rollbacks == 1 # ДО ПРАВКИ: 0 — сессия оставалась деактивированной
# вердикт по свердловскому ряду посчитан и доехал до done-прогона
assert (out["latest_year"], out["latest_month"], out["age_days"]) == (2026, 6, 72)
assert (out["pull_lag_days"], out["max_pull_lag_days"], out["alert"]) == (6, 14, 0)
assert calls["failed"] is None
assert calls["done"] == out
# соседний ряд после отката читается как обычно — испорчен был запрос, не БД
assert out["regions_missing"] == 1
assert out["alert_regions_missing"] == 1
assert out["age_days_max"] == 72
assert "Москва" in " ".join(r.getMessage() for r in records)
def test_failed_rollback_is_not_swallowed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Если не прошёл и откат — мертво соединение целиком, а не один запрос.
Молча досчитывать вердикт на такой сессии нельзя: наружу должна уйти ошибка отката.
КРАСНЫЙ ДО ПРАВКИ: отката не было вовсе, наружу уходил PendingRollbackError
следующего запроса.
"""
class _DeadSession(_MonDBDriverFault):
def rollback(self) -> None:
raise OperationalError("ROLLBACK", {}, TimeoutError("connection is closed"))
calls = _patch_runs(monkeypatch)
db = _DeadSession({r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS}, "Москва")
with pytest.raises(OperationalError):
mon.check_sber_freshness(db, run_id=13, params={}, now=_NOW) # type: ignore[arg-type]
assert calls["done"] is None
def test_every_missing_required_series_is_named_even_on_early_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""КРАСНЫЙ ДО ПРАВКИ: блок с ERROR про пропавший обязательный ряд стоял ПОСЛЕ
раннего выхода, т.е. в его ветке не выполнялся НИКОГДА.
Пропали оба обязательных ряда в мониторинг уходило сообщение только про
свердловский, хотя под московским 212 937 сделок региона 77 и чинится он отдельно
(имя ряда в источнике).
"""
calls = _patch_runs(monkeypatch)
db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026})
with _capture_errors() as records:
out = mon.check_sber_freshness(db, run_id=14, params={}, now=_NOW) # type: ignore[arg-type]
msg = " ".join(r.getMessage() for r in records)
assert "Москва" in msg # ДО ПРАВКИ: про 77 не уходило ни одного сообщения
assert est.SBER_TIME_ADJUST_REGION in msg
# прежняя семантика раннего выхода не тронута
assert calls["done"] is None
assert calls["failed"] is not None
assert "Свердловская область" in calls["failed"]
assert out["alert_regions_missing"] == 1
assert (out["latest_year"], out["latest_month"], out["age_days"], out["alert"]) == (0, 0, 0, 0)
def test_missing_required_series_reaches_the_real_alerting_channel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Настоящий потребитель тревоги — СОБЫТИЕ GlitchTip, а не счётчик прогона.
alert_regions_missing не читает ни одно правило алертинга; соседи алертят
ERROR-логом (tests/test_alerts_become_events.py проверяет факт события, а не
levelno) проверяем тем же харнессом и тот же факт.
КРАСНЫЙ ДО ПРАВКИ: на ветке раннего выхода события про «Москва» не было.
"""
_patch_runs(monkeypatch)
db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026})
with glitchtip_events() as events:
out = mon.check_sber_freshness(db, run_id=15, params={}, now=_NOW) # type: ignore[arg-type]
assert any("Москва" in t for t in event_texts(events))
assert out["alert_regions_missing"] == 1

View file

@ -1,7 +1,12 @@
# Локальный сбор SERP Авито по Москве и МО (эпик #2989, трек 1)
# Локальный сбор SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1)
`collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито
(вторичка, Москва + МО) и заливает их в прод-схему `msk_raw`.
`collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито,
Циан или Яндекс.Недвижимости (вторичка, Москва + МО) и заливает их в прод-схему
`msk_raw`. Площадка — ключ `--platform {avito,cian,yandex}` (дефолт `avito`).
Платформо-зависимые куски (URL коридора, счётчик, парс карточек, потолок пагинации,
целевая таблица) вынесены в `PlatformAdapter` / `ADAPTERS` в `collect.py` — общая
часть (бисекция по цене, guard на блок, накопитель/заливка в psql) одна на все три
площадки.
Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**.
Браузер — уже открытый Chrome владельца (подключение по CDP), парсер — импорт из
@ -41,62 +46,186 @@
Больше ничего ставить не нужно: только stdlib + playwright + импорт `scraper_kit`
(путь `packages/scraper-kit/src` скрипт добавляет в `sys.path` сам, от `__file__`).
4. **Миграция под Циан/Яндекс применена на проде** — до первого не-`--dry-run`
прогона `--platform cian` / `--platform yandex` выполнить
`tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql`
(таблицы `msk_raw.cian_cards` в проде пока нет). Путь указан именно так:
каталога `backend/data/sql` в корне репозитория не существует, файл лежит внутри
`tradein-mvp/`. Сборщик проверяет наличие целевой таблицы на старте и падает
сразу, не начиная обход выдачи.
## Запуск (PowerShell)
```powershell
cd D:\prjct\gendesign\tradein-mvp\scripts\local-avito-msk
# 0) сухой прогон: ничего не шлём на прод, карточки пишем в runs\cards-<batch>.csv
# 0) сухой прогон Авито: ничего не шлём на прод, карточки пишем в runs\cards-<batch>.csv
python .\collect.py --dry-run --measure 5
# 0б) то же для Циан
python .\collect.py --platform cian --dry-run --measure 5
# 0в) то же для Яндекса
python .\collect.py --platform yandex --dry-run --measure 5
# 1) обязательный первый прогон — замер (дефолт, 100 загрузок страниц)
python .\collect.py
python .\collect.py --platform cian
python .\collect.py --platform yandex
# 2) полный проход — только явно
python .\collect.py --full --batch-id msk-serp-20260908
python .\collect.py --full --batch-id msk-serp-avito-20260908
python .\collect.py --platform cian --full --batch-id msk-serp-cian-20260908
python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
# 3) продолжить прерванный прогон по сохранённому плану коридоров
python .\collect.py --full --resume --batch-id msk-serp-20260908
python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
```
Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
Дефолт — `--platform avito`.
Ключи: `--delay` (пауза между загрузками, дефолт 8.0 с ±20 % джиттера — сознательно
совпадает с прод-расписаниями `request_delay_sec` 710 с), `--batch-size` (карточек в
одной заливке, дефолт 1000), `--target-count` (целевой размер коридора, дефолт 1500),
`--base-url`, `--batch-id`, `--out-dir`, `--ssh-host/--container/--db-user/--db-name`.
Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками,
дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
`request_delay_sec` 710 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
от `--platform`), `--batch-id` (дефолт `msk-serp-<platform>-<UTC>` — платформа в имени,
чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
`--ssh-host/--container/--db-user/--db-name`.
`AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ —
это адрес браузера владельца, а не площадки; имя переменной оставлено историческим.
## Как режется выдача
Потолок пагинации Авито — 30 страниц по 60 = **1800 объявлений на запрос**. Любой
запрос с `count > 1800` целиком не добирается, поэтому строится план ценовых коридоров:
Обе площадки режутся одной и той же адаптивной бисекцией по цене, но с **разным
потолком пагинации на запрос**:
* читаем счётчик «N объявлений» со страницы 1 (`page-title/count`);
| Платформа | Карточек/страница | Потолок страниц | Потолок объявлений/запрос |
|---|---|---|---|
| Авито | 50 (`AVITO_PAGE_SIZE`) | 30 (`AVITO_MAX_PAGES`) | **1500** |
| Циан | 28 (`_CIAN_OFFERS_PER_PAGE`) | 54 (см. `CianScraper._paginate_leaf_bucket`, "hard cap ~54") | **1512** |
| Яндекс | 20 (`pager.pageSize`) | 25 (`pager.totalPages`, потолок; `page=26` → HTTP 301) | **500** |
Любой коридор, где `count` больше потолка платформы, целиком не добирается, поэтому
строится план ценовых коридоров:
* читаем счётчик найденных объявлений со страницы 1 (Авито — `page-title/count`;
Циан — `results.totalOffers` из Redux-state SSR-страницы, `_extract_total_offers`);
* `count > --target-count` → делим коридор пополам **по геометрической середине**
(`sqrt(lo*hi)`): цены логнормальны, арифметическая середина диапазона 1 млн … 100 млн
даёт вырожденно-пустую верхнюю половину;
* верхняя граница открытого коридора подбирается удвоением от 8 млн ₽;
* предохранители: глубина рекурсии ≤ 12 и минимальная ширина коридора (отношение
границ ≤ 1.05). Если коридор уже узкий, а `count` всё ещё > 1800 — он помечается
`truncated: true` в плане, а в лог и в `msk_raw.batches.notes` пишется, сколько
объявлений заведомо не добрано;
* гео-параметры `radius`/`geoCoords` не используются: сервером они не применяются (#3043).
границ ≤ 1.05). Если коридор уже узкий, а `count` всё ещё больше потолка платформы —
он помечается `truncated: true` в плане, а в лог и в `msk_raw.batches.notes` пишется,
сколько объявлений заведомо не добрано;
* гео-параметры типа `radius`/`geoCoords` не используются: для Авито сервером они не
применяются (#3043); для Циан фильтрация по гео вообще не поддерживается на уровне
SERP (сервер отдаёт весь регион, `region=` — единственный гео-скоуп).
План лежит в `runs/plan-<batch_id>.json` и обновляется после каждой страницы — отсюда
работает `--resume`.
План лежит в `runs/plan-<batch_id>.json` (включает `platform`, чтобы `--resume` не
перепутал план Авито с планом Циан) и обновляется после каждой страницы.
## Яндекс: gate-API вместо разметки
Яндекс.Недвижимость — единственная из трёх площадок, у которой **нет разбора DOM**:
выдача берётся из gate-API и приходит готовым JSON. DOM-парсера у неё нет и в
`scraper_kit`, поэтому адаптер `yandex` в `collect.py` тащит из кита ровно чистые
функции разбора gate-payload (`_parse_gate_json`, `_extract_gate_data`,
`_extract_json_from_content`), а класс `YandexRealtyScraper` не создаёт вовсе: тот
существует ради camoufox-транспорта и пула прокси, а здесь транспорт — вкладка
Chrome владельца, как и у остальных.
Запрос:
```
https://realty.yandex.ru/gate/react-page/get/
?rgid=741964&type=SELL&category=APARTMENT&newFlat=NO
&_pageType=search&_providers=react-search-results-data
&priceMin=<lo>&priceMax=<hi>&page=<1..25>
```
`page` **1-based и проставляется всегда**, в том числе на первой странице:
`page=0` gate считает ошибкой (у Авито/Циан, наоборот, `p` на первой странице
опускается).
### rgid
Снят эмпирически 11.09 из SSR-разметки `realty.yandex.ru` (`"geo":{...,"rgid":...}`
и `page.params`), не из памяти. Счётчики — `pager.totalItems` живого gate-запроса
по вторичке:
| Скоуп | rgid | Офферов вторички |
|---|---|---|
| Москва | 587795 | 18 705 |
| Московская область | 587654 | 14 242 |
| **Москва и МО (дефолт)** | **741964** | **31 074** |
| Екатеринбург (`_EKB_RGID`, контроль) | 559132 | 4 060 |
Дефолт — 741964: единый скоуп «Москва и МО», прямой аналог `region=-1` у Циан и
`moskva_i_mo` у Авито. Другой скоуп задаётся через `--base-url` с нужным `rgid`.
### Границы коридора включающие с обеих сторон
Замер 11.09 (Москва+МО): `priceMax=5000000` отдаёт выдачу с максимальной ценой
ровно 5 000 000 (`totalItems=2998`), `priceMin=5000000&priceMax=5000000` — 1279
(округлая цена популярна у нижней границы рынка), `priceMin=5000000&priceMax=5100000`
— 1377 = 1279 + 98. То есть **обе границы включающие**. Соседние коридоры бисекции
`(lo, mid)` и `(mid, upper)` поэтому пересекаются ровно по цене `mid`: дубли гасит
`ON CONFLICT (source_id,batch_id,kind) DO NOTHING`, потерь между коридорами нет —
ровно как у Авито и Циан.
### Потолок пагинации — 500, а не 1500
`pager` отдаёт `pageSize=20` и `totalPages=25` даже при `totalItems=31 074`;
`page=26` отвечает HTTP 301. То есть на один набор фильтров Яндекс отдаёт максимум
**500 офферов** — втрое меньше Авито и Циан. Поэтому у адаптера свой
`default_target = 500`: цель коридора не может быть выше потолка площадки, иначе
бисекция штатно оставляла бы недобранные коридоры. Полный проход Москва+МО — это
≥ 62 коридора по построению, и в среднем их больше, так что план у Яндекса заметно
дробнее цианского.
### Блок
Капча Яндекса приходит HTML-страницей на месте JSON, HTTP-статус при этом обычный.
`_yandex_detect_block` смотрит первые 4 КБ на `smartcaptcha`/`showcaptcha`/`captcha`,
затем — извлекается ли из документа JSON вообще. Вторая сеть общая с остальными
площадками: `parse_page` останавливает прогон, если нет ни счётчика, ни карточек.
Ждать готовности нечего — это текстовый документ, `_yandex_wait_ready` ждёт `<pre>`
три секунды и не тратит полный таймаут на селекторы, которых тут не бывает.
Chrome экранирует в тексте `<pre>` символы `&`, `<`, `>`; `_unescape_pre_text`
разворачивает их обратно **до** `json.loads`, иначе описания приезжают с `&amp;`.
### Координаты
`location.point.latitude/longitude` заполнены **у 100 %** карточек (замер на живой
выдаче), плюс `geocoderAddress` с номером дома. Геокодировать Яндекс, в отличие от
Авито (`lat`/`lon` пусты у всех 50 335 карточек), не требуется.
## Стоп на первом признаке блока
Проверки в фиксированном порядке, первое срабатывание = немедленный стоп
(никаких ретраев и никакого «продолжим со следующего коридора»):
| # | Признак | Причина в `notes` |
|---|---|---|
| 1 | HTTP 403 / 439 | `platform` |
| 2 | HTTP 429 | `ratelimit` |
| 3 | `_is_firewall_page(html)` — «доступ ограничен», «проблема с ip», `firewall-container` | `firewall` |
| 4 | `startpow` / «доступ ограничен: проверка безопасности» в первых 4 КБ | `challenge` |
| 5 | 0 карточек при ненулевом счётчике (DOM-drift или тихий блок) | `empty_page` |
| # | Признак | Причина в `notes` | Платформа |
|---|---|---|---|
| 1 | HTTP 403 / 439 | `platform` | обе |
| 2 | HTTP 429 | `ratelimit` | обе |
| 3 | `_is_firewall_page(html)` — «доступ ограничен», «проблема с ip», `firewall-container` | `firewall` | avito |
| 4 | `startpow` / «доступ ограничен: проверка безопасности» в первых 4 КБ | `challenge` | avito |
| 5 | 0 карточек при ненулевом счётчике (DOM-drift или тихий блок) | `empty_page` | обе |
| 6 | три подряд таймаута навигации (`Blocked("nav_timeout")` в `Loader._goto`) | `nav_timeout` | обе |
Для Циан текстовых маркеров блока (аналог п.3/4) пока нет — не проверялись живым
прогоном (задание такого прогона и не предполагало). Блок на Циан обычно всплывает как
проваленное извлечение Redux-state (`extract_state` вернул `None` — капча/смена
вёрстки), что уже покрыто общим `empty_page` (0 карточек при ненулевом счётчике) и
стопом `счётчик не прочитался` при `count is None` во время построения плана. Если на
практике всплывёт свой текстовый маркер блока Циан — его место в `_cian_detect_block`
в `collect.py`.
При стопе: недоотправленный батч дозаливается, у батча проставляются `finished_at` и
`notes`, скрипт выходит с кодом **2**.
@ -107,24 +236,33 @@ python .\collect.py --full --resume --batch-id msk-serp-20260908
увеличить `--delay`. `ratelimit` — темп слишком высокий: `--delay 15` и выше.
`empty_page` — сначала посмотреть сохранённую страницу в браузере: если выдача
рисуется, значит уехал DOM и чинить надо парсер в `scraper-kit`, а не скрипт.
`nav_timeout`**не признак бана**: страница трижды подряд не догрузилась за таймаут.
Проверить сеть/VPN и саму вкладку в браузере владельца (жива ли, не висит ли диалог),
при необходимости поднять `--delay`. Циан-страницы тяжелее авитовских (SSR c Redux-state),
поэтому там `nav_timeout` вероятнее — это ожидаемо, паузы на часы он не требует.
После разбора — `--resume` с тем же `--batch-id`, уже собранное не потеряется.
## Куда пишем
Схема `msk_raw` на проде (создана заранее):
Схема `msk_raw` на проде:
* `msk_raw.batches(batch_id PK, kind, query, started_at, finished_at, rows_sent, rows_new, notes, uploaded_at)`
— общая для всех платформ (батч различается только по `batch_id`).
* `msk_raw.avito_cards(id, source_id, observed_at, batch_id → batches, kind, url, price, payload, UNIQUE(source_id,batch_id,kind))`
* `msk_raw.avito_latest` — вью `DISTINCT ON (source_id) … ORDER BY source_id, observed_at DESC`
* `msk_raw.cian_cards`**та же схема**, что и `avito_cards` (тот же набор колонок и
тот же `UNIQUE(source_id,batch_id,kind)`); таблица создаётся отдельной SQL-миграцией
(database-expert) до первого прогона `--platform cian` — этот скрипт DDL не запускает.
* `msk_raw.avito_latest` / `msk_raw.cian_latest` — вью `DISTINCT ON (source_id) …
ORDER BY source_id, observed_at DESC` (аналогично `cian_latest`, тоже отдельная миграция).
Форма заливки: поток в
`ssh <host> "docker exec -i tradein-postgres psql -U tradein -d tradein -v ON_ERROR_STOP=1 -f -"`.
Внутри одной транзакции: `INSERT` батча (`ON CONFLICT DO NOTHING` — строка обязана
существовать до карточек из-за FK), `CREATE TEMP TABLE _stg (LIKE msk_raw.avito_cards
существовать до карточек из-за FK), `CREATE TEMP TABLE _stg (LIKE msk_raw.<table>
INCLUDING DEFAULTS)`, `\copy _stg (...) FROM STDIN WITH (FORMAT csv)`, затем
`INSERT … SELECT` в `avito_cards` с `ON CONFLICT (source_id,batch_id,kind) DO NOTHING`
и `UPDATE batches SET rows_sent/rows_new` (`rows_new` = разница `count(*)` по batch_id
до и после вставки).
`INSERT … SELECT` в `msk_raw.<table>` (`avito_cards` или `cian_cards`, по `--platform`)
с `ON CONFLICT (source_id,batch_id,kind) DO NOTHING` и `UPDATE batches SET
rows_sent/rows_new` (`rows_new` = разница `count(*)` по batch_id до и после вставки).
Одиночный `psql -c` через ssh ломается на квотинге скобок и кавычек — поэтому только поток.
@ -139,9 +277,37 @@ ssh selectel "docker exec -i tradein-postgres psql -U tradein -d tradein -c \"SE
ssh selectel "docker exec -i tradein-postgres psql -U tradein -d tradein -c \"SELECT batch_id, rows_sent, rows_new, started_at, finished_at, notes FROM msk_raw.batches ORDER BY uploaded_at DESC LIMIT 5;\""
```
## Про фильтр городов
## Про фильтр городов и площадок
Парсер конструируется как `AvitoScraper(SimpleNamespace(avito_serp_ekb_only=False),
Авито: парсер конструируется как `AvitoScraper(SimpleNamespace(avito_serp_ekb_only=False),
target_city_slug="moskva")`. `avito_serp_ekb_only=False` **обязателен**: с `True`
`_parse_html` выбрасывает всё, у чего в URL нет `/ekaterinburg/`, включая все
подмосковные слаги — из московской выдачи не осталось бы ничего.
подмосковные слаги — из московской выдачи не осталось бы ничего. URL берётся с
готовым слагом `vtorichka` — фильтр «только вторичка» задан самим URL.
Циан: `CianScraper(SimpleNamespace(glitchtip_dsn=None))`. Домен URL — `www.cian.ru`,
базовый скоуп — `region=-1` (Москва и МО **одним** параметром) плюс `object_type[0]=1`
(вторичка). Два параметра `region=` не объединяются — сервер берёт последний, поэтому
прежняя пара `region=1&region=4593` отдавала только область. Живой замер 10.09 по
`region=-1` + `object_type[0]=1`: **62 548 объявлений**. У `_build_url` из
`CianScraper` домен захардкожен под ЕКБ (`self.base_url = "https://ekb.cian.ru"`),
поэтому URL коридора строит свой билдер `_cian_build_url` в `collect.py`, а не метод
скрапера — используются только чистые парс-функции скрапера (`_extract_total_offers`,
`_parse_serp_html`), которым домен запроса не важен.
Вторичка у Циан задана в URL (`object_type[0]=1`), и **постфильтра новостроек в
адаптере нет** — это сознательное отличие от кита. `CianScraper` внутри
`_paginate_leaf_bucket`/`fetch_around` (эти методы скрипт не вызывает) выбрасывает всё,
у чего есть `offer.newbuilding.id`, считая SERP-параметр `object_type=1` ненадёжным
(`serp.py:401-402`). Для ЕКБ, где выдача бралась без этого параметра, так и было
правильно. Здесь — нет.
Замер живой страницы 10.09: из 28 карточек 16 имеют блок `newbuilding`, и у всех
шестнадцати `isFromBuilder=false`, `isFromLeadFactory=false`, `flatType=rooms`, а ЖК —
«Пригород Лесное», «РУСИЧ Новые Котельники» и подобные. Это обычная вторичка в новых
домах, а не лоты застройщика: `newbuilding.id` означает «дом в ЖК». С китовым фильтром
прогон терял бы **57 % корпуса**, причём безвозвратно.
`msk_raw` — сырьё: `payload` несёт `listing_segment` целиком, поэтому сегмент отделяется
позже, на импорте в `listings`. Адаптер только считает карточки со ссылкой на ЖК —
`scraper.last_nb_ref` попадает в `notes` как `nb_ref=N`.

File diff suppressed because it is too large Load diff