Москва: пред-геокод Авито, Яндекс третьей площадкой, продукт отвечает по региону 77 #3440
22 changed files with 5491 additions and 1017 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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-*/
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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®ion_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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
157
tradein-mvp/backend/app/services/deal_city_key.py
Normal file
157
tradein-mvp/backend/app/services/deal_city_key.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
685
tradein-mvp/backend/app/tasks/msk_raw_import.py
Normal file
685
tradein-mvp/backend/app/tasks/msk_raw_import.py
Normal 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()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
132
tradein-mvp/backend/tests/test_3051_estimate_region_scope.py
Normal file
132
tradein-mvp/backend/tests/test_3051_estimate_region_scope.py
Normal 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
|
||||
483
tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py
Normal file
483
tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py
Normal 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)
|
||||
181
tradein-mvp/backend/tests/test_3051_suggest_region_scope.py
Normal file
181
tradein-mvp/backend/tests/test_3051_suggest_region_scope.py
Normal 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)
|
||||
|
|
@ -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='Екатеринбург' обязана резолвиться в Берёзовский
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
402
tradein-mvp/backend/tests/test_msk_raw_import.py
Normal file
402
tradein-mvp/backend/tests/test_msk_raw_import.py
Normal 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]
|
||||
600
tradein-mvp/backend/tests/test_sber_region_series_3051.py
Normal file
600
tradein-mvp/backend/tests/test_sber_region_series_3051.py
Normal 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
|
||||
|
|
@ -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` 7–10 с), `--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` 7–10 с), `--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`, иначе описания приезжают с `&`.
|
||||
|
||||
### Координаты
|
||||
|
||||
`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®ion=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
Loading…
Add table
Reference in a new issue