diff --git a/.gitignore b/.gitignore index 1990723a..9bde812c 100644 --- a/.gitignore +++ b/.gitignore @@ -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-*/ diff --git a/_wt-mskcol/tradein-mvp/scripts/local-avito-msk/collect.py b/_wt-mskcol/tradein-mvp/scripts/local-avito-msk/collect.py deleted file mode 100644 index 5b0f2461..00000000 --- a/_wt-mskcol/tradein-mvp/scripts/local-avito-msk/collect.py +++ /dev/null @@ -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-)") - 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()) diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index 99ecfd9b..c1f18b9f 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -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( diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 9f436231..35406e73 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -2789,15 +2789,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): город раньше резолвился модой @@ -2820,7 +3011,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), @@ -2832,8 +3031,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 км.""" @@ -2849,23 +3139,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 diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index 1f68abf7..7c581c22 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -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, diff --git a/tradein-mvp/backend/app/services/deal_city_key.py b/tradein-mvp/backend/app/services/deal_city_key.py new file mode 100644 index 00000000..39f27e8e --- /dev/null +++ b/tradein-mvp/backend/app/services/deal_city_key.py @@ -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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 135fc5f6..8556f6ec 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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), @@ -2012,8 +2105,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 @@ -2025,13 +2118,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} """ ), { @@ -2055,7 +2147,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 — @@ -2093,11 +2188,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 @@ -2107,8 +2202,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} """ ), { @@ -2165,7 +2259,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, чтобы коридор был # устойчив к выбросам (один нерыночный ДКП не сдвигает границу). @@ -4203,6 +4297,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, @@ -4260,10 +4376,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, @@ -4282,8 +4403,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", ) @@ -6672,6 +6802,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_REQUIRED = _CityFallbackRequired() + + def _is_plausible_deal( price_per_m2: float | None, floor: int | None, @@ -6681,6 +6841,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 если ДКП-сделка правдоподобна. @@ -6695,13 +6856,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: @@ -6728,11 +6908,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 @@ -6771,20 +6955,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 diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index b882558c..6d29ff8b 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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 [] diff --git a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py index 882bbe0f..96ab17ae 100644 --- a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py +++ b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py @@ -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 diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py new file mode 100644 index 00000000..5382c0d6 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py @@ -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//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//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//kvartiry/...` → ``. + + Единственный признак населённого пункта, который Авито отдаёт честно и + даром. Регион из него НЕ выводится (см. докстринг модуля) — он лишь сужает + констрейнт геокодера. + """ + 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() diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index cde46a41..64be8991 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -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( diff --git a/tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql b/tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql new file mode 100644 index 00000000..265bfde9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql @@ -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//). +-- * 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. 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; diff --git a/tradein-mvp/backend/tests/test_3051_estimate_region_scope.py b/tradein-mvp/backend/tests/test_3051_estimate_region_scope.py new file mode 100644 index 00000000..9d7dfcb2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_estimate_region_scope.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py b/tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py new file mode 100644 index 00000000..f9ff2a76 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_moscow_okrug_bands.py @@ -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) diff --git a/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py b/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py new file mode 100644 index 00000000..5b251ce6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py @@ -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) diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py index 266af904..4a6b4df1 100644 --- a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -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='Екатеринбург' обязана резолвиться в Берёзовский diff --git a/tradein-mvp/backend/tests/test_estimator_client_coords.py b/tradein-mvp/backend/tests/test_estimator_client_coords.py index 80411698..6122c3ef 100644 --- a/tradein-mvp/backend/tests/test_estimator_client_coords.py +++ b/tradein-mvp/backend/tests/test_estimator_client_coords.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py index aa7d5f6b..db037064 100644 --- a/tradein-mvp/backend/tests/test_geocoder_city_hint.py +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -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 ( diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py new file mode 100644 index 00000000..4ed0a2d7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_msk_raw_import.py @@ -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] diff --git a/tradein-mvp/backend/tests/test_sber_region_series_3051.py b/tradein-mvp/backend/tests/test_sber_region_series_3051.py new file mode 100644 index 00000000..85301f26 --- /dev/null +++ b/tradein-mvp/backend/tests/test_sber_region_series_3051.py @@ -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 diff --git a/tradein-mvp/scripts/local-avito-msk/README.md b/tradein-mvp/scripts/local-avito-msk/README.md index 499a22b9..72d581ce 100644 --- a/tradein-mvp/scripts/local-avito-msk/README.md +++ b/tradein-mvp/scripts/local-avito-msk/README.md @@ -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-.csv +# 0) сухой прогон Авито: ничего не шлём на прод, карточки пишем в runs\cards-.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--` — платформа в имени, +чтобы 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-.json` и обновляется после каждой страницы — отсюда -работает `--resume`. +План лежит в `runs/plan-.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=&priceMax=&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` ждёт `
`
+три секунды и не тратит полный таймаут на селекторы, которых тут не бывает.
+
+Chrome экранирует в тексте `
` символы `&`, `<`, `>`; `_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  "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.
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.
` (`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`. diff --git a/tradein-mvp/scripts/local-avito-msk/collect.py b/tradein-mvp/scripts/local-avito-msk/collect.py index db6ddb6a..1a349360 100644 --- a/tradein-mvp/scripts/local-avito-msk/collect.py +++ b/tradein-mvp/scripts/local-avito-msk/collect.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 -"""Локальный ручной сборщик SERP Авито по Москве и МО (эпик #2989, трек 1). +"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1). Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца (подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает. +Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) — см. +класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой +билдер URL коридора и своя целевая таблица в msk_raw. + Дефолтный режим — --measure 100 (замер): полный проход только по явному --full. """ @@ -20,6 +24,7 @@ import math import os import random import re +import shlex import subprocess import sys import time @@ -27,10 +32,10 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace -from typing import Any, Iterable +from typing import Any, Callable, Iterable from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -# --- импорт парсера из scraper-kit без установки backend ------------------- +# --- импорт парсеров из 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)) @@ -39,16 +44,99 @@ from scraper_kit.providers.avito.serp import ( # noqa: E402 AvitoScraper, _is_firewall_page, ) +from scraper_kit.providers.cian.serp import ( # noqa: E402 + CianScraper, + _CIAN_OFFERS_PER_PAGE, +) +# Яндекс ходит в gate-API и отдаёт JSON, а не SERP-разметку: DOM-парсера у него нет +# и тащить его сюда нечего. Из кита берём ровно чистые функции разбора gate-payload — +# класс YandexRealtyScraper не нужен (он существует ради BrowserFetcher/camoufox и +# пула прокси, а здесь транспорт — вкладка Chrome владельца). +from scraper_kit.providers.yandex.serp import ( # noqa: E402 + _extract_gate_data, + _extract_json_from_content, + _is_gate_error, + _parse_gate_json, +) # Вкладка, открытая у владельца: вторичка, Москва + МО. -DEFAULT_BASE_URL = ( +DEFAULT_AVITO_BASE_URL = ( "https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg" "?f=ASgBAgICA0SSA8YQ5geMUuDC3c0CgOkP" ) -PAGE_SIZE = 60 # карточек на странице выдачи -MAX_PAGES = 30 # потолок пагинации Авито → 30*60 = 1800 на один запрос -HARD_CAP = PAGE_SIZE * MAX_PAGES +# domain www.cian.ru, а не ekb.cian.ru: у CianScraper._build_url домен захардкожен +# под ЕКБ (self.base_url класс-константа), сюда не подходит — поэтому URL +# коридора строим своим билдером (_cian_build_url), а не scraper._build_url. +# +# Параметры проверены живым запросом 10.09 (curl, и totalOffers из SSR): +# region=1 -> «Купить квартиру в Москве — 92 817» +# region=4593 -> «Купить квартиру в Московской области — 60 231» +# region=1®ion=4593 -> «Московская область — 60 231»: побеждает ПОСЛЕДНИЙ, +# объединения по нескольким region= НЕТ +# region=-1 -> «Москва и Московская область — 153 049» +# region=-1 + object_type[0]=1 -> «Москва и МО — 62 548», puid3=sale_type_second +# region=1 + object_type[0]=1 -> «Москва — 36 743» +# Отсюда region=-1 — единственный способ получить единый скоуп Москва+МО. +# object_type[0]=1 (вторичка) обязателен: без него totalOffers считает и +# новостройки, которые парсер выбрасывает, — счётчик и карточки расходятся вдвое. +DEFAULT_CIAN_BASE_URL = ( + "https://www.cian.ru/cat.php?deal_type=sale&engine_version=2&offer_type=flat" + "®ion=-1&object_type%5B0%5D=1&sort=creation_date_desc" +) + +# Яндекс.Недвижимость: gate-API (JSON), а не HTML-выдача. +# +# rgid установлен ЭМПИРИЧЕСКИ 11.09, а не взят из памяти: в SSR-разметке +# realty.yandex.ru/moskva/kupit/kvartira/vtorichniy-rynok/ лежит +# "geo":{"id":213,"type":"CITY","rgid":587795,...,"name":"Москва", +# "parents":[{"id":1,"name":"Москва и МО","rgid":"741964",...}]} +# и там же page.params = {"rgid":587795,"type":"SELL","category":"APARTMENT", +# "newFlat":"NO"} — ровно параметры gate-запроса. Аналогично со страницы МО снят +# rgid 587654 ("Московская область", SUBJECT_FEDERATION). +# +# Счётчики вторички gate-API (pager.totalItems, живой запрос 11.09): +# rgid=587795 (Москва) 18 705 +# rgid=587654 (МО) 14 242 +# rgid=741964 (Москва и МО) 31 074 +# rgid=559132 (ЕКБ, _EKB_RGID) 4 060 ← контроль: московский скоуп в 7.6 раза больше +# Дефолт — 741964: единый скоуп «Москва и МО», как region=-1 у Циан и +# moskva_i_mo у Авито. +_YANDEX_MSK_RGID = 587795 +_YANDEX_MO_RGID = 587654 +_YANDEX_MSK_MO_RGID = 741964 + +DEFAULT_YANDEX_BASE_URL = ( + "https://realty.yandex.ru/gate/react-page/get/" + f"?rgid={_YANDEX_MSK_MO_RGID}&type=SELL&category=APARTMENT&newFlat=NO" + "&_pageType=search&_providers=react-search-results-data" +) + +# Замерено живым проходом (не из документации): выдача Москва+МО отдаёт 50 карточек +# на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал +# последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных. +# Молчаливое усечение читается как «покрыто всё», поэтому число проверяется живьём. +AVITO_PAGE_SIZE = 50 # карточек на странице выдачи Авито +AVITO_MAX_PAGES = 30 # потолок пагинации Авито → 30*50 = 1500 на один запрос + +# Циан отдаёт ~28 офферов/страницу (_CIAN_OFFERS_PER_PAGE в providers/cian/serp.py). +# Потолок страниц на бакет — 54, см. CianScraper._paginate_leaf_bucket +# (max_pages_per_bucket: int = 54, комментарий "Cian hard cap ~54; не превышать"). +# 28*54 = 1512 — заметно меньше авитовских 1800, поэтому свой HARD_CAP обязателен. +CIAN_PAGE_SIZE = _CIAN_OFFERS_PER_PAGE +CIAN_MAX_PAGES = 54 + +# Замерено живым gate-запросом 11.09, не взято из кита: pager отдаёт +# pageSize=20 и totalPages=25 ДАЖЕ когда totalItems=31 074 (то есть 1554 +# страницы по построению). page=26 отвечает HTTP 301 — редирект, не выдача. +# Значит жёсткий потолок Яндекса = 25*20 = 500 офферов на один набор фильтров, +# втрое ниже авитовского и цианского. Константа _GATE_MAX_PAGES_CAP=50 в +# scraper_kit — исторический запас, живьём недостижима. +YANDEX_PAGE_SIZE = 20 +YANDEX_MAX_PAGES = 25 +# Целевой размер коридора не может превышать hard_cap площадки, иначе бисекция +# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated. +YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500 PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽ PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы @@ -65,6 +153,10 @@ _POW_MARKERS = ("startpow", "доступ ограничен: проверка PlaywrightTimeoutError: type[BaseException] = TimeoutError +# Через столько загрузок вкладка сборщика пересоздаётся (см. _recycle_if_needed). +PAGE_RECYCLE_EVERY = 75 + + class Blocked(Exception): """Первый признак блока. Ретраев нет — только немедленный стоп.""" @@ -112,10 +204,10 @@ class Corridor: status=d.get("status") or "pending", missed=int(d.get("missed") or 0), ) - def planned_pages(self) -> int: + def planned_pages(self, page_size: int, max_pages: int) -> int: if not self.count: return 1 - return max(1, min(MAX_PAGES, math.ceil(self.count / PAGE_SIZE))) + return max(1, min(max_pages, math.ceil(self.count / page_size))) @dataclass @@ -123,6 +215,7 @@ class Plan: base_url: str target: int batch_id: str + platform: str = "avito" # старые планы (до --platform) читаются как avito corridors: list[Corridor] = field(default_factory=list) created_at: str = "" @@ -131,7 +224,8 @@ class Plan: json.dumps( { "version": 1, "base_url": self.base_url, "target": self.target, - "batch_id": self.batch_id, "created_at": self.created_at, + "batch_id": self.batch_id, "platform": self.platform, + "created_at": self.created_at, "corridors": [c.to_json() for c in self.corridors], }, ensure_ascii=False, indent=1, @@ -144,13 +238,14 @@ class 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"], + platform=d.get("platform", "avito"), 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.""" +def _avito_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"}] @@ -165,6 +260,26 @@ def build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str: ) +def _cian_build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str: + """URL коридора Циан: minprice/maxprice + пагинация (аналог _avito_build_url). + + Названия ключей — как в CianScraper._build_url (minprice/maxprice/p), но домен + и мульти-регион берём из base_url как есть, а не пересобираем через scraper. + """ + parts = urlsplit(base_url) + q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) + if k not in {"p", "minprice", "maxprice"}] + if lo is not None: + q.append(("minprice", str(int(lo)))) + if hi is not None: + q.append(("maxprice", 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: """Геометрическая середина коридора. @@ -183,20 +298,314 @@ def width_ratio(lo: int | None, hi: int | None) -> float: return float(hi) / max(float(lo or PRICE_FLOOR), 1.0) +# --- платформенные адаптеры -------------------------------------------------- + + +def _avito_detect_block(html: str) -> tuple[str, str] | None: + """Доп. текстовые маркеры блока Авито поверх общих HTTP-проверок в _guard. + + Порядок (firewall перед PoW) идентичен исходному _guard — регресс-нейтрально. + """ + if _is_firewall_page(html): + return "firewall", "firewall-страница на HTTP 200" + head = html[:4096].lower() + if any(m in head for m in _POW_MARKERS): + return "challenge", "PoW / проверка безопасности" + return None + + +async def _avito_wait_ready(page: Any) -> None: + """Карточка Авито — обычный DOM-узел, ждём её появления.""" + try: + await page.wait_for_selector('[data-marker="item"]', timeout=7_000) + except Exception: # noqa: BLE001 — пустая/блочная страница разбирается ниже + pass + + +async def _cian_wait_ready(page: Any) -> None: + """У Циан карточки читаются не из DOM, а из window._cianConfig['frontend-serp'], + поэтому ждать `[data-marker="item"]` (авитовский селектор) бессмысленно: он не + появится никогда, и каждая загрузка стоила бы лишних 7 с таймаута. Ждём сам + state — ровно то, что потом парсится. + """ + try: + await page.wait_for_function("!!window._cianConfig", timeout=10_000) + except Exception: # noqa: BLE001 — пустая/блочная страница разбирается ниже + pass + + +def _cian_detect_block(html: str) -> tuple[str, str] | None: + """Капча Циан: HTTP 200 и обычная с виду страница, но без выдачи. + + Поймана живьём 10.09: тот же IP, что нормально отдаёт SERP браузеру и curl, + на голом http-клиенте получил 40 КБ с <title>Captcha - база объявлений ЦИАН + и без window._cianConfig вообще. То есть блок Циан НЕ приходит ни 403, ни 429 — + только телом, и по HTTP-статусу его не отличить. + + Маркер берём в первых 4 КБ: в блок-странице слово встречается там 4 раза + (title + текст), в нормальной выдаче на 2.6 МБ — ни разу (все восемь вхождений + лежат глубоко в скриптах). Ложных срабатываний на живой выдаче нет. + + Вторая сеть на случай другой формы блока — parse_page: count is None и сырых + карточек 0 тоже даёт Blocked("challenge"). + """ + if "captcha" in html[:4096].lower(): + return "challenge", "капча Циан (HTTP 200, страница без выдачи)" + return None + + +def _cian_parse_cards(scraper: CianScraper, html: str) -> list[Any]: + """Парс карточек Циан. Ничего не отбрасываем, только считаем ссылки на ЖК. + + Кит фильтрует новостройки по `offer.newbuilding.id` (serp.py:401-402, + listing_segment == "novostroyki"), считая SERP-параметр object_type=1 + ненадёжным. Для ЕКБ, где выдача бралась без него, это было верно, здесь — + нет: базовый URL уже несёт object_type[0]=1, и Циан подтверждает скоуп + сам (puid3=sale_type_second). + + Замер живой страницы 10.09: из 28 карточек 16 имеют блок newbuilding, и у + ВСЕХ шестнадцати isFromBuilder=false, isFromLeadFactory=false, flatType=rooms, + а ЖК — «Пригород Лесное», «РУСИЧ Новые Котельники» и подобные. То есть это + обычная вторичка в новых домах, а не продажа от застройщика: newbuilding.id + означает «дом в ЖК», а не «лот застройщика». + + С фильтром прогон терял бы 57% корпуса безвозвратно, а msk_raw — сырьё: + payload несёт listing_segment целиком, и отделить сегмент можно потом, на + импорте в listings. Поэтому здесь только счётчик last_nb_ref для notes. + """ + lots = scraper._parse_serp_html(html) + # Сырое число карточек — по нему parse_page отличает блок от пустой страницы. + scraper.last_raw_count = len(lots) + nb_ref = sum(1 for lot in lots + if getattr(lot, "listing_segment", None) == "novostroyki") + if nb_ref: + scraper.last_nb_ref = getattr(scraper, "last_nb_ref", 0) + nb_ref + return lots + + +# --- Яндекс: gate-API (JSON), общего с DOM-платформами только конвейер --------- + +# Chrome отдаёт JSON-документ как текстовый узел внутри
, а сериализатор
+# экранирует в тексте ровно три символа. Разэкранируем их обратно ДО json.loads:
+# иначе описания приезжают с &/< вместо & и <. Порядок обязателен —
+# "&" последним, иначе "&lt;" из описания превратился бы в "<".
+def _unescape_pre_text(text: str) -> str:
+    return text.replace("<", "<").replace(">", ">").replace("&", "&")
+
+
+def _yandex_payload(scraper: Any, html: str) -> dict[str, Any] | None:
+    """gate-payload из содержимого вкладки, с мемоизацией на один HTML.
+
+    parse_page дёргает сначала extract_total_count, потом parse_cards — оба по
+    одной и той же строке. JSON тут на сотни КБ, поэтому разбираем один раз и
+    кладём на scraper вместе с самой строкой. Сравнение — `is`, и ссылка на
+    строку хранится: по id() было бы неверно, id освобождённой строки может
+    достаться следующей.
+    """
+    if getattr(scraper, "_payload_html", None) is html:
+        return scraper._payload
+    json_text = _extract_json_from_content(html)
+    payload: dict[str, Any] | None = None
+    if json_text:
+        try:
+            payload = json.loads(_unescape_pre_text(json_text))
+        except (json.JSONDecodeError, ValueError):
+            payload = None
+    if payload is not None and _is_gate_error(payload):
+        payload = None
+    scraper._payload_html = html
+    scraper._payload = payload
+    return payload
+
+
+def _yandex_build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
+    """URL коридора Яндекса: priceMin/priceMax + page (1-based).
+
+    page=0 gate-API считает ошибкой, поэтому page проставляется ВСЕГДА, в том
+    числе на первой странице, — в отличие от Авито/Циан, где `p` на первой
+    странице опускается.
+    """
+    parts = urlsplit(base_url)
+    q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
+         if k not in {"page", "priceMin", "priceMax"}]
+    if lo is not None:
+        q.append(("priceMin", str(int(lo))))
+    if hi is not None:
+        q.append(("priceMax", str(int(hi))))
+    q.append(("page", str(max(1, page))))
+    return urlunsplit(
+        (parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
+    )
+
+
+def _yandex_extract_total(scraper: Any, html: str) -> int | None:
+    payload = _yandex_payload(scraper, html)
+    if payload is None:
+        return None
+    extracted = _extract_gate_data(payload)
+    if extracted is None:
+        return None
+    _entities, pager = extracted
+    total = pager.get("totalItems")
+    return int(total) if total is not None else None
+
+
+def _yandex_parse_cards(scraper: Any, html: str) -> list[Any]:
+    """Разбор gate-payload в ScrapedLot теми же функциями, что и прод-скрейпер.
+
+    page_param берём из самого ответа (response.pageParams.params.page), а не из
+    аргумента: контракт адаптера номера страницы не передаёт, а в raw_payload
+    должна лечь та страница, которую реально отдал gate.
+    """
+    payload = _yandex_payload(scraper, html)
+    if payload is None:
+        scraper.last_raw_count = 0
+        return []
+    extracted = _extract_gate_data(payload)
+    if extracted is None:
+        scraper.last_raw_count = 0
+        return []
+    entities, _pager = extracted
+    # Сырое число — до отбраковки лотов без offerId/цены в _entity_to_lot.
+    scraper.last_raw_count = len(entities)
+    try:
+        page_param = int(payload["response"]["pageParams"]["params"]["page"])
+    except (KeyError, TypeError, ValueError):
+        page_param = 1
+    return _parse_gate_json(payload, page_param=page_param, new_flat="NO")
+
+
+def _yandex_detect_block(html: str) -> tuple[str, str] | None:
+    """Блок Яндекса: SmartCaptcha приходит HTML-страницей на месте JSON.
+
+    Порядок важен: сперва ищем маркеры капчи (иначе «нет JSON» рапортовалось бы
+    как schema-drift), потом проверяем, что документ вообще похож на gate-ответ.
+    Пустой gate-payload без response — тоже стоп: это заглушка, а не выдача.
+    """
+    head = html[:4096].lower()
+    if "smartcaptcha" in head or "showcaptcha" in head or "captcha" in head:
+        return "challenge", "SmartCaptcha Яндекса вместо gate-ответа"
+    json_text = _extract_json_from_content(html)
+    if not json_text:
+        return "challenge", "во вкладке нет JSON (gate отдал не тот документ)"
+    return None
+
+
+async def _yandex_wait_ready(page: Any) -> None:
+    """gate-API — документ JSON, клиентской дорисовки нет.
+
+    Ждать `[data-marker=item]` (Авито) или window._cianConfig (Циан) здесь
+    бессмысленно — их не будет никогда, каждая загрузка стоила бы полного
+    таймаута. Chrome заворачивает текстовый документ в 
, его и ждём, коротко:
+    к моменту domcontentloaded он, как правило, уже на месте, а на капче его нет
+    вовсе — и тогда через 3 с отработает _yandex_detect_block.
+    """
+    try:
+        await page.wait_for_selector("pre", timeout=3_000)
+    except Exception:  # noqa: BLE001 — капча/заглушка разбирается detect_block ниже
+        pass
+
+
+@dataclass(frozen=True)
+class PlatformAdapter:
+    """Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
+    накопитель/заливка) — в основном теле скрипта и от платформы не зависит."""
+
+    name: str
+    table: str                    # msk_raw.
+ default_base_url: str + page_size: int + max_pages: int + build_url: Callable[[str, int, int | None, int | None], str] + extract_total_count: Callable[[Any, str], int | None] + parse_cards: Callable[[Any, str], list[Any]] + detect_block: Callable[[str], tuple[str, str] | None] + wait_ready: Callable[[Any], Any] # корутина: дождаться готовности страницы + # Дефолт --target-count. Он обязан быть <= hard_cap площадки, иначе бисекция + # штатно оставляет коридоры, которые заведомо не вычитываются до конца. + default_target: int = 1500 + + @property + def hard_cap(self) -> int: + return self.page_size * self.max_pages + + def make_scraper(self) -> Any: + if self.name == "yandex": + # У Яндекса разбор — чистые функции над gate-JSON; YandexRealtyScraper + # нужен только ради camoufox-транспорта и пула прокси, которых здесь + # нет (транспорт — вкладка Chrome владельца). Носитель состояния для + # мемоизации payload и last_raw_count — пустой namespace. + return SimpleNamespace() + if self.name == "avito": + # avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё, + # где в URL нет /ekaterinburg/ — то есть все подмосковные слаги + # (serp.py:2154). + return AvitoScraper( + SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type] + target_city_slug="moskva", + ) + # glitchtip_dsn=None — отключает попытку sentry-репорта schema-regression + # из _report_schema_regression (нет реального DSN в локальном прогоне). + return CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type] + + +ADAPTERS: dict[str, PlatformAdapter] = { + "avito": PlatformAdapter( + name="avito", + table="avito_cards", + default_base_url=DEFAULT_AVITO_BASE_URL, + page_size=AVITO_PAGE_SIZE, + max_pages=AVITO_MAX_PAGES, + build_url=_avito_build_url, + extract_total_count=lambda scraper, html: scraper._extract_total_count(html), + parse_cards=lambda scraper, html: scraper._parse_html(html, "https://www.avito.ru"), + detect_block=_avito_detect_block, + wait_ready=_avito_wait_ready, + ), + "cian": PlatformAdapter( + name="cian", + table="cian_cards", + default_base_url=DEFAULT_CIAN_BASE_URL, + page_size=CIAN_PAGE_SIZE, + max_pages=CIAN_MAX_PAGES, + build_url=_cian_build_url, + extract_total_count=lambda scraper, html: scraper._extract_total_offers(html), + parse_cards=_cian_parse_cards, + detect_block=_cian_detect_block, + wait_ready=_cian_wait_ready, + ), + "yandex": PlatformAdapter( + name="yandex", + table="yandex_cards", + default_base_url=DEFAULT_YANDEX_BASE_URL, + page_size=YANDEX_PAGE_SIZE, + max_pages=YANDEX_MAX_PAGES, + build_url=_yandex_build_url, + extract_total_count=_yandex_extract_total, + parse_cards=_yandex_parse_cards, + detect_block=_yandex_detect_block, + wait_ready=_yandex_wait_ready, + default_target=YANDEX_TARGET_COUNT, + ), +} + + # --- загрузка страницы ----------------------------------------------------- -def _guard(html: str, status: int | None) -> None: - """Порядок проверок фиксирован заданием; первое срабатывание = стоп.""" +def _guard(html: str, status: int | None, adapter: PlatformAdapter) -> None: + """Порядок проверок фиксирован заданием; первое срабатывание = стоп. + + HTTP-статус — платформо-независимая проверка. Текстовые маркеры (firewall/ + PoW и т.п.) — через adapter.detect_block, у каждой платформы свои. + """ 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 / проверка безопасности") + extra = adapter.detect_block(html) + if extra is not None: + raise Blocked(*extra) class Loader: @@ -206,14 +615,17 @@ class Loader: это рабочий Chrome с залогиненным техаккаунтом. """ - def __init__(self, delay: float, page_budget: int | None) -> None: + def __init__(self, delay: float, page_budget: int | None, adapter: PlatformAdapter) -> None: self._delay = delay self._budget = page_budget + self._adapter = adapter 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 @@ -222,6 +634,8 @@ class Loader: global PlaywrightTimeoutError PlaywrightTimeoutError = _PwTimeout + # AVITO_CDP — исторически названо под первую платформу, но это адрес + # браузера владельца, а не площадки: используется для обеих платформ. endpoint = os.environ.get("AVITO_CDP", "http://localhost:9222") self._pw = await async_playwright().start() try: @@ -230,7 +644,7 @@ class Loader: await self._pw.stop() raise SystemExit( f"Не удалось подключиться по CDP к {endpoint}: {exc}\n" - "Запусти Chrome с залогиненным техаккаунтом Авито и ключом " + "Запусти Chrome с залогиненным техаккаунтом и ключом " "--remote-debugging-port=9222, либо укажи адрес в AVITO_CDP." ) from exc if not self._browser.contexts: @@ -239,8 +653,8 @@ class Loader: "В подключённом Chrome нет ни одного контекста. Открой обычное окно " "Chrome, запущенное с --remote-debugging-port=9222." ) - ctx = self._browser.contexts[0] - self._page = await ctx.new_page() + self._ctx = self._browser.contexts[0] + self._page = await self._ctx.new_page() return self async def __aexit__(self, *exc: object) -> None: @@ -270,19 +684,42 @@ class Loader: 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 + await self._adapter.wait_ready(self._page) html = await self._read_content() - _guard(html, status) + _guard(html, status, self._adapter) 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 с ограниченным ретраем на таймаут навигации. @@ -304,7 +741,7 @@ class Loader: except Exception: # noqa: BLE001 — документа может не быть вовсе partial = "" if partial: - _guard(partial, None) # настоящий блок остановит прогон здесь + _guard(partial, None, self._adapter) # настоящий блок остановит прогон здесь if i == attempts - 1: raise Blocked("nav_timeout") from None print(f" таймаут навигации, попытка {i + 2}/{attempts}", flush=True) @@ -352,35 +789,43 @@ def _csv_rows(rows: Iterable[dict[str, Any]]) -> str: def build_sql(batch_id: str, query: str, rows: list[dict[str, Any]], - started_at: str, kind: str = "serp") -> str: + started_at: str, table: str, kind: str = "serp") -> str: """Один поток на `psql -f -`: batch (FK!) → TEMP staging → \\copy → INSERT. Одиночный `psql -c` через ssh ломается на квотинге скобок и кавычек, поэтому только поток. rows_new = разница count(*) по batch_id до и после вставки. + table — msk_raw.avito_cards / msk_raw.cian_cards (из PlatformAdapter, не из + пользовательского ввода — подстановка f-строкой безопасна). + + У staging обязателен INCLUDING IDENTITY: если целевая таблица объявлена как + GENERATED ALWAYS AS IDENTITY, один INCLUDING DEFAULTS identity не переносит, + а NOT NULL переносится всегда — и \\copy без колонки id падает на каждом + батче. """ bid = _sql_str(batch_id) + tbl = f"msk_raw.{table}" 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) " + f"CREATE TEMP TABLE _stg (LIKE {tbl} INCLUDING DEFAULTS INCLUDING IDENTITY) " "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" + f" SELECT count(*) AS n FROM {tbl} 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 " + f"INSERT INTO {tbl} " "(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" + f" ((SELECT count(*) FROM {tbl} WHERE batch_id = {bid})\n" " - (SELECT n FROM _before))\n" f"WHERE b.batch_id = {bid};\n" "COMMIT;\n" @@ -398,18 +843,54 @@ def build_finalize_sql(batch_id: str, query: str, notes: str) -> str: ) -def run_psql(sql: str, ssh_host: str, container: str, db_user: str, db_name: str) -> None: +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 -", ] - proc = subprocess.run(cmd, input=sql.encode("utf-8"), capture_output=True) - out = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip() + 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)) + + +def psql_scalar(sql: str, ssh_host: str, container: str, db_user: str, + db_name: str) -> str: + """Тот же ssh+psql, что и run_psql, но `-Atc` и с возвратом stdout. + + Нужен для дешёвых проверок до старта (преflight целевой таблицы в collect): + один SELECT, без ретраев и без потока на stdin. + """ + cmd = [ + "ssh", ssh_host, + f"docker exec -i {container} psql -U {db_user} -d {db_name} " + f"-v ON_ERROR_STOP=1 -Atc {shlex.quote(sql)}", + ] + proc = subprocess.run(cmd, capture_output=True) if proc.returncode != 0: - raise RuntimeError(f"psql через ssh вернул {proc.returncode}:\n{out}") - if out: - print(f" psql: {out}", flush=True) + err = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip() + raise RuntimeError(f"psql через ssh вернул {proc.returncode}:\n{err}") + return proc.stdout.decode("utf-8", "replace").strip() # --- накопитель карточек --------------------------------------------------- @@ -422,6 +903,7 @@ class Sink: batch_id: str started_at: str query: str + table: str batch_size: int dry_run: bool csv_path: Path @@ -440,6 +922,12 @@ class Sink: except (TypeError, ValueError): self.skipped_non_numeric += 1 return + # offerId Яндекса — 19-значный (напр. 3937530341842304552), это уже + # впритык к signed bigint. Число сверх диапазона уронило бы \copy всего + # батча целиком, а не одну строку, поэтому отсекаем здесь же. + if not (-(2**63) <= source_id < 2**63): + self.skipped_non_numeric += 1 + return payload = lot.model_dump(mode="json") self.buffer.append({ "source_id": source_id, @@ -468,10 +956,10 @@ class Sink: 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), + build_sql(self.batch_id, self.query, rows, self.started_at, self.table), self.ssh_host, self.container, self.db_user, self.db_name, ) - print(f" залито {len(rows)} строк в msk_raw.avito_cards", flush=True) + print(f" залито {len(rows)} строк в msk_raw.{self.table}", flush=True) self.sent += len(rows) def finalize(self, notes: str) -> None: @@ -488,45 +976,68 @@ class Sink: # --- сбор ------------------------------------------------------------------ -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: +def parse_page(scraper: Any, html: str, url: str, + adapter: PlatformAdapter) -> tuple[int | None, list[Any]]: + """Счётчик и карточки страницы + детект блока по СЫРОМУ числу карточек. + + Гвард считает по сырому числу (до отброса новостроек), а не по итоговому + списку: на Циан страница может легально дать 0 карточек после фильтра + вторички, и такой штатный ноль неотличим от блока. scraper.last_raw_count + выставляет _cian_parse_cards; у Авито этого атрибута нет, + поэтому дефолт getattr — len(lots), и авито-ветка ведёт себя как раньше. + + Это вторая сеть после текстовых маркеров в adapter.detect_block: блок, + не попавший под маркер, всплывает как проваленный extract_state, то есть + count=None и пустой парс одновременно. + """ + count = adapter.extract_total_count(scraper, html) + lots = adapter.parse_cards(scraper, html) + raw = getattr(scraper, "last_raw_count", len(lots)) + if count is None and raw == 0: + raise Blocked( + "challenge", + f"страница без счётчика и без карточек (провал extract_state): {url}", + ) + if raw == 0 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) +async def probe(loader: Loader, scraper: Any, base_url: str, + lo: int | None, hi: int | None, + adapter: PlatformAdapter) -> tuple[int | None, list[Any]]: + url = adapter.build_url(base_url, 1, lo, hi) html, _ = await loader.fetch(url) - return parse_page(scraper, html, url) + return parse_page(scraper, html, url, adapter) -async def build_plan(loader: Loader, scraper: AvitoScraper, base_url: str, target: int, - cache: dict[tuple[int | None, int | None], list[Any]] +async def build_plan(loader: Loader, scraper: Any, base_url: str, target: int, + cache: dict[tuple[int | None, int | None], list[Any]], + adapter: PlatformAdapter, ) -> list[Corridor]: """Адаптивная бисекция по цене; страница 1 каждого коридора кэшируется.""" corridors: list[Corridor] = [] + hard_cap = adapter.hard_cap 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 + 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) + f"страниц={c.planned_pages(adapter.page_size, adapter.max_pages)}{flag}", + flush=True) if truncated: print(f" ВНИМАНИЕ: коридор {c.label()} не влезает в потолок " - f"{HARD_CAP}; заведомо не добрано ~{missed} объявлений", flush=True) + 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) + cnt, _ = await probe(loader, scraper, base_url, cand, None, adapter) print(f" проба хвоста pmin={cand:_} count={cnt}", flush=True) if cnt is not None and cnt <= target: return cand @@ -536,39 +1047,35 @@ async def build_plan(loader: Loader, scraper: AvitoScraper, base_url: str, targe 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) + url = adapter.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) + 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) + tail_cnt, tail_lots = await probe(loader, scraper, base_url, upper, None, adapter) emit(upper, None, tail_cnt, - bool(tail_cnt and tail_cnt > HARD_CAP), tail_lots) + 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) + sub_cnt, sub_lots = await probe(loader, scraper, base_url, sub_lo, sub_hi, adapter) 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) + root_cnt, root_lots = await probe(loader, scraper, base_url, None, None, adapter) 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", - ) + adapter = ADAPTERS[args.platform] + scraper = adapter.make_scraper() 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" @@ -585,9 +1092,14 @@ async def collect(args: argparse.Namespace) -> int: print(f"Resume по {plan_path}: коридоров {len(plan.corridors)}, " f"готово {done}", flush=True) - # Resume: URL берём из сохранённого плана, а не из CLI — коридоры посчитаны - # именно под него. Расхождение = молчаливая заливка чужой выдачи под тем же - # batch_id, поэтому это ошибка, а не тихий приоритет одного из двух. + # Resume: URL и платформу берём из сохранённого плана, а не из CLI — коридоры + # посчитаны именно под них. Расхождение = молчаливая заливка чужой выдачи под + # тем же batch_id, поэтому это ошибка, а не тихий приоритет одного из двух. + if plan is not None and plan.platform != args.platform: + raise SystemExit( + "--resume: план построен для другой платформы." + f" В плане {plan.platform}, в аргументах {args.platform}." + ) if plan is not None and plan.base_url != args.base_url: raise SystemExit( "--resume: план построен для другого URL." @@ -598,13 +1110,27 @@ async def collect(args: argparse.Namespace) -> int: 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) + print(f"Режим: {mode}; platform={args.platform}; batch_id={args.batch_id}; " + f"delay={args.delay}s; target={args.target_count}; dry_run={args.dry_run}", + flush=True) + + # Преflight целевой таблицы: без неё прогон умирал бы только на первой + # заливке — после часов планирования и сбора. Проверяем на первой секунде. + if not args.dry_run: + if not psql_scalar(f"SELECT to_regclass('msk_raw.{adapter.table}')", + args.ssh_host, args.container, + args.db_user, args.db_name): + raise SystemExit( + f"Таблицы msk_raw.{adapter.table} на проде нет. Применить" + " tradein-mvp/backend/data/sql/" + "299_msk_raw_cian_domclick_yandex_cards.sql" + " либо гонять с --dry-run." + ) 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, + table=adapter.table, 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]] = {} @@ -613,15 +1139,15 @@ async def collect(args: argparse.Namespace) -> int: rc = 0 loads = 0 - async with Loader(args.delay, page_budget) as loader: + async with Loader(args.delay, page_budget, adapter) as loader: try: if plan is None: print("Строю план коридоров...", flush=True) corridors = await build_plan(loader, scraper, base_url, - args.target_count, cache) + args.target_count, cache, adapter) plan = Plan(base_url=base_url, target=args.target_count, - batch_id=args.batch_id, corridors=corridors, - created_at=started_at) + batch_id=args.batch_id, platform=args.platform, + corridors=corridors, created_at=started_at) plan.save(plan_path) print(f"План сохранён: {plan_path} ({len(corridors)} коридоров)", flush=True) @@ -629,7 +1155,7 @@ async def collect(args: argparse.Namespace) -> int: for corridor in plan.corridors: if corridor.status == "done": continue - pages = corridor.planned_pages() + pages = corridor.planned_pages(adapter.page_size, adapter.max_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): @@ -637,9 +1163,9 @@ async def collect(args: argparse.Namespace) -> int: if page == 1 and key in cache: lots = cache.pop(key) # страница 1 уже скачана при планировании else: - url = build_url(base_url, page, corridor.lo, corridor.hi) + url = adapter.build_url(base_url, page, corridor.lo, corridor.hi) html, _ = await loader.fetch(url) - _, lots = parse_page(scraper, html, url) + _, lots = parse_page(scraper, html, url, adapter) for lot in lots: sink.add(lot) total += len(lots) @@ -667,9 +1193,11 @@ async def collect(args: argparse.Namespace) -> int: truncated = [c for c in (plan.corridors if plan else []) if c.truncated] missed = sum(c.missed for c in truncated) + nb_ref = getattr(scraper, "last_nb_ref", 0) notes = "; ".join(x for x in [ - f"mode={mode}", f"loads={loads}", f"cards={total}", + f"mode={mode}", f"platform={args.platform}", f"loads={loads}", f"cards={total}", f"skipped_non_numeric={sink.skipped_non_numeric}", + (f"nb_ref={nb_ref}" if nb_ref else ""), (f"truncated_corridors={len(truncated)} missed~{missed}" if truncated else ""), stop_reason, ] if x) @@ -687,10 +1215,12 @@ async def collect(args: argparse.Namespace) -> int: def parse_args(argv: list[str] | None = None) -> argparse.Namespace: p = argparse.ArgumentParser( prog="collect.py", - description="Ручной сбор SERP Авито (вторичка, Москва+МО) в прод-схему msk_raw.", + description="Ручной сбор SERP Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.", ) - p.add_argument("--base-url", default=DEFAULT_BASE_URL, - help="базовый URL выдачи (дефолт — вкладка владельца)") + p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito", + help="площадка сбора (дефолт avito)") + p.add_argument("--base-url", default=None, + help="базовый URL выдачи (дефолт зависит от --platform)") p.add_argument("--measure", type=int, default=100, metavar="N", help="режим замера: не больше N загрузок страниц (дефолт 100)") p.add_argument("--full", action="store_true", @@ -703,10 +1233,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: 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("--target-count", type=int, default=None, + help="целевой размер коридора; больше — делим " + "(дефолт зависит от --platform: 1500 у avito/cian, " + "500 у yandex — там потолок пагинации 25*20)") p.add_argument("--batch-id", default=None, - help="batch_id в msk_raw.batches (дефолт msk-serp-)") + help="batch_id в msk_raw.batches (дефолт msk-serp--)") p.add_argument("--out-dir", default=str(Path(__file__).resolve().parent / "runs"), help="каталог плана/CSV") p.add_argument("--ssh-host", default="selectel", help="ssh-хост прода") @@ -715,8 +1247,14 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: p.add_argument("--db-user", default="tradein") p.add_argument("--db-name", default="tradein") args = p.parse_args(argv) + if args.base_url is None: + args.base_url = ADAPTERS[args.platform].default_base_url + if args.target_count is None: + args.target_count = ADAPTERS[args.platform].default_target if args.batch_id is None: - args.batch_id = "msk-serp-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + args.batch_id = ( + f"msk-serp-{args.platform}-" + 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: