Merge branch 'main' into fix/eesk-bind-in-comment
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 17m40s

This commit is contained in:
bot-backend 2026-09-09 21:47:07 +00:00
commit 6eb1441508
223 changed files with 23962 additions and 2101 deletions

View file

@ -419,11 +419,22 @@ jobs:
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` выше пишет
# его новым инодом: `up -d` изменения не видит, контейнер держит старый.
METRICS_ROLE=apps \
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
sleep 10
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
agent-infra:
runs-on: ubuntu-latest
needs: server
@ -478,7 +489,18 @@ jobs:
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` (джоба server)
# пишет его новым инодом: `up -d` изменения не видит, контейнер держит старый.
METRICS_ROLE=infra \
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
sleep 10
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }

View file

@ -596,6 +596,11 @@ jobs:
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
# #3324: дефолт appleboy/ssh-action — command_timeout 10m, а worst-case
# гейта в конце скрипта ~17 мин (4 сервиса × 240s ожидания healthy +
# фронт + diagnose). Сессию убило бы посреди печати диагноза, и авария
# выглядела бы обрывом связи, а не мёртвым контейнером.
command_timeout: 30m
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H
script: |
set -euo pipefail
@ -1037,6 +1042,171 @@ jobs:
fi
echo "→ backend healthy на /health."
# ── Гейт деплоя (#3324) ────────────────────────────────────────────
# ДО этого блока весь гейт ПТИЦЫ = один `curl backend /health` выше:
# worker/beat не проверялись вообще (у них и healthcheck'а в compose не
# было), у frontend была только TCP-проба внутри контейнера, которую
# деплой не читал. То есть crash-loop воркера, вставший beat и фронт,
# отдающий 500, уезжали ЗЕЛЁНЫМ деплоем. Дисциплина перенесена из
# deploy-tradein.yml (health каждого сервиса + HTTP фронта + сверка
# образов), сюда добавлено чтение docker-health, потому что у ПТИЦЫ
# пробы теперь описаны в compose.
# `up -d --wait` НЕ используется намеренно: подъём здесь разбит на
# несколько `up` (bulk без worker'а → guard #3029 → caddy → forwarder),
# и общий --wait ждал бы ещё и профильные/инфраструктурные сервисы,
# ломая порядок «миграции до подъёма кода». Читаем состояние явно.
# Все проверки выполняются ДО выхода (не падаем на первой) — один
# прогон обязан показать ВСЕ поломанные сервисы, а не первый по списку.
# tail -n1: у сервиса может остаться залежавшийся exited-контейнер, и
# тогда `ps -aq` вернёт НЕСКОЛЬКО id через \n — `docker inspect` с таким
# аргументом падает, статус приходит пустым и гейт краснеет на ровном
# месте. Последний id — самый свежий контейнер сервиса.
cid() { docker compose -p gendesign -f docker-compose.prod.yml ps -aq "$1" 2>/dev/null | tail -n1 || true; }
diagnose() { # $1 сервис, $2 id контейнера (может быть пустым)
local svc="$1" c="$2"
echo "── ДИАГНОЗ $svc ──"
if [ -z "$c" ]; then
echo " контейнера нет вообще (docker compose ps -aq $svc пусто)"
return 0
fi
docker inspect -f ' state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}<healthcheck не сконфигурирован>{{end}} restarts={{.RestartCount}} exit_code={{.State.ExitCode}} image={{.Image}}' "$c" || true
echo " healthcheck log:"
docker inspect -f '{{json .State.Health}}' "$c" 2>/dev/null | head -c 2000 || true
echo ""
echo " последние 40 строк логов $svc:"
docker logs --tail 40 "$c" 2>&1 | sed 's/^/ /' || true
}
wait_healthy() { # $1 сервис, $2 таймаут, с
local svc="$1" deadline="$2" c="" status="" alive="" r0="" r1="" waited=0
while [ "$waited" -lt "$deadline" ]; do
c="$(cid "$svc")"
if [ -n "$c" ]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none:{{.State.Status}}{{end}}' "$c" 2>/dev/null || echo '')"
case "$status" in
healthy)
echo "→ $svc healthy (за ${waited}s)"
return 0
;;
none:running)
# Контейнер без healthcheck-конфига = создан ДО этой правки
# compose и в этом прогоне не пересоздавался (штатный случай —
# worker, пропущенный guard'ом #3029). Валить деплой за это
# нельзя, но и молчать нельзя: падаем на «стабильный running»
# (двойное чтение, как tgbot/scraper в deploy-tradein.yml).
# Одного `running` дважды НЕДОСТАТОЧНО: crash-loop с временем
# жизни больше паузы читается как «стабилен» — контейнер оба
# раза running, просто это разные его жизни. Поэтому вместе со
# статусом сверяем RestartCount: изменился за окно = именно
# тот дефект, ради которого этот гейт и писался.
r0="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')"
sleep 15
alive="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo unknown)"
r1="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')"
if [ "$alive" = "running" ] && [ -n "$r0" ] && [ "$r0" = "$r1" ]; then
echo "→ $svc: healthcheck не сконфигурирован (контейнер не пересоздавался), running стабилен (RestartCount=$r0 не изменился за 15s)"
return 0
fi
# waited растёт на длину ЭТОЙ паузы тоже — иначе таймаут
# 240s превратился бы в ~24 минуты реального ожидания и упёрся
# бы в command_timeout SSH-сессии.
waited=$((waited + 15))
echo " $svc: running нестабилен — status='$alive', RestartCount ${r0:-<нет>}→${r1:-<нет>} (контейнер перезапускался внутри окна наблюдения); продолжаю ждать"
;;
esac
fi
waited=$((waited + 3))
sleep 3
done
echo "ERROR (#3324): $svc не стал healthy за ${deadline}s (последний статус: '${status:-<контейнера нет>}') — деплой FAILED"
diagnose "$svc" "$c"
return 1
}
health_rc=0
for gate_svc in backend worker beat frontend; do
wait_healthy "$gate_svc" 240 || health_rc=$?
done
# Фронт: HTTP-СТАТУС, а не только «порт слушает». Compose-проба фронта
# намеренно TCP-only (в node:alpine нет ни curl, ни wget), и она не
# отличает живой Next.js от процесса, отдающего 500 на каждый запрос.
# Тянем с хоста через опубликованный 127.0.0.1:3000. basePath у ПТИЦЫ
# нет (frontend/next.config.*), корень — настоящий маршрут приложения.
# Годным считаем 2xx/3xx: редирект middleware'а на логин — это живой
# роутинг, а не поломка (тот же критерий, что `curl -f` в tradein).
fe_rc=0
fe_code=000
for i in $(seq 1 5); do
fe_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:3000/ || echo 000)"
case "$fe_code" in
2*|3*) break ;;
esac
sleep 3
done
case "$fe_code" in
2*|3*) echo "→ frontend отвечает HTTP $fe_code на /." ;;
*)
echo "ERROR (#3324): frontend на http://localhost:3000/ вернул '$fe_code' (000 = соединения нет) — деплой FAILED"
diagnose frontend "$(cid frontend)"
fe_rc=1
;;
esac
# Сверка образов (приём #2679 из deploy-tradein.yml, адаптирован под
# ПТИЦУ). Здесь не одно «backend-семейство»: backend и beat бегут один
# образ gendesign-backend, worker и frontend — свои. Поэтому эталон не
# «образ backend'а», а то, что реально лежит локально под тегом
# $IMAGE_TAG после pull'а: контейнер, оставшийся на другом id, работает
# на старом коде при зелёном деплое.
# Гард свежести самого :latest в registry — отдельный шаг выше
# (scripts/check-latest-image-revision.sh, #2950); здесь проверяется
# следующее звено: доехал ли уже скачанный образ до контейнера.
check_image() { # $1 сервис, $2 репозиторий образа
local svc="$1" repo="$2" want run c
want="$(docker image inspect -f '{{.Id}}' "$repo:$IMAGE_TAG" 2>/dev/null || echo '')"
c="$(cid "$svc")"
run="$(docker inspect -f '{{.Image}}' "$c" 2>/dev/null || echo '')"
if [ -z "$want" ]; then
echo "ERROR (#3324): локально нет образа $repo:$IMAGE_TAG — сверять не с чем (pull не отработал?)"
return 1
fi
if [ -z "$run" ]; then
echo "ERROR (#3324): контейнера сервиса $svc НЕТ — это не «отставший образ», а неполный стек"
return 1
fi
if [ "$want" != "$run" ]; then
echo "ERROR (#3324): $svc ОТСТАЛ: работает на $run, а $repo:$IMAGE_TAG — это $want"
echo " лечение: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps $svc"
diagnose "$svc" "$c"
return 1
fi
echo "→ $svc на свежем $repo:$IMAGE_TAG ($run)"
}
image_rc=0
check_image backend ghcr.io/lekss361/gendesign-backend || image_rc=$?
check_image beat ghcr.io/lekss361/gendesign-backend || image_rc=$?
check_image frontend ghcr.io/lekss361/gendesign-frontend || image_rc=$?
# worker сверяем ТОЛЬКО если этот прогон его пересоздавал: guard #3029
# намеренно оставляет worker на старом образе, пока идёт живой прогон
# скрейпа, и это уже отражено WARNING'ом выше. Падать здесь означало бы
# красить деплой за штатное поведение guard'а.
case " $WORKER_SERVICES " in
*" worker "*) check_image worker ghcr.io/lekss361/gendesign-worker || image_rc=$? ;;
*) echo "→ сверка образа worker'а пропущена: guard #3029 не пересоздавал его в этом прогоне (см. WARNING выше)" ;;
esac
# Явный rc: «зелёная сводка» ниже печатается ДО выхода, поэтому итог
# обязан быть числом в логе, а не выводом из отсутствия ERROR-строк.
gate_rc=0
[ "$health_rc" = 0 ] || gate_rc=1
[ "$fe_rc" = 0 ] || gate_rc=1
[ "$image_rc" = 0 ] || gate_rc=1
echo "Гейт деплоя (#3324): health_rc=$health_rc frontend_rc=$fe_rc image_rc=$image_rc → rc=$gate_rc"
exit "$gate_rc"
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).

View file

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

View file

@ -133,6 +133,11 @@ users:
# продукта; ранее expired с 2026-06-27). Безлимитная квота оценок
# выдана через account_quota_overrides.unlimited (migration 191),
# не через код — см. app.services.account_quota.is_unlimited.
buyer1: pilot # Тестовый доступ потенциального покупателя — заведён 2026-09-02 по
# просьбе владельца. Квота 50 оценок/мес через
# account_quota_overrides.monthly_limit (не unlimited). DB-роль
# manager (как praktika/kopylov — самостоятельный внешний аккаунт,
# не employee под чьим-то manager_id).
admintest: admin # temp QA 2026-05-26
pilottest: pilot # temp QA 2026-05-26
analysttest: analyst # temp QA 2026-06-07 (#962)

View file

@ -19,6 +19,7 @@ per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync
import hashlib
import json
import logging
import math
import re
import httpx
@ -101,20 +102,47 @@ def parse_voltage_class(name: str | None) -> str | None:
return m.group(1).replace(".", "/")
def _stable_external_id(feature: dict, props: dict) -> str:
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
def _coord_e5(value: float | None) -> str:
"""Координата → целое в единицах 1e-5 градуса (~1 м), полукруглением от нуля.
WFS обычно отдаёт стабильный ``feature['id']``; если его нет детерминированный
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL-
бэкфиллом (99c), а текстовое представление double в питоне и в PG разное.
Округление ДВОИЧНОЕ (по значению double, не по десятичному представлению):
64.423605*1e5 == 6442360.499999999 6442360, хотя «по десятичному» было бы
6442361. В 99c та же семантика: floor/abs/sign над float8, БЕЗ каста в numeric
(каст округляет по кратчайшему десятичному repr и расходится в 0.19% координат).
"""
fid = feature.get("id")
if fid:
return str(fid)
geom = feature.get("geometry") or {}
coords = geom.get("coordinates")
seed = f"{props.get('sc_name', '')}|{coords}"
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
if value is None:
return ""
n = math.floor(abs(value) * 100000 + 0.5)
return str(-n if value < 0 else n)
def _stable_external_id(feature: dict, props: dict) -> str:
"""Стабильный external_id фичи — хэш атрибутов. ``feature['id']`` ИГНОРИРУЕТСЯ.
GeoServer отдаёт СЕССИОННЫЙ fid (``sc_points_fullview.fid--<random>``), новый на
каждый GetFeature ON CONFLICT (source, external_id) не срабатывал ни разу и
таблица росла ×10 (4880 строк на 481 ЦП, #3322). Ключ считаем только по стабильным
атрибутам: нормализованное имя | класс напряжения | координаты в 1e-5 градуса.
ФОРМУЛА ПРОДУБЛИРОВАНА в ``data/sql/99c_power_supply_centers_dedup.sql`` (бэкфилл
существующих строк) менять только синхронно. sha256, а не sha1: sha256 встроен
в PG16, sha1 требует pgcrypto. Префикс ``h:`` отличает новый ключ от старого fid.
"""
geom_pair = _point_geom_sql(feature)
coords = geom_pair[1] if geom_pair else {}
sc_name = props.get("sc_name")
seed = "|".join(
(
normalize_sc_name(sc_name),
parse_voltage_class(sc_name) or "",
_coord_e5(coords.get("lon")),
_coord_e5(coords.get("lat")),
)
)
# sha256 здесь — стабильный дедуп-id фичи, не криптография.
return "h:" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
def _map_load_index(props: dict) -> str | None:

View file

@ -18,6 +18,7 @@ Pure / mock-based — без реальной сети и БД. Покрывае
from __future__ import annotations
import hashlib
import io
from contextlib import contextmanager
from typing import Any
@ -96,6 +97,64 @@ def test_map_load_index_unknown_and_missing() -> None:
assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None
# ── _stable_external_id (#3322: сессионный fid раздувал таблицу ×10) ───────────
def _wfs_feature(fid: str, name: str, lon: float, lat: float) -> dict[str, Any]:
return {
"id": fid,
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {"sc_name": name},
}
def test_stable_external_id_ignores_session_fid() -> None:
"""Разные сессионные fid + одинаковые атрибуты → ОДИН ключ (регрессия #3322)."""
a = _wfs_feature("sc_points_fullview.fid--1a2b3c", "ПС 110/10 Уктус", 60.47123, 56.77456)
b = _wfs_feature("sc_points_fullview.fid--9f8e7d", "ПС 110/10 Уктус", 60.47123, 56.77456)
key_a = rw._stable_external_id(a, a["properties"])
assert key_a == rw._stable_external_id(b, b["properties"])
# Проверка ПО ЗНАЧЕНИЮ: ключ = sha256 по «имя|напряжение|lon_e5|lat_e5»,
# не fid. Тот же seed повторён в data/sql/99c_power_supply_centers_dedup.sql.
expected = "h:" + hashlib.sha256("уктус|110/10|6047123|5677456".encode()).hexdigest()[:16]
assert key_a == expected == "h:844e54f0152d2799"
def test_stable_external_id_differs_on_attributes() -> None:
"""Одинаковый fid, разные атрибуты (координата / имя) → РАЗНЫЕ ключи."""
base = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47123, 56.77456)
moved = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47124, 56.77456)
renamed = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Северная", 60.47123, 56.77456)
keys = {rw._stable_external_id(f, f["properties"]) for f in (base, moved, renamed)}
assert len(keys) == 3
assert rw._stable_external_id(moved, moved["properties"]) == "h:8c5b5fa5f35c651b"
assert rw._stable_external_id(renamed, renamed["properties"]) == "h:297e00f6e0d4f3b8"
def test_stable_external_id_no_geometry() -> None:
"""Фича без геометрии: координатные компоненты пустые, ключ всё равно стабилен."""
f: dict[str, Any] = {"id": "fid--x", "properties": {"sc_name": "ПС 110/10 Уктус"}}
assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f"
def test_coord_e5_rounds_on_binary_double_not_decimal() -> None:
"""Округление по ДВОИЧНОМУ double, не по десятичному представлению.
64.423605*1e5 == 6442360.499999999 6442360; «по десятичному» вышло бы 6442361
(так считал бы round(ST_X(geom)::numeric*100000) расхождение на 0.19% реальных
координат). 99c обязана давать те же цифры, поэтому семантика закреплена тестом.
"""
assert rw._coord_e5(64.423605) == "6442360"
assert rw._coord_e5(-64.423605) == "-6442360"
# 60.123455*1e5 == ровно 6012345.5 → полукругление ОТ нуля, симметрично знаку.
assert rw._coord_e5(60.123455) == "6012346"
assert rw._coord_e5(-60.123455) == "-6012346"
assert rw._coord_e5(60.6) == "6060000"
assert rw._coord_e5(None) == ""
# ── sanitize_tp_capacity_mva (кВА-санитайз) ───────────────────────────────────

View file

@ -0,0 +1,54 @@
# ═══════════════════════════════════════════════════════════════════════════
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
#
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
# когда апстрим не отвечает.
#
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
# оставляет окно 3090 с, в котором контейнера просто нет: Caddy набирает
# новый апстрим сразу, тот ещё не слушает (замер по access-логам, #3274 —
# все 502 кластеризуются на окнах мержа, duration < 2 мс = мгновенный отказ
# соединения). Снипет НЕ УБИРАЕТ окно — он меняет то, что видит человек и
# клиент внутри окна. Настоящее лечение (готовность нового контейнера до
# переключения) — п.1 issue, решение владельца, здесь его нет.
#
# ПОЧЕМУ 503, А НЕ 502. 502 значит «апстрим ответил мусором» — постоянная
# поломка; поисковик по нему выкидывает страницу из индекса, клиентские
# библиотеки не ретраят. 503 + `Retry-After: 30` — стандартный код «временно
# недоступен, приходи через 30 секунд»: Googlebot держит страницу в индексе,
# HTTP-клиенты понимают, что повтор осмыслен.
#
# ПОЧЕМУ ДВА ТЕЛА. `/trade-in/api/*` вызывают из JS и внешних клиентов — они
# парсят JSON, и HTML-страница у них превращается в ошибку разбора вместо
# читаемого статуса. Всё остальное открывает человек браузером.
#
# ВНЕШНИХ РЕСУРСОВ В СТРАНИЦЕ НЕТ ВООБЩЕ — ни шрифта, ни CSS-файла, ни
# картинки. В окне деплоя они пришли бы с того же мёртвого апстрима, и
# страница-заглушка отрисовалась бы голым текстом. Отсюда же инлайновые
# `style=` вместо блока `<style>`: фигурные скобки в теле `respond` Caddy
# пытается разобрать как плейсхолдеры.
# ═══════════════════════════════════════════════════════════════════════════
@deployWindowApi path /trade-in/api/*
handle @deployWindowApi {
header Content-Type "application/json; charset=utf-8"
header Retry-After "30"
respond `{"detail":"Сервис обновляется, повторите запрос через минуту","error":"service_unavailable","retry_after":30}` 503
}
handle {
header Content-Type "text/html; charset=utf-8"
header Retry-After "30"
respond `<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Сервис обновляется</title>
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#fff;color:#111">
<main style="max-width:30rem;padding:2rem;text-align:center">
<h1 style="font-size:1.25rem;font-weight:600;margin:0 0 .75rem">Сервис обновляется</h1>
<p style="margin:0;line-height:1.6;color:#444">Это занимает около минуты. Обновите страницу чуть позже — введённые данные не потеряются.</p>
</main>
` 503
}

View file

@ -175,10 +175,28 @@ gendsgn.ru {
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
# X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
# правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
# анонимный, до всякого логина trade-in.
#
# ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот
# ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он
# сверяется ТОЛЬКО после того, как в запросе нашёлся непустой
# X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no
# authenticated user»). А X-Authenticated-User на этом же хопе строкой
# ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не
# может по построению, сравнение недостижимо. Второй потребитель
# секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`),
# берёт его из query-параметра `?secret=`, а не из заголовка, и на эту
# правку не реагирует.
#
# ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка
# в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой.
# Инжект работал в обратную сторону: раздавал внутренний секрет по
# анонимному пути (он же виден в env контейнера Caddy). Легитимный
# dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl
# -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`,
# см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
@ -186,7 +204,6 @@ gendsgn.ru {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -224,9 +241,13 @@ gendsgn.ru {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}).
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
# причине здесь больше нет инжекта X-Internal-Auth-Secret
# (#3324). На этом хопе он был мёртв ещё очевиднее: адресат —
# Next-сервер tradein-frontend, в его коде заголовок не читается
# и дальше в backend не пробрасывается (SSR не форвардит
# входящие заголовки — `headers()` во фронте не используется).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -246,6 +267,32 @@ gendsgn.ru {
}
}
}
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
# оставляет 3090 с, в которые контейнера нет и посетитель видит голый 502
# (замер по access-логам: 23 × 502 на этом домене за 4 суток, все —
# на окнах деплоя). Снипет подменяет это на 503 + Retry-After и читаемое
# тело; разбор «что лечится, а что нет» — в самом снипете.
#
# ГЕЙТ ПО ПУТИ ОБЯЗАТЕЛЕН: `handle_errors` объявляется на весь site-блок,
# а этот блок обслуживает ещё и Site Finder («Птица») — его апстримы
# (backend, frontend) деплоятся отдельным пайплайном и в задачу не входят.
# Пути вне матчера не попадают ни в один вложенный handle, ошибка остаётся
# необработанной, и Caddy отдаёт ровно то, что отдавал раньше. Проверено на
# живом Caddy 2.11.3 (dead-upstream 127.0.0.1:9): `/` и `/api/v1/*` —
# прежний пустой 502, `/trade-in/*` — новый 503.
#
# Матчер здесь сверяется с ИСХОДНЫМ путём запроса, а не с переписанным:
# для error-маршрута Caddy восстанавливает запрос, каким он пришёл. Поэтому
# `/trade-in/api/*` внутри снипета матчится, хотя на основном маршруте до
# падения апстрима уже отработал `uri strip_prefix /trade-in`. Тоже
# проверено на стенде, а не выведено из документации.
handle_errors 502 503 504 {
@tradeinScope path /trade-in /trade-in/*
handle @tradeinScope {
import ../deploy-window.caddy.snippet
}
}
}
www.gendsgn.ru {
@ -463,7 +510,40 @@ meraocenka.ru {
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
#
# #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ.
# App Router раскладывает код постранично: `chunks/app/<маршрут>/page-<hash>.js`
# (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`,
# `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*`
# и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` —
# это /admin, /scrapers, /team, /history, /cache, /versions, /v2,
# /sale-share, /login, /ui-preview: страницы закрытого контура, которые
# аноним с публичного домена качал целиком и читал в них имена внутренних
# ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
# `not` вместо негативного lookahead, RE2 его не поддерживает.
#
# `_not-found` в исключениях — это не маршрут продукта, а внутренняя
# страница Next (404 при клиентской навигации по битому href). Закрытого
# кода не несёт, а без исключения клиентский переход падал бы в
# error-boundary вместо честного «не найдено».
#
# Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
# лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
# маршрутов из неё по-прежнему вычитывается. По той же причине оставлены
# без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не
# код», и городить второй матчер ради него смысла нет. Полное разделение —
# split build (отдельный public-бандл), отдельная задача.
#
# `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
# директив Caddy), поэтому вложенный матчер не зависит от сортировки
# handle-блоков между собой.
handle /trade-in/_next/static/* {
@foreignRouteChunk {
path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/*
}
respond @foreignRouteChunk 404
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
@ -499,9 +579,12 @@ meraocenka.ru {
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
# анонимного домена — расширять доверие без нужды.
# X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
# проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
# расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
# gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
# заголовок нужен только внутрисетевому dual-mode трафику, который ходит
# мимо прокси и несёт его сам.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
@ -514,6 +597,19 @@ meraocenka.ru {
handle {
respond 404
}
# Окно деплоя (#3274). В отличие от gendsgn.ru гейт по пути не нужен: все
# апстримы этого блока — контейнеры МЕРЫ (tradein-frontend/tradein-backend),
# чей деплой и создаёт окно. 15 × 502 за 4 суток, все на окнах мержа.
#
# Белый список выше это НЕ ослабляет. Во-первых, `handle_errors`
# срабатывает только на перечисленные статусы, а отказ белого списка —
# 404. Во-вторых, `respond 404` пишет ответ напрямую и ошибкой маршрута
# вообще не является. Проверено на стенде: при мёртвом апстриме `/admin`
# по-прежнему отдаёт 404, а не страницу обновления.
handle_errors 502 503 504 {
import ../deploy-window.caddy.snippet
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.

View file

@ -0,0 +1,137 @@
-- 99c_power_supply_centers_dedup.sql
-- Issue #3322 — power_supply_centers раздут ×10: 4880 строк на 481 уникальный ЦП.
--
-- Причина. rosseti_wfs_loader брал external_id из feature['id'] WFS-ответа, а
-- GeoServer отдаёт СЕССИОННЫЙ fid (новый на каждый GetFeature) → ON CONFLICT
-- (source, external_id) не срабатывал ни разу, каждый weekly-прогон добавлял
-- полный набор ~488 фич заново. Починка разбора сама старые строки не убирает
-- (ON CONFLICT ничего не перезапишет, ключи не совпадут) → нужен этот бэкфилл.
--
-- Что делает файл:
-- (а) пересчитывает external_id по НОВОЙ формуле (см. ниже) для всех строк
-- source='rosseti_wfs';
-- (б) схлопывает копии: победитель группы — свежайший снапшот
-- (fetched_at DESC NULLS LAST, id DESC — DESC в PG это NULLS FIRST,
-- поэтому NULLS LAST задан ЯВНО);
-- (в) печатает числа: строк до / после, удалено, переключено на новый ключ.
-- Ожидание после прогона — ~481-488 строк (столько ЦП отдаёт источник).
-- (г) идемпотентен: на повторном прогоне ключи уже совпадают → 0 удалений,
-- 0 обновлений, «до» = «после».
--
-- ФОРМУЛА КЛЮЧА (дублирует rosseti_wfs_loader._stable_external_id — менять только
-- синхронно, иначе следующий weekly-прогон вставит второй комплект строк):
-- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5
-- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16)
-- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление
-- floor(|v|*1e5 + 0.5) со знаком — ДВОИЧНОЕ, ровно как в питоне; пустая строка,
-- если geom отсутствует. Целые, а не форматированный float — текстовое
-- представление double в питоне и в PG различается.
--
-- ПОЧЕМУ НЕ round(...::numeric): каст float8→numeric берёт кратчайшее десятичное
-- представление, и округление идёт по нему, а не по двоичному double. На реальных
-- координатах расходится в 0.19% случаев (замер: 761 из 400000), напр. 64.423605
-- → питон 6442360 (двоичное 6442360.499999999), numeric-путь 6442361. Каждое
-- расхождение = вечный дубль ЦП, который сам не зарастёт: миграция применяется
-- один раз (_schema_migrations). Поэтому в SQL считаем ТЕМ ЖЕ double: floor/abs/
-- sign над float8 — это IEEE754, бит в бит как math.floor в питоне.
-- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto.
--
-- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует
-- сам: sc_name_norm и voltage_class — уже готовые колонки, их записал тот же
-- normalize_sc_name / parse_voltage_class. Если normalize_sc_name когда-нибудь
-- изменится, старые sc_name_norm разъедутся с новыми ключами — тогда нужен
-- повторный прогон логики этого файла (он идемпотентен, ре-apply безопасен).
--
-- Порядок: миграция ПЕРЕД деплоем кода (schema-first) — новый код после неё
-- попадает ON CONFLICT-ом в уже схлопнутые строки.
--
-- Naming: deploy.yml применяет файлы по `ls -1 data/sql/*.sql | sort`;
-- '99c_' идёт после '99b_grant_quarter_price_index_fdw.sql' ('b' < 'c').
BEGIN;
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
rows_before bigint;
names_before bigint;
rows_after bigint;
names_after bigint;
deleted bigint;
rekeyed bigint;
BEGIN
SELECT count(*), count(DISTINCT sc_name_norm)
INTO rows_before, names_before
FROM power_supply_centers
WHERE source = 'rosseti_wfs';
CREATE TEMP TABLE psc_new_key ON COMMIT DROP AS
SELECT
id,
fetched_at,
'h:' || substring(
encode(
sha256(convert_to(
sc_name_norm
|| '|' || coalesce(voltage_class, '')
|| '|' || CASE WHEN geom IS NULL THEN ''
ELSE (sign(ST_X(geom))
* floor(abs(ST_X(geom)) * 100000 + 0.5))::bigint::text END
|| '|' || CASE WHEN geom IS NULL THEN ''
ELSE (sign(ST_Y(geom))
* floor(abs(ST_Y(geom)) * 100000 + 0.5))::bigint::text END,
'UTF8'
)),
'hex'
) FROM 1 FOR 16
) AS new_key
FROM power_supply_centers
WHERE source = 'rosseti_wfs';
-- (б) схлопывание: оставляем свежайший снапшот каждой группы.
-- Резервы (reserve_mva и пр.) не теряются: rosseti/eesk-лоадеры пишут их
-- UPDATE-ом по sc_name_norm, т.е. во ВСЕ копии сразу, победитель их несёт.
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY new_key
ORDER BY fetched_at DESC NULLS LAST, id DESC
) AS rn
FROM psc_new_key
)
DELETE FROM power_supply_centers p
USING ranked r
WHERE p.id = r.id
AND r.rn > 1;
GET DIAGNOSTICS deleted = ROW_COUNT;
-- (а) пересчёт ключа у выживших. После DELETE каждый new_key принадлежит
-- ровно одной строке → UNIQUE (source, external_id) не нарушается.
-- IS DISTINCT FROM даёт идемпотентность: второй прогон обновит 0 строк.
UPDATE power_supply_centers p
SET external_id = k.new_key
FROM psc_new_key k
WHERE p.id = k.id
AND p.external_id IS DISTINCT FROM k.new_key;
GET DIAGNOSTICS rekeyed = ROW_COUNT;
SELECT count(*), count(DISTINCT sc_name_norm)
INTO rows_after, names_after
FROM power_supply_centers
WHERE source = 'rosseti_wfs';
RAISE NOTICE '#3322 power_supply_centers: было % строк / % имён -> стало % строк / % имён (удалено %, переключено на стабильный ключ %)',
rows_before, names_before, rows_after, names_after, deleted, rekeyed;
-- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области.
-- Превышение = формула ключа не схлопнула дубли. EXCEPTION, а не WARNING:
-- иначе файл пометится applied навсегда, а дубли останутся. Откат всей
-- транзакции ничего не теряет и оставляет миграцию непринятой до разбора.
IF rows_after > 700 THEN
RAISE EXCEPTION '#3322: после дедупа осталось % строк (ожидалось ~481-488) — формула ключа не схлопнула дубли, транзакция откачена', rows_after;
END IF;
END $$;
COMMIT;

View file

@ -313,6 +313,30 @@ services:
# /data/anton-sqlite/analysis.db).
- /opt/gendesign/site-finder:/data/anton-sqlite:ro
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"]
# #3324: до этого у worker'а healthcheck'а не было ВООБЩЕ — контейнер в
# crash-loop'е (ImportError в новом коде, протухший uv.lock) уезжал зелёным
# деплоем: деплой смотрел только `curl backend /health`.
# Проба — `inspect ping` ИМЕННО В ЭТОТ узел (`-d celery@$(hostname)`, у нас
# nodename дефолтный: в command нет `-n`). Без `-d` ping вернул бы OK на
# ответ ЛЮБОГО воркера на брокере — мёртвый контейнер выглядел бы живым.
# Ответ на ping = жив parent-процесс и держится соединение с Redis, т.е.
# ровно та связность, без которой очереди не разбираются. `$$` — экранировка
# для compose (в контейнер уезжает литеральное `$(hostname)`).
# Интервал 60s (не 30s как у backend): каждая проба — отдельный запуск
# celery-CLI с импортом приложения, дешёвым его не назовёшь.
# start_period 90s: холодный старт worker'а с Chromium-образа заметно
# медленнее backend'а.
# stderr НЕ глушим: docker хранит вывод пробы в .State.Health.Log, и деплой
# печатает его в диагнозе — с `2>&1` там была бы пустота вместо причины.
# retries 5 (не 3): при interval 60s тройка промахов = 3 минуты, столько
# длится обычный флап Redis, а из unhealthy контейнер сам не выходит по
# restart-политике — следующий деплой краснел бы за исправный воркер.
healthcheck:
test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null"]
interval: 60s
timeout: 30s
retries: 5
start_period: 90s
# #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task,
# которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared.
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
@ -336,6 +360,25 @@ services:
# хранит только last_run_at для periodic tasks — потеря на restart OK,
# beat перестроит из `celery_app.conf.beat_schedule` на старте.
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"]
# #3324: beat тоже жил без healthcheck'а. На `inspect ping` beat НЕ отвечает
# (remote control — свойство воркера, не планировщика), поэтому проба другая:
# СВЕЖЕСТЬ shelve-файла расписания. Celery beat синкует его на диск не реже
# чем раз в `Scheduler.sync_every` = 180 с — но только когда в этом окне была
# отправлена задача. У нас в расписании есть поминутная (nspd-geo-zombie-
# cleanup, `* * * * *`) и двухминутная задачи, так что живой beat обновляет
# mtime примерно каждые 3 минуты, а вставший — не обновляет вовсе. Порог 10
# минут = 3× запас к этой каденции.
# Почему glob `celerybeat-schedule*`: имя на диске зависит от того, какой
# backend выберет shelve/dbm в образе (gnu → тот же файл, dumb → .dat/.dir).
# Почему не `pgrep`/`true`: PID-1 процесс жив ровно пока жив контейнер —
# такая проба повторяет `State.Running` и не ловит подвисший планировщик.
# `grep -q .`: сам find возвращает 0 и когда не нашёл ничего.
healthcheck:
test: ["CMD-SHELL", "find /tmp -maxdepth 1 -name 'celerybeat-schedule*' -mmin -10 | grep -q ."]
interval: 60s
timeout: 10s
retries: 3
start_period: 120s
# ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ────────
# Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140),
@ -735,6 +778,9 @@ services:
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
- ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro
- ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro
# То же самое для страницы окна деплоя (#3274): caddy/sites/apps.caddy
# импортирует её как `import ../deploy-window.caddy.snippet`.
- ./caddy/deploy-window.caddy.snippet:/etc/caddy/caddy/deploy-window.caddy.snippet:ro
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
# держится в git через caddy/local/.gitignore — иначе docker создал бы
# отсутствующий bind-source сам, root-owned пустышкой.

View file

@ -172,6 +172,26 @@ loki.process "scrub_credentials" {
expression = "://[^:@/ ]+:([^@ ]+)@"
replace = "***"
}
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
// Прод-факт (#3154): uvicorn пишет в access-log ПОЛНЫЙ путь с query, и туда
// уезжал `?secret=<64 hex>` вебхука GlitchTip. Приложение чистит это у себя
// (tradein-mvp/backend/app/core/log_scrub.py), здесь — второй слой на случай
// строки, пришедшей мимо фильтра: другой процесс, sidecar, будущий логгер.
//
// Множество имён держим ОДИНАКОВЫМ с log_scrub.py. Префикс `[\w.-]*` перед
// альтернацией — ради суффиксных имён (`client_secret`, `refresh_token`,
// `webhook_secret`); значение обрывается на `&`, пробеле или кавычке, потому
// что access-строка uvicorn обрамляет запрос кавычками.
//
// Группа захвата стоит на ЗНАЧЕНИИ, а не на имени параметра: Alloy заменяет
// содержимое групп, а не весь совпавший фрагмент (ровно как у DSN выше — там
// группа на пароле, поэтому пользователь и хост уцелевают). Обернуть группой
// `?secret=` значило бы затереть имя и оставить сам секрет.
stage.replace {
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
replace = "***"
}
}
loki.relabel "journal" {

View file

@ -131,6 +131,18 @@ loki.process "scrub_credentials" {
expression = "://[^:@/ ]+:([^@ ]+)@"
replace = "***"
}
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
// То же выражение, что в alloy-apps.alloy, и по той же причине — подробности
// там. Здесь оно нужно не меньше: на инфра-хосте живут Forgejo и GlitchTip,
// у обоих есть ручки с `?token=` в адресе, и их логи идут в тот же Loki.
//
// Группа захвата — на ЗНАЧЕНИИ: Alloy заменяет содержимое групп, а не весь
// совпавший фрагмент (как у DSN выше, где группа на пароле).
stage.replace {
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
replace = "***"
}
}
loki.relabel "journal" {

View file

@ -52,15 +52,22 @@ check() {
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
local out code head_and_body
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
out=$(curl -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
code=${out##*$'\n'}
head_and_body=${out%$'\n'*}
if [ "$code" != "$expected" ]; then
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
elif [ -n "$reject" ] && printf '%s' "$head_and_body" | grep -qEi "$reject"; then
echo "FAIL: $desc ($url -> $code, но ответ от заглушки окна деплоя, а не от приложения)"
fail=1
else
echo "PASS: $desc ($url -> $code)"
fi
}
@ -119,6 +126,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324):
# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой
# правки аноним скачивал с публичного домена бандлы /admin, /team,
# /scrapers — с именами внутренних ручек внутри.
#
# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404,
# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а
# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по
# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой
# (замер на проде 02.09.2026: 9 байт).
check_caddy_404() {
local desc="$1" url="$2"
local out code size
out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
code=${out%% *}
size=${out##* }
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)"
else
echo "FAIL: $desc ($url -> got '${out:-<no response>}', expected '404 0')"
fail=1
fi
}
check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js"
check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js"
check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js"
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
# успешной, а публичный сайт молча остался бы без JS.
layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
if [ -z "$layout_chunk" ]; then
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
# чём — поэтому это FAIL, а не «пропустим».
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
fail=1
else
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
fi
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.
@ -156,8 +208,23 @@ check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/ap
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
# открытом наружу бэкенде.
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
# Путь именно `/api/v1/trade-in/history`: роутер trade_in подключён в main.py с
# префиксом `/api/v1/trade-in`, а сама ручка объявлена как `@router.get("/history")`.
# До 05.09.2026 здесь стоял несуществующий `/api/v1/history` — проверка была
# зелёной только потому, что guard отвечал 401 на ЛЮБОЙ путь; после #3352
# несуществующий путь даёт 404, и проверка честно покраснела.
check "trade-in /api/v1/trade-in/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/trade-in/history" 401
# #3360: admin-префикс анониму отвечает 404, а НЕ 401. Периметр здесь не срезан
# (Caddy-блок /trade-in/api/* стоит выше basic_auth-снипета, и срезать нельзя —
# admin-UI зовёт эти же пути из браузера), поэтому существование ручки прячет сам
# guard. Пара путей взята намеренно разная: `/proxies` — РЕАЛЬНЫЙ роут
# (app/api/v1/admin.py), `/users` — несуществующий. Одинаковый код на обоих и
# означает, что перебором имён admin-API снаружи ничего не узнать; 401 на первом
# = регресс маскировки (app/core/rbac.py::_unauthenticated).
check "trade-in /api/v1/admin/proxies — 404 anonymous (существующая ручка скрыта)" \
"$BASE_MAIN/trade-in/api/v1/admin/proxies" 404
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
@ -206,8 +273,16 @@ check "meraocenka.ru payments/checkout — must 404 (Caddy не проксиру
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
#
# С #3274 голый код 503 больше не доказывает ничего: Caddy сам отдаёт 503 на
# окне деплоя (`handle_errors` -> caddy/deploy-window.caddy.snippet), и тогда
# запрос до приложения не дошёл вовсе. Отличаем по признакам заглушки —
# заголовок `Retry-After: 30` и `"error":"service_unavailable"` в теле; у
# приложения тело `{"detail":"payments are disabled"}` и никакого Retry-After.
# Совпал код, но пришла заглушка → FAIL, а не молчаливый PASS.
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503 \
'service_unavailable|^retry-after: *30'
check "trade-in payments/notify — 405 на GET (только POST)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \

View file

@ -40,7 +40,6 @@ from pydantic import BaseModel, Field, field_validator
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
# что и debug-роуты этого файла.
from scraper_kit.base import save_listings
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import (
DEFAULT_REGION_CODE,
run_avito_city_sweep,
@ -49,6 +48,7 @@ from scraper_kit.orchestration.pipeline import (
run_yandex_city_sweep,
run_yandex_full_load,
)
from scraper_kit.providers._base import build_browser_fetcher
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment
from scraper_kit.providers.avito.imv import (
@ -268,6 +268,7 @@ async def geocode_missing(
db: Annotated[Session, Depends(get_db)],
limit: int = 100,
target: Literal["listings", "deals"] = "listings",
region_code: int = 66,
) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
@ -279,6 +280,10 @@ async def geocode_missing(
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером.
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66
прежнее поведение без изменений (cron не меняется, follow-up ниже).
"""
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
@ -291,13 +296,14 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
ORDER BY geocode_tried_at NULLS FIRST
LIMIT :limit
"""
),
{"limit": limit},
{"limit": limit, "region_code": region_code},
)
.mappings()
.all()
@ -332,8 +338,8 @@ async def geocode_missing(
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
city = known_city_hint(row.get("city"))
result = await geocode(clean, db, city_hint=city)
city = known_city_hint(row.get("city"), region_code)
result = await geocode(clean, db, city_hint=city, region_code=region_code)
if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute(
@ -362,10 +368,12 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
"""
)
),
{"region_code": region_code},
).scalar()
return {
@ -508,9 +516,16 @@ async def cian_auto_login(
)
try:
async with BrowserFetcher(
source="cian", endpoint=settings.browser_http_endpoint
) as fetcher:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
raw_cookies = await fetcher.login(
url=settings.cian_login_url,
email=email,
@ -699,7 +714,12 @@ async def debug_domclick_detail_fetch(
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
# рекомендация от 2026-06-27, до появления выделенного пула).
async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf:
# #3197 (хвост): фабрика вместо прямой конструкции — она и есть то место, где
# proxy_provider/use_pool/environment попадают в тело POST /fetch. Без них узел
# выбирал сайдкар из своего env, а не пул с provider_affinity='domclick'.
async with build_browser_fetcher(
RealScraperConfig(), "domclick", proxy_provider=_kit_proxy_provider()
) as bf:
try:
enrichment = await domclick_fetch_detail(
body.card_url, browser_fetcher=bf, cookies=cookies
@ -1106,6 +1126,10 @@ async def scrape_avito_imv(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
# #3386: без provider'а curl_proxy_url считает use_pool=False (флаг AND
# provider is not None) → env-прокси SCRAPER_PROXY_URL, мёртвый узел (#2613).
# _kit_proxy_provider() отдаёт None при выключенных флагах — ship-dark цел.
proxy_provider=_kit_proxy_provider(),
)
except IMVAddressNotFoundError as e:
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
@ -1944,7 +1968,10 @@ async def scrape_yandex_newbuilding(
"""
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
# config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=.
scraper = YandexNewbuildingScraper(config=RealScraperConfig())
# proxy_provider (#3197): без него сайдкар уходит на свой env-узел мимо пула.
scraper = YandexNewbuildingScraper(
config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
)
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
if result is None:
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")

View file

@ -13,9 +13,11 @@ _send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields",...}]}`` просто у uptime пустые/отсутствующие ``fields``/``color``;
2) единственный канал для аутентификации сам URL (как и Slack-вебхуки).
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
нечем (GlitchTip-сторона его не добавляет).
2) единственный канал для аутентификации от САМОГО GlitchTip сам URL (как и
у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому
хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret``
(предпочтительно не течёт в access-log, #3154), и из query-параметра
``?secret=`` как fallback для текущего отправителя.
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета тот же
@ -47,7 +49,7 @@ import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, ValidationError
from app.core.config import settings
@ -175,8 +177,9 @@ def _alerts_configured() -> bool:
def _verify_secret(provided: str) -> None:
expected = settings.tradein_internal_auth_secret
# constant-time: длина/префикс секрета не утекают через время ответа.
if not secrets.compare_digest(provided or "", expected):
logger.warning("glitchtip webhook: invalid or missing secret query param")
logger.warning("glitchtip webhook: invalid or missing secret")
raise HTTPException(status_code=401, detail="invalid or missing secret")
@ -184,18 +187,27 @@ def _verify_secret(provided: str) -> None:
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) этот
хендлер сам делает единственную проверку (``secret`` query-параметр).
хендлер сам делает единственную проверку секрета.
Секрет принимается ИЗ ЗАГОЛОВКА ``X-GlitchTip-Secret``, а query-параметр
``?secret=`` остаётся fallback'ом (#3154). Заголовок предпочтителен потому,
что query едет в access-log и оттуда в Loki открытым текстом; query оставлен,
т.к. САМ GlitchTip 6.1.6 заголовков не шлёт вовсе (``send_webhook()``
``session.post(url, json=...)`` без headers, см. docstring модуля), и убрать
query можно только когда заголовок начнёт подставлять кто-то перед нами
(Caddy ``header_up`` на маршруте вебхука) либо после смены отправителя.
"""
if not _alerts_configured():
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
_verify_secret(secret)
_verify_secret(header_secret or secret)
raw_body = await request.body()
received_at = datetime.now(UTC)

View file

@ -187,12 +187,52 @@ _ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"})
# списания требуют, чтобы человек намеренно оплатил обе формы.
_ABANDONED_AFTER_MINUTES = 30
# Статус для строки, чей Init отвалился: своего кода вроде INIT_FAILED в
# CHECK миграции 233 нет, а заводить его ради этого случая значило бы менять
# схему ради ярлыка — «почему» и так лежит в error_code/error_message. Берём
# терминальный DEADLINE_EXPIRED, которым checkout уже помечает попытки, из
# которых платёж не выйдет сам. Обязательное свойство ровно одно: статус ВНЕ
# предиката 279 (= вне _REUSABLE_STATUSES), иначе мёртвая строка продолжит
# держать пару (estimate_id, product_code). Это стережёт
# tests/test_payments_router.py::test_init_failed_status_is_terminal.
_INIT_FAILED_STATUS = "DEADLINE_EXPIRED"
_ORDER_ID_PREFIX = "mera-"
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
_REPORT_TOKEN_BYTES = 32
def _mark_init_failed(db: Session, order_id: str, *, code: str, message: str) -> None:
"""Переводит строку провалившегося Init в терминальный статус + «почему».
Общая для ВСЕХ исходов, после которых ссылки у строки не будет: отказ банка
и Success:true без PaymentURL. Статус NEW тут оставлять нельзя см.
комментарий к `_INIT_FAILED_STATUS`: строка без `payment_url` невидима для
`_find_live_payment`, но видима предикату UNIQUE миграции 279, и следующий
checkout получил бы ложный 409 на все `_ABANDONED_AFTER_MINUTES`.
"""
db.execute(
text(
"""
UPDATE payments
SET status = :status,
error_code = :code,
error_message = :message,
updated_at = NOW()
WHERE order_id = :order_id
"""
),
{
"status": _INIT_FAILED_STATUS,
"code": code[:64],
"message": message[:500],
"order_id": order_id,
},
)
db.commit()
def _require_enabled() -> None:
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
if not settings.payments_enabled:
@ -389,21 +429,17 @@ def checkout(
)
)
except TBankApiError as exc:
# Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт
# попытки (и возможного холда, если обрыв случился после приёма запроса
# банком) не остался бы нигде. Слепой повтор Init по тому же order_id
# Запись остаётся в БД с текстом ошибки — иначе факт попытки (и заказа,
# который банк мог принять до обрыва) не остался бы нигде. Холда здесь
# быть не может: Init только заводит заказ и отдаёт ссылку на форму, а
# авторизация суммы происходит, когда покупатель платит по форме — её
# ему не выдавали. Слепой повтор Init по тому же order_id всё равно
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
db.execute(
text(
"""
UPDATE payments
SET error_code = :code, error_message = :message, updated_at = NOW()
WHERE order_id = :order_id
"""
),
{"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id},
)
db.commit()
#
# Статус — терминальный (см. `_mark_init_failed`); если банк всё же
# пришлёт по этой строке нотификацию, статус-машина notify доведёт её до
# конца (терминальный статус не блокирует CONFIRMED).
_mark_init_failed(db, order_id, code=exc.error_code, message=str(exc))
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
raise HTTPException(status_code=502, detail="payment provider error") from exc
@ -411,7 +447,15 @@ def checkout(
tbank_payment_id = init.get("PaymentId")
if not isinstance(payment_url, str) or not payment_url:
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
# ссылку нечем.
# ссылку нечем. Замок тот же, что и у отказа выше, и даже вернее: банк
# заказ ПРИНЯЛ, а ссылки у строки уже не будет — оставить её в NEW
# значит отдать следующему checkout ложный 409 на 30 минут.
_mark_init_failed(
db,
order_id,
code="no_payment_url",
message=f"Init: Success без PaymentURL (Status={init.get('Status')!r})",
)
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
raise HTTPException(status_code=502, detail="payment provider returned no payment url")

View file

@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import TextClause
from app.core.auth import get_role
from app.core.auth import get_role, yaml_role
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password
@ -288,7 +288,9 @@ def _upsert_quota_override(
)
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
def _batch_quota_status(
db: Session, usernames: list[str], known_roles: dict[str, str] | None = None
) -> dict[str, dict[str, Any]]:
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
@ -351,13 +353,21 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
try:
role = get_role(username)
except KeyError:
role = None
# #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих
# же строк — иначе батч снова стал бы N+1 (ловит
# test_list_employees_query_count_is_not_n_plus_1). Роль реестра —
# ровно то, что вернул бы get_role: он спрашивает реестр первым.
role: str | None
if known_roles is not None and username in known_roles:
role = known_roles[username]
else:
try:
role = get_role(username)
except KeyError:
role = None
if role == "admin":
unlimited = True
elif role is not None:
elif yaml_role(username) is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
@ -432,6 +442,20 @@ async def create_employee(
`identity_db` реестр (строка сотрудника), `db` продуктовая квота;
в дефолтном режиме это одна и та же сессия и одна транзакция.
"""
# #3316 defense-in-depth: имя, за которым в roles.yaml уже числятся права
# (admin/pilot/analyst), занять нельзя. Роль резолвится из реестра первой
# (app.core.auth.get_role), так что эскалации не было бы и без этой
# проверки — но совпадение имён само по себе означает двух разных людей с
# одним логином, и дешевле отказать на входе, чем разбирать это в логах.
legacy = yaml_role(body.username)
if legacy is not None and legacy != "employee":
logger.warning(
"create_employee: %r refused — username занят в roles.yaml (role=%s)",
body.username,
legacy,
)
raise HTTPException(status_code=409, detail="username reserved in roles config")
schema = identity_schema()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
@ -762,7 +786,11 @@ async def list_employees(
.all()
)
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
quota_by_username = _batch_quota_status(
db,
[row["username"] for row in rows],
known_roles={row["username"]: row["role"] for row in rows},
)
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]

View file

@ -50,6 +50,7 @@ from app.schemas.trade_in import (
TradeInEstimateInput,
)
from app.services import account_quota
from app.services import regions as regions_mod
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
@ -763,8 +764,22 @@ def load_estimate(
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
# #3051 PR-A: тот же region_code-скоуп, что и POST /estimate — гарантирует
# «регион не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL
# обнулил бы фильтр). row.lat/row.lon персистятся с estimate-time.
target_region = (
regions_mod.region_for_point(row.lat, row.lon)
if row.lat is not None and row.lon is not None
else None
)
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
dkp_raw = _fetch_dkp_corridor(
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
db,
address=row.address,
rooms=row.rooms,
area=area_f,
city=target_city,
region_code=target_region_code,
)
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
@ -776,7 +791,16 @@ def load_estimate(
recommended_price=int(imv_raw["recommended_price"]),
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None,
# #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market
# считаем тем же порогом, что POST-путь в estimator, иначе одна и та же
# оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка.
market_count=(
int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None
),
thin_market=(
imv_raw.get("market_count") is not None
and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold
),
)
if imv_raw is not None and imv_raw.get("recommended_price")
else None

View file

@ -8,6 +8,11 @@ from repo root. We deliberately do NOT share code between repos via
When updating one copy, update the other.
РАСХОЖДЕНИЕ С ЗЕРКАЛОМ (#3316, намеренное — не «синхронизировать» обратно):
здесь `get_role` резолвит роль СНАЧАЛА из реестра людей (`tradein_users.role` /
`auth.users.role`), и только потом из YAML. У основного бэкенда реестра нет,
там копия остаётся YAML-only.
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
и пропускает в backend заголовок `X-Authenticated-User: <username>` через
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
@ -25,13 +30,15 @@ import logging
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal, TypedDict
from typing import Literal, TypedDict, cast
import yaml
logger = logging.getLogger(__name__)
Role = Literal["admin", "pilot", "analyst", "expired"]
# legacy roles.yaml-роли + роли реестра ('admin'|'manager'|'employee', CHECK
# tradein м.192 / auth м.004). Оба набора приходят из одного `get_role` (#3316).
Role = Literal["admin", "pilot", "analyst", "expired", "manager", "employee"]
class UserScope(TypedDict):
@ -155,8 +162,74 @@ def _load_roles_config() -> dict:
# ---------------------------------------------------------------------------
def yaml_role(username: str) -> Role | None:
"""Роль из roles.yaml (без похода в реестр) или None, если юзера там нет.
Нужна там, где спрашивают именно про legacy-файл, а не про эффективную роль:
`team.create_employee` (#3316) не даёт занять имя, за которым в YAML уже
числятся права.
"""
users: dict[str, Role] = _load_roles_config()["users"]
return users.get(username)
def _registry_role(username: str) -> str | None:
"""Роль из реестра людей (`tradein_users.role` / `auth.users.role`) или None.
None означает «реестр про этого юзера ничего не сказал»: строки нет, роль
пустая, либо реестр вообще недоступен. Во всех трёх случаях решение
остаётся за roles.yaml падение БД не имеет права выключить legacy-вход.
Осознанный компромисс (#3316 review): последняя ветка — недоступный
реестр на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно
то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий
имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в
`team.create_employee`. Если коллизия всё же появится (ручной INSERT в
реестр, расширение roles.yaml) сбой БД станет окном эскалации, и тогда
эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не
дописывать проверки у вызывающих.
Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение
едет bind-параметром: снаружи в SQL не попадает ничего.
"""
try:
from sqlalchemy import text
from app.services.identity_store import identity_schema, identity_session
schema = identity_schema()
with identity_session() as db:
row = db.execute(
text(f"SELECT role FROM {schema.users_table} WHERE username = :username"),
{"username": username},
).fetchone()
except Exception:
logger.exception(
"registry role lookup failed for %r — fallback to roles.yaml",
username,
)
return None
if row is None or not row.role:
return None
return str(row.role)
def get_role(username: str) -> Role:
"""Return the role for *username* or raise KeyError if unknown."""
"""Эффективная роль *username*: реестр (БД) первый, roles.yaml — fallback.
Raises KeyError, если юзера нет ни там, ни там.
#3316: раньше роль резолвилась ТОЛЬКО из roles.yaml, при том что люди
заводятся в БД (`tradein_users`) два дефекта разом. Вверх: сотрудник,
чьё имя совпало с YAML-админом, получал admin (IDOR по чужим оценкам +
безлимит квоты). Вниз: сотрудник, которого в YAML нет, получал KeyError
403 на СОБСТВЕННУЮ оценку. Единственный источник истины теперь один, и он
здесь вызывающие (rbac, trade_in, team, account_quota) не меняются.
"""
db_role = _registry_role(username)
if db_role is not None:
return cast(Role, db_role)
config = _load_roles_config()
users: dict[str, Role] = config["users"]
if username not in users:
@ -217,13 +290,22 @@ def get_user_scope(username: str) -> UserScope:
"""
config = _load_roles_config()
role = get_role(username)
role_def = config["roles"][role]
role_def = config["roles"].get(role)
if role_def is None:
# Роль реестра (employee/manager) — её scope живёт в DB_ROLE_PATHS, а не
# в roles.yaml (#3316: get_role теперь может вернуть и такую роль).
from app.services.auth_session import get_db_role_scope
allowed_paths, deny_paths = get_db_role_scope(role)
else:
allowed_paths = list(role_def.get("paths", []) or [])
deny_paths = list(role_def.get("deny", []) or [])
display_name, org, email = get_profile_for_user(username)
return UserScope(
username=username,
role=role,
allowed_paths=list(role_def.get("paths", []) or []),
deny_paths=list(role_def.get("deny", []) or []),
allowed_paths=allowed_paths,
deny_paths=deny_paths,
brand=get_brand_for_user(username),
display_name=display_name,
org=org,

View file

@ -1007,6 +1007,14 @@ class Settings(BaseSettings):
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
yandex_proxy_max_rotations: int = 4
# ── Порог полноты detail-страницы Яндекса (#3191) ────────────────────────
# Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным
# HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к
# структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKERS); любой из двух
# даёт отказ, объявление остаётся в очереди (detail_enriched_at не проставляется).
# ENV: YANDEX_DETAIL_MIN_HTML_BYTES.
yandex_detail_min_html_bytes: int = 1_000_000
@property
def yandex_proxy_url(self) -> str | None:
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
@ -1179,7 +1187,8 @@ class Settings(BaseSettings):
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
# секунды, clear_source_bans снимает запись из scrape_proxy_source_bans), но
# секунды, clear_source_bans гасит бан в scrape_proxy_source_bans — строка живёт
# до purge, #3404), но
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,

View file

@ -0,0 +1,72 @@
"""Секреты из query-строки не попадают в лог процесса (#3154).
Прод-факт: uvicorn пишет в access-log ПОЛНЫЙ путь вместе с query, а лог уезжает
в Loki (ретенция 30 суток, доступ по входу в Grafana):
INFO: 172.18.0.3:60322 - "POST /api/v1/trade-in/ops/glitchtip-webhook
?secret=<64 hex> HTTP/1.1" 200 OK
Скруббер в Alloy (#3115) это не ловит: там одно выражение под форму
``scheme://user:pass@host`` (DSN postgres_exporter, #3114). Чиним в СВОЁМ
процессе тогда секрета нет и в `docker logs`, до отправки куда-либо.
Фильтр вешается на логгер (`logging.Filter`), а не на форматтер: uvicorn.access
кладёт путь в ``record.args``, до форматирования он уже там. Поэтому берём
``record.getMessage()`` и, если что-то замаскировали, подменяем msg/args.
"""
from __future__ import annotations
import logging
import re
# Имена параметров, значение которых маскируем: имя ОКАНЧИВАЕТСЯ на чувствительное
# слово, поэтому перед альтернацией допускаем префикс (`client_secret`,
# `refresh_token`, `webhook_secret`). Значение — до следующего `&`, пробела или
# кавычки (access-строка uvicorn обрамляет запрос кавычками).
_SENSITIVE_QUERY = re.compile(
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
r"[^&\s\"'<>]+",
re.IGNORECASE,
)
def scrub_query_secrets(text: str) -> str:
"""Заменяет значения чувствительных query-параметров на ``***``.
Имя параметра и остальная строка сохраняются иначе access-лог перестал бы
годиться для диагностики.
"""
return _SENSITIVE_QUERY.sub(r"\1***", text)
class QuerySecretFilter(logging.Filter):
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
scrubbed = scrub_query_secrets(message)
if scrubbed != message:
record.msg = scrubbed
record.args = ()
return True
def install_query_secret_filter(*logger_names: str) -> None:
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
Двумя местами, потому что uvicorn в своём log-config ставит `uvicorn.access`
собственный handler с ``propagate = False`` до корневого его записи не
доходят. А фильтр на handler'ах корня закрывает всё остальное приложение
(записи дочерних логгеров фильтры родителя не проходят, фильтры handler'а
проходят).
Идемпотентно: повторный вызов не наплодит дублей.
"""
targets: list[logging.Logger | logging.Handler] = [
logging.getLogger(name) for name in logger_names or ("uvicorn.access",)
]
targets.extend(logging.getLogger().handlers)
for target in targets:
if not any(isinstance(f, QuerySecretFilter) for f in target.filters):
target.addFilter(QuerySecretFilter())

View file

@ -35,10 +35,11 @@ from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse, Response
from starlette.routing import Match
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
from app.services.auth_session import get_db_role_scope, get_session_user
from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user
from app.services.identity_store import identity_session
logger = logging.getLogger(__name__)
@ -181,6 +182,90 @@ def _db_role_path_allowed(role: str, path: str) -> bool:
return any(_db_glob_match(p, path) for p in paths)
def _path_is_routed(request: Request) -> bool:
"""Есть ли у пути хоть один маршрут в роутере приложения.
#3324: guard — HTTP-middleware, он отрабатывает ДО роутинга, поэтому раньше
отвечал 401 и на пути, которых в приложении нет вовсе. Анониму этого хватало,
чтобы бесплатно разведать периметр: мусор под «интересным» префиксом
(``/api/public/whatever``) давал 401 с rbac-текстом, а мусор под публичным
префиксом 404 роутера. Выключенная/закрытая ручка отличалась от
несуществующей. Несуществующий путь обязан отвечать одинаково независимо от
префикса, поэтому такие запросы пропускаются дальше 404 отдаёт роутер, тот
же самый, что и на любой другой мусор.
ЧТО ИМЕННО РАЗМЕНЯНО (это НЕ «ослабления нет»). Раньше аноним получал 401 на
ЛЮБОЙ non-public путь то есть оракул был ПРЕФИКСНЫЙ: он говорил «префикс
закрыт», но перечислить по нему таблицу маршрутов было нельзя. Теперь 401 =
«такой маршрут есть», 404 = «нет», и это уже оракул СУЩЕСТВОВАНИЯ маршрута:
перебором аноним восстанавливает список всех ручек приложения, включая имена
под ``/api/v1/admin/*``. Доступа это не даёт (закрытая ручка по-прежнему
отвечает 401/403), но карту периметра даёт.
Почему размен принят. Точечно: 404 на несуществующее норма HTTP, а
подобранное ИМЯ ручки без креденшелов бесполезно; исчезает же реальный
признак «этот префикс что-то охраняет». Это защита в глубину, и её глубина
здесь честно меньше, чем была.
Периметр admin-путей снаружи НЕ срезан проверено по конфигу, а не по
предположению: ``caddy/sites/apps.caddy`` блок ``handle /trade-in/api/*``
делает ``uri strip_prefix /trade-in`` + ``reverse_proxy tradein-backend:8000``
и стоит ЦЕЛИКОМ ВЫШЕ ``import caddy/users.caddy.snippet`` (basic_auth), т.е.
у trade-in своя авторизация и внешнего barrier'а нет. Значит внешний
``https://gendsgn.ru/trade-in/api/v1/admin/...`` доходит до этого guard'а
анонимно, и перебор имён admin-ручек выполним снаружи, не только изнутри
docker-сети. Хочется убрать резать надо в Caddy (отдельный issue), guard
этого не сделает: он про роли, а не про сетевой периметр.
``Match.NONE`` по ВСЕМ маршрутам значит, что выполнять нечего хендлера, до
которого можно было бы дотянуться, не существует. ``Match.PARTIAL`` (путь
есть, метод другой) считается маршрутом и идёт в guard как раньше там путь
реально существует, скрывать нечего.
Трейлинг-слэш был вторым каналом того же оракула в обход guard'а:
``/api/v1/me/`` не матчит ни один маршрут (``Match.NONE``) guard пропускает
Starlette-роутер отвечал 307 на ``/api/v1/me``, то есть «маршрут есть»
сообщал редирект, а не 401. Закрыто в ``app/main.py``:
``FastAPI(redirect_slashes=False)`` теперь такой путь даёт тот же 404, что
и любой другой мусор.
Неизвестное приложение (``scope["app"]`` не выставлен) ведём себя как
раньше, то есть отдаём запрос в guard.
"""
router = getattr(request.scope.get("app"), "router", None)
if router is None:
return True
return any(route.matches(request.scope)[0] != Match.NONE for route in router.routes)
def _unauthenticated(path: str, detail: str) -> JSONResponse:
"""Отказ анониму: 401 везде, но на admin-префиксе — 404 роутера.
#3360: периметр admin-путей снаружи НЕ срезан (``caddy/sites/apps.caddy``,
блок ``handle /trade-in/api/*`` стоит выше ``import
caddy/users.caddy.snippet``), и срезать его нельзя admin-UI кабинета зовёт
``/api/v1/admin/*`` ИЗ БРАУЗЕРА (tradein-mvp/frontend/src/app/scrapers/**,
components/scrapers/**, lib/admin-audit-api.ts). Значит внешний аноним
доходит сюда, а после #3324 (несуществующий путь → 404 роутера) 401 на
существующей ручке работал оракулом: перебором имён восстанавливался список
admin-API. Отвечаем ТЕМ ЖЕ, что роутер отдаёт на несуществующий путь,
существующая и несуществующая admin-ручки анониму неразличимы.
Скрываем ровно от НЕаутентифицированного. Аутентифицированный не-admin
по-прежнему получает 403 «admin only»: он уже прошёл идентификацию, прятать
от него наличие ручки незачем, а 404 вместо 403 маскировал бы отладку.
Тело литерал ``{"detail": "Not Found"}``: это ответ дефолтного
http_exception_handler FastAPI, тот же, что придёт из роутера. Тест
сравнивает два ЖИВЫХ ответа между собой, а не с этой константой, если
фреймворк сменит формулировку, покраснеет он, а не прод.
"""
if _ADMIN_API_RE.match(path):
logger.info("RBAC: anonymous probe of admin path %s — cloaked as 404", path)
return JSONResponse(status_code=404, content={"detail": "Not Found"})
return JSONResponse(status_code=401, content={"detail": detail})
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
@ -239,6 +324,11 @@ async def rbac_guard(
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
return await call_next(request)
# #3324: путь, которого нет в роутере, отвечает как любой несуществующий
# путь (404 роутера) — иначе 401 работает оракулом существования ручки.
if not _path_is_routed(request):
return await call_next(request)
username: str | None = None
role: str | None = None
from_session = False
@ -278,18 +368,12 @@ async def rbac_guard(
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
# отключён, даже если валидный X-Authenticated-User присутствует.
if settings.auth_mode != "dual":
return JSONResponse(
status_code=401,
content={"detail": "valid session required"},
)
return _unauthenticated(path, "valid session required")
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (valid session required)"},
)
return _unauthenticated(path, "no authenticated user (valid session required)")
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
@ -305,10 +389,7 @@ async def rbac_guard(
username,
path,
)
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
return _unauthenticated(path, "invalid or missing internal auth secret")
try:
role = get_role(username)
@ -334,7 +415,11 @@ async def rbac_guard(
# scope-narrowed юзер не смог бы получить свою роль вовсе.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
if from_session:
# Матчер выбирается по РОДУ роли, а не только по источнику (#3316):
# с DB-first резолвом legacy trusted-header путь тоже может отдать роль
# реестра (employee/manager), а её паттернов в roles.yaml нет — сверка
# с `is_path_allowed` дала бы 403 на всё.
if from_session or role in DB_ROLE_PATHS:
allowed = _db_role_path_allowed(role, external_path)
else:
try:

View file

@ -44,6 +44,7 @@ from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
from app.core.http_errors import install_validation_error_handler
from app.core.log_scrub import install_query_secret_filter
from app.core.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
@ -64,6 +65,11 @@ logging.basicConfig(
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)
# #3154: uvicorn access-log печатает полный путь С QUERY, а лог уезжает в Loki —
# так секрет вебхука GlitchTip (`?secret=…`) оказался в хранилище открытым.
# Маскируем значения чувствительных query-параметров ДО записи строки.
install_query_secret_filter()
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
@ -218,6 +224,16 @@ app = FastAPI(
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
version="0.1.0",
lifespan=lifespan,
# #3324: трейлинг-слэш обходил rbac_guard как канал разведки периметра.
# `/api/v1/me/` не матчит ни один маршрут → guard пропускает (см.
# rbac._path_is_routed) → роутер отвечал 307 на `/api/v1/me`, т.е. «маршрут
# существует» сообщал редирект вместо 401. Выключение проверено на предмет
# поломок: ни один route не объявлен с трейлинг-слэшем (нет `@router.get("/")`
# и пустых путей), ни один из 173 вызовов `api/v1` во фронте
# (tradein-mvp/frontend/src) не заканчивается слэшем, mount/StaticFiles нет.
# Deny-правила на слэш тоже не зависят от редиректа — они переведены на
# глоб-форму специально ради этого (см. auth_session.DB_ROLE_PATHS).
redirect_slashes=False,
)

View file

@ -101,15 +101,23 @@ def _should_run() -> bool:
return settings.scheduler_enable
async def _await_scheduler(task: asyncio.Task[None]) -> None:
async def _await_scheduler(task: asyncio.Task[None]) -> bool:
"""Дождаться завершения scheduler-задачи с кооперативным SIGTERM-drain'ом.
Возвращает True, если задачу пришлось хард-кансельнуть (grace истёк), False если
она вышла сама. Вызывающий обязан различать эти исходы в логах (#3391): строка
«drained cleanly» после hard-cancel'а — ложь, а именно она печаталась на проде
07.09 сразу за WARNING'ом о превышении grace.
- Нет shutdown: scheduler_loop бесконечен задача никогда не завершается, ждём её
как есть (процесс просто работает).
- shutdown запрошен, задача ещё бежит: bounded `wait_for(_DRAIN_TIMEOUT_S)`
кооперативная задача докоммитит текущий unit на ближайшем checkpoint'е и выйдет
сама. Если превысила grace hard-cancel + suppress CancelledError, чтобы
некооперативная задача не подвесила процесс за пределами docker stop_grace_period.
Пометку `interrupted` своим in-flight прогонам kit-scheduler успевает поставить в
обработчике CancelledError (SchedulerContext.mark_inflight_interrupted): между
hard-cancel'ом и SIGKILL'ом остаётся 20 с docker-grace (120s 100s).
"""
# Гонка «задача завершилась сама» против «пришёл SIGTERM»: отсчёт safety-net'а
# должен стартовать от МОМЕНТА запроса drain'а, а не от старта процесса.
@ -127,7 +135,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> None:
# упал (сохраняем прежнюю loud-crash семантику `await task`), а не глушит его.
task.result()
logger.info("scheduler_main: scheduler task exited cleanly")
return
return False
logger.info(
"scheduler_main: SIGTERM-drain — waiting up to %.0fs for in-flight unit to commit",
@ -136,6 +144,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> None:
try:
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
logger.info("scheduler_main: scheduler drained and exited cleanly")
return False
except TimeoutError:
logger.warning(
"scheduler_main: drain exceeded %.0fs grace — hard-cancelling scheduler task",
@ -144,6 +153,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> None:
task.cancel()
with suppress(asyncio.CancelledError):
await task
return True
async def _run_kit_scheduler() -> None:
@ -224,8 +234,12 @@ async def _run() -> None:
# Windows dev: signal handlers через loop не поддерживаются
logger.warning("scheduler_main: loop.add_signal_handler not supported (Windows dev)")
await _await_scheduler(task)
hard_cancelled = await _await_scheduler(task)
if hard_cancelled:
# Дрейн НЕ был чистым: задача не вышла сама, её сняли. WARNING об этом уже
# напечатан в _await_scheduler — второй строкой её не «переобъявляем».
return
if shutdown_requested():
logger.info("scheduler_main: scheduler drained cleanly (SIGTERM)")
else:

View file

@ -46,7 +46,7 @@ from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.auth import get_role
from app.core.auth import get_role, yaml_role
from app.core.config import settings
logger = logging.getLogger(__name__)
@ -61,8 +61,7 @@ def limit_exhausted_message(limit: int) -> str:
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
anon default_limit, см. #b2c-antiabuse-2)."""
return (
f"Лимит из {limit} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову."
)
@ -97,6 +96,14 @@ def is_unlimited(db: Session, username: str) -> bool:
return False
if role == "admin":
return True
# #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник
# team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом
# осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не
# раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать
# для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с
# `_batch_quota_status` в списке «Команды»).
if yaml_role(username) is None:
return False
row = db.execute(
text(
"""

View file

@ -56,6 +56,8 @@ from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
from scraper_kit.orchestration.runs import BAN_KIND_PLATFORM
@dataclass
class BlockRatioBreaker:
@ -88,7 +90,26 @@ class BlockRatioBreaker:
"""Знаменатель ratio-критерия: сколько попыток уже влезло в окно."""
return len(self._window)
def record_block(self) -> None:
def record_block(self, kind: str = BAN_KIND_PLATFORM) -> None:
"""Блок в числитель ratio-критерия — ТОЛЬКО отказ площадки (#3288).
`kind` диагноз того же исключения, что уходит в counters.ban_kinds
(ban_kind_of_exception). Всё, что не 'platform' (в первую очередь
BAN_KIND_INFRA: отказ нашего сайдкара, пустой пул), отказ НАШЕЙ стороны:
площадка его не показывала, и рвать по нему прогон значит объявить бан там,
где площадка молчала. Прогон 5425 оборвался по доле на 41 infra из 48
«блоков» при 41 успешно обогащённой карточке.
Такой отказ идёт ровно туда же, куда record_failure(): в ЗНАМЕНАТЕЛЬ окна
(доля платформенных блоков от него честно падает), мимо серии и safety-net.
Отдельно важно, что серию он не двигает: иначе safety-net на снапшоте
короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают.
"""
if kind != BAN_KIND_PLATFORM:
self.record_failure()
return
self._consecutive_blocks += 1
self._window.append(True)

View file

@ -0,0 +1,464 @@
"""Макро-ряды ЦБ РФ: ипотека по субъектам (XLSX) + ключевая ставка (SOAP).
CONTEXT: продукту нужен региональный макро-контекст ипотечного рынка (динамика ставок,
объёмов выдач и задолженности по субъектам РФ) и дневная ключевая ставка. Этот модуль
только собирает данные в cbr_mortgage_series / cbr_key_rate (292_cbr_macro_series.sql)
подключение к estimator вынесено в отдельное продуктовое решение (out of scope).
ИСТОЧНИК (ипотека): три XLSX-дашборда cbr.ru, wide-формат (территория × месяц),
строка 1 пустая, строка 2 заголовок-описание, строка 3 шапка периодов
(«Январь 2019», «Февраль 2019», ), строки 4+ территории в колонке A
(РФ целиком федеральные округа субъекты). Unpivot в длинный ряд
(region, period_month, series, value).
ИСТОЧНИК (ключевая ставка): POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx,
SOAP 1.1 (Content-Type: text/xml; charset=utf-8, SOAPAction: http://web.cbr.ru/KeyRate),
метод KeyRate(fromDate, ToDate). Ответ DataSet-XML со строками
<KR><DT>дата</DT><Rate>значение</Rate></KR> (namespace-агностичный парсинг: берём по
локальному имени тега, т.к. .NET DataSet оборачивает их в diffgram/reset-default-ns).
TLS: cbr.ru отдаёт RU-сертификат verify=False (open data, без auth/PII).
psycopg v3: CAST(:x AS type), НИКОГДА :x::type.
Дизайн-инвариант (как domrf_kapremont_loader): сервис-функции НЕ коммитят коммитит
caller (app/tasks/cbr_macro_pull.py). Изоляция сбоев try/except на серию,
db.rollback() в except (иначе аборченная транзакция каскадит на следующие серии).
"""
from __future__ import annotations
import io
import logging
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import date, datetime
import httpx
import openpyxl
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
DOWNLOAD_TIMEOUT_SEC = 60.0
# ---------------------------------------------------------------------------
# Ипотека по субъектам — конфиг серий
# ---------------------------------------------------------------------------
_MORTGAGE_BASE_URL = "https://www.cbr.ru/vfs/statistics/BankSector/Mortgage"
NEW_LOANS_URL = f"{_MORTGAGE_BASE_URL}/02_11_New_loans_mortgage.xlsx"
RATES_URL = f"{_MORTGAGE_BASE_URL}/02_13_Rates_mortgage.xlsx"
DEBT_URL = f"{_MORTGAGE_BASE_URL}/02_14_Debt_mortgage.xlsx"
@dataclass(frozen=True)
class CbrMortgageSeries:
slug: str
url: str
sheet: str
# По одной серии на файл (лист «в рублях» — основной ряд каждого дашборда).
CBR_MORTGAGE_SERIES: list[CbrMortgageSeries] = [
CbrMortgageSeries("new_loans_rub", NEW_LOANS_URL, "в рублях"),
CbrMortgageSeries("rate_rub", RATES_URL, "ставка в рублях"),
CbrMortgageSeries("debt_rub", DEBT_URL, "в рублях"),
]
@dataclass(slots=True, frozen=True)
class CbrMortgageRow:
region: str
period_month: date
series: str
value: float
# ---------------------------------------------------------------------------
# Период: русские имена месяцев → date(y, m, 1)
# ---------------------------------------------------------------------------
RU_MONTHS: dict[str, int] = {
"Январь": 1,
"Февраль": 2,
"Март": 3,
"Апрель": 4,
"Май": 5,
"Июнь": 6,
"Июль": 7,
"Август": 8,
"Сентябрь": 9,
"Октябрь": 10,
"Ноябрь": 11,
"Декабрь": 12,
}
def parse_ru_period(period_str: str) -> date:
"""Разобрать шапку периода «Январь 2019» → date(2019, 1, 1)."""
parts = str(period_str).strip().split()
if len(parts) != 2:
raise ValueError(f"cbr_macro: не удалось разобрать период {period_str!r}")
month_name, year_str = parts
month = RU_MONTHS.get(month_name)
if month is None:
raise ValueError(f"cbr_macro: неизвестное имя месяца {month_name!r} в {period_str!r}")
try:
year = int(year_str)
except ValueError as exc:
raise ValueError(f"cbr_macro: неверный год в периоде {period_str!r}") from exc
return date(year, month, 1)
def parse_period_cell(cell: object) -> date:
"""Разобрать ячейку шапки периода в первое число месяца.
ЦБ отдаёт шапку в двух формах, зависящих от файла:
* текст «Январь 2019» 02_11_New_loans_mortgage, 02_13_Rates_mortgage;
* настоящий datetime/date 02_14_Debt_mortgage.
Обе приводятся к date(y, m, 1).
"""
if isinstance(cell, datetime):
return date(cell.year, cell.month, 1)
if isinstance(cell, date):
return date(cell.year, cell.month, 1)
return parse_ru_period(str(cell))
# ---------------------------------------------------------------------------
# Unpivot листа (чистая функция — тестируется без сети/файла)
# ---------------------------------------------------------------------------
HEADER_SCAN_ROWS = 6
"""Сколько первых строк просматривать в поисках шапки периодов."""
MIN_HEADER_PERIODS = 6
"""Минимум распознанных периодов, чтобы считать строку шапкой (а не данными)."""
def _locate_header_row(rows: list[tuple[object, ...]]) -> tuple[int, dict[int, date]]:
"""Найти строку шапки периодов и колонки-периоды в ней.
Раскладка у файлов ЦБ РАЗНАЯ и жёстко фиксировать индекс строки нельзя:
* 02_11 / 02_13 строка 1 пустая, строка 2 описание, шапка в строке 3 (idx 2);
* 02_14 (задолженность) строка 1 описание, шапка уже в строке 2 (idx 1),
и периоды там datetime, а не «Январь 2019».
Ранее индекс был захардкожен на idx 2, из-за чего серия задолженности молча
давала ноль строк. Поэтому шапку ищем: берём строку с наибольшим числом
распознанных периодов среди первых HEADER_SCAN_ROWS.
"""
best_idx = -1
best_periods: dict[int, date] = {}
for row_idx, row in enumerate(rows[:HEADER_SCAN_ROWS]):
periods: dict[int, date] = {}
for col_idx, cell in enumerate(row):
if col_idx == 0 or cell is None or str(cell).strip() == "":
continue
try:
periods[col_idx] = parse_period_cell(cell)
except ValueError:
continue
if len(periods) > len(best_periods):
best_idx, best_periods = row_idx, periods
if len(best_periods) < MIN_HEADER_PERIODS:
return -1, {}
return best_idx, best_periods
def parse_mortgage_sheet(
rows: list[tuple[object, ...]], *, series_slug: str
) -> list[CbrMortgageRow]:
"""Unpivot wide-листа ЦБ (территория × месяц) в длинный ряд.
rows результат ws.iter_rows(values_only=True). Строка шапки периодов ищется
динамически (`_locate_header_row`), территории идут сразу после неё.
"""
if len(rows) < 3:
return []
header_idx, periods = _locate_header_row(rows)
if header_idx < 0:
logger.warning(
"cbr_macro: шапка периодов не найдена для series=%s — лист пропущен", series_slug
)
return []
out: list[CbrMortgageRow] = []
for data_row in rows[header_idx + 1 :]:
if not data_row or data_row[0] is None:
continue
region = str(data_row[0]).strip()
if not region:
continue
for col_idx, period_month in periods.items():
if col_idx >= len(data_row):
continue
raw_value = data_row[col_idx]
if raw_value is None:
continue
try:
value = float(raw_value)
except (TypeError, ValueError):
logger.warning(
"cbr_macro: пропуск ячейки region=%r period=%s series=%s — не число: %r",
region,
period_month,
series_slug,
raw_value,
)
continue
out.append(
CbrMortgageRow(
region=region, period_month=period_month, series=series_slug, value=value
)
)
return out
# ---------------------------------------------------------------------------
# Загрузка XLSX + upsert
# ---------------------------------------------------------------------------
_UPSERT_MORTGAGE_SQL = text("""
INSERT INTO cbr_mortgage_series (region, period_month, series, value, source, fetched_at)
VALUES (
CAST(:region AS text),
CAST(:period_month AS date),
CAST(:series AS text),
CAST(:value AS double precision),
'cbr',
now()
)
ON CONFLICT (region, period_month, series)
DO UPDATE SET
value = EXCLUDED.value
-- fetched_at НЕ трогаем (#2846 у sber_price_index): означает
-- «когда мы ВПЕРВЫЕ увидели этот период», а не время последней загрузки.
""")
def _upsert_mortgage_rows(db: Session, rows: list[CbrMortgageRow]) -> int:
for row in rows:
db.execute(
_UPSERT_MORTGAGE_SQL,
{
"region": row.region,
"period_month": row.period_month.isoformat(),
"series": row.series,
"value": row.value,
},
)
return len(rows)
def _download(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def load_cbr_mortgage_series(
db: Session,
series: CbrMortgageSeries,
*,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачать один XLSX-дашборд, разобрать один лист, upsert-нуть ряд.
Не коммитит коммитит caller.
"""
own_client = client is None
if own_client:
# cbr.ru отдаёт RU-сертификат НУЦ Минцифры → verify=False. Открытые данные,
# без auth/PII (прецедент: sber_index.py, domrf_kapremont_loader.py).
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
data = _download(series.url, client=client)
finally:
if own_client:
client.close()
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
try:
if series.sheet not in wb.sheetnames:
raise ValueError(
f"cbr_macro: лист {series.sheet!r} отсутствует в {series.url} "
f"(есть: {wb.sheetnames!r})"
)
ws = wb[series.sheet]
rows_raw = list(ws.iter_rows(values_only=True))
finally:
wb.close()
parsed = parse_mortgage_sheet(rows_raw, series_slug=series.slug)
upserted = 0 if dry_run else _upsert_mortgage_rows(db, parsed)
return {"rows": len(parsed), "upserted": upserted}
def pull_cbr_mortgage(
db: Session,
*,
series_list: list[CbrMortgageSeries] | None = None,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Забрать все (или выбранные) серии ипотечной статистики.
Per-series try/except: одна сбойнувшая серия логируется и не роняет остальные
(урок #1345 у sber_index: без rollback аборченная транзакция каскадит дальше).
"""
if series_list is None:
series_list = CBR_MORTGAGE_SERIES
counters = {"upserted": 0, "skipped": 0, "errors": 0}
own_client = client is None
if own_client:
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
for series in series_list:
try:
result = load_cbr_mortgage_series(db, series, client=client, dry_run=dry_run)
if result["rows"] == 0:
logger.info("cbr_macro: пустой результат для series=%s", series.slug)
counters["skipped"] += 1
else:
counters["upserted"] += result["upserted"]
logger.info(
"cbr_macro: upserted %d rows for series=%s", result["upserted"], series.slug
)
except Exception:
db.rollback()
logger.exception("cbr_macro: series=%s сбойнула — пропуск", series.slug)
counters["errors"] += 1
finally:
if own_client:
client.close()
return counters
# ---------------------------------------------------------------------------
# Ключевая ставка — SOAP
# ---------------------------------------------------------------------------
CBR_SOAP_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
KEY_RATE_START = date(2013, 9, 13) # инструмент введён Советом директоров ЦБ РФ
_KEY_RATE_ENVELOPE = """<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" \
xmlns:xsd="http://www.w3.org/2001/XMLSchema" \
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<KeyRate xmlns="http://web.cbr.ru/">
<fromDate>{from_date}</fromDate>
<ToDate>{to_date}</ToDate>
</KeyRate>
</soap:Body>
</soap:Envelope>"""
def build_key_rate_envelope(from_date: date, to_date: date) -> str:
return _KEY_RATE_ENVELOPE.format(from_date=from_date.isoformat(), to_date=to_date.isoformat())
def _local_tag(tag: str) -> str:
"""Локальное имя тега без namespace-префикса ('{ns}KR''KR')."""
return tag.rsplit("}", 1)[-1]
def parse_key_rate_response(xml_text: str) -> list[tuple[date, float]]:
"""Разобрать SOAP-ответ KeyRate: строки <KR><DT>…</DT><Rate>…</Rate></KR>.
Namespace-агностично (ищем по локальному имени тега) .NET DataSet
оборачивает строки в diffgram/reset-default-ns, точный путь не гарантирован.
"""
root = ET.fromstring(xml_text)
out: list[tuple[date, float]] = []
for el in root.iter():
if _local_tag(el.tag) != "KR":
continue
dt_text: str | None = None
rate_text: str | None = None
for child in el:
name = _local_tag(child.tag)
if name == "DT":
dt_text = child.text
elif name == "Rate":
rate_text = child.text
if not dt_text or rate_text is None:
continue
try:
rate_date = datetime.fromisoformat(dt_text).date()
rate = float(rate_text)
except (ValueError, TypeError):
logger.warning(
"cbr_macro: пропуск строки KeyRate — не распознана: DT=%r Rate=%r",
dt_text,
rate_text,
)
continue
out.append((rate_date, rate))
return out
def fetch_key_rate(
client: httpx.Client, *, from_date: date, to_date: date
) -> list[tuple[date, float]]:
body = build_key_rate_envelope(from_date, to_date)
headers = {
"Content-Type": "text/xml; charset=utf-8",
"SOAPAction": "http://web.cbr.ru/KeyRate",
}
resp = client.post(CBR_SOAP_URL, content=body.encode("utf-8"), headers=headers)
resp.raise_for_status()
return parse_key_rate_response(resp.text)
_UPSERT_KEY_RATE_SQL = text("""
INSERT INTO cbr_key_rate (rate_date, rate, fetched_at)
VALUES (CAST(:rate_date AS date), CAST(:rate AS double precision), now())
ON CONFLICT (rate_date)
DO UPDATE SET
rate = EXCLUDED.rate
-- fetched_at НЕ трогаем та же логика, что и у cbr_mortgage_series.
""")
def _upsert_key_rate_rows(db: Session, rows: list[tuple[date, float]]) -> int:
for rate_date, rate in rows:
db.execute(_UPSERT_KEY_RATE_SQL, {"rate_date": rate_date.isoformat(), "rate": rate})
return len(rows)
def load_key_rate(
db: Session,
*,
from_date: date | None = None,
to_date: date | None = None,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачать ключевую ставку за диапазон дат, upsert-нуть в cbr_key_rate.
Не коммитит коммитит caller. from_date/to_date по умолчанию вся история
инструмента (KEY_RATE_START) до сегодня.
"""
if from_date is None:
from_date = KEY_RATE_START
if to_date is None:
to_date = date.today()
own_client = client is None
if own_client:
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
rows = fetch_key_rate(client, from_date=from_date, to_date=to_date)
finally:
if own_client:
client.close()
upserted = 0 if dry_run else _upsert_key_rate_rows(db, rows)
result = {"rows": len(rows), "upserted": upserted}
logger.info("cbr_macro: key_rate load DONE (dry_run=%s): %s", dry_run, result)
return result

View file

@ -34,25 +34,6 @@ from app.services.scraper_settings import get_scraper_delay
logger = logging.getLogger(__name__)
class _PoolCurlConfig(RealScraperConfig):
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
services.scraper.environment), а этот бэкфилл запускается ручкой
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
`settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
Флаг рубильник раскатки pool-режима для планировщика, а не решение «этому пути
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
"""
@property
def use_proxy_pool_curl(self) -> bool:
return True
@dataclass
class CianPriceHistoryResult:
checked: int = 0
@ -84,7 +65,13 @@ async def backfill_cian_price_history(
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
# (mark_banned на CianBlockedError / mark_health / release).
scraper_config = _PoolCurlConfig()
#
# Флаг читается из окружения как у всех (#3386 хвост): до #3387 у контейнера
# `backend` не было `USE_PROXY_POOL_CURL`, и здесь стоял подкласс с зашитым
# `use_proxy_pool_curl = True` — иначе `curl_proxy_url` игнорировал бы
# `proxy_provider`. Теперь переменная задана и сервису `backend` (compose), а
# зашитая константа делала рубильник неотключаемым ровно на этом пути.
scraper_config = RealScraperConfig()
proxy_provider = RealProxyProvider()
if listing_id is not None:

View file

@ -0,0 +1,157 @@
"""Радиусные агрегаты ДТП вокруг точки — `dtp_incidents` (#3410).
Отдельный модуль, не расширение `location_index.py`: та модель считает ЦЕНОВОЙ индекс
(медиана /м² локально vs по городу) с фиксированным bbox продукт-ядра города другая
предметная область и другой контракт результата (status/coverage у location_index
завязан на `regions_mod`/`bbox_product_core`, ДТП покрывает всю область без city-bbox
ограничения источника). Общее только сам паттерн запроса (bbox-префильтр +
`ST_DWithin(geom::geography, )`) и dtp_incidents/osm_poi_ekb_local roднит один и тот же
"пустая таблица -> unavailable" graceful fallback (см. `_fetch_nearby_poi` в
location_index.py) переиспользован здесь буквально.
Bbox-префильтр здесь, в отличие от location_index (там фиксированный bbox city-ядра),
строится ВОКРУГ ТОЧКИ по радиусу (`_bbox_from_point`) dtp_incidents не ограничена
одним городом, фиксированного bbox покрытия нет. Дешёвый lat/lon BETWEEN перед
ST_DWithin тот же приём, что в шаблоне recon_geo.md п.3.
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
"""
from __future__ import annotations
import logging
import math
from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Радиус по умолчанию для "фактора безопасности" района — сопоставим по порядку
# величины с DEFAULT_POI_RADIUS_M=1200 в location_index.py (тот же масштаб "пешком от
# дома"), но не импортируется оттуда: разные предметные области, совпадение числа
# не должно создавать ложную связь между модулями.
DEFAULT_DTP_RADIUS_M = 1000
# Сколько лет ДТП назад учитывать по умолчанию — источник копит данные с 2015 (см.
# докстринг dtp_stat_loader.py), но "фактор безопасности" района должен отражать
# ТЕКУЩУЮ ситуацию, а не десятилетнюю историю.
DEFAULT_YEARS_LOOKBACK = 5
_METERS_PER_DEGREE_LAT = 111_320.0
def _bbox_from_point(lat: float, lon: float, radius_m: int) -> tuple[float, float, float, float]:
"""(south, north, west, east) — bbox вокруг точки радиусом radius_m (приближённо)."""
lat_delta = radius_m / _METERS_PER_DEGREE_LAT
lon_scale = math.cos(math.radians(lat))
# Защита от вырождения делителя у полюсов — для Свердловской обл. (~56-61° с.ш.)
# cos никогда не приближается к нулю, но защита дешёвая и делает функцию safe везде.
lon_delta = radius_m / (_METERS_PER_DEGREE_LAT * max(lon_scale, 0.01))
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
class DtpAreaStats(BaseModel):
"""Результат compute_dtp_stats."""
status: str # "ok" | "unavailable" (dtp_incidents пуста — рефреш ещё не запускался)
radius_m: int
years: int
incidents_count: int
severe_count: int
dead: int
injured: int
_DTP_STATS_SQL = text(
"""
SELECT
count(*) AS incidents_count,
count(*) FILTER (WHERE severity = 'Тяжёлый') AS severe_count,
COALESCE(sum(dead), 0) AS dead_total,
COALESCE(sum(injured), 0) AS injured_total
FROM dtp_incidents
WHERE dtp_at >= NOW() - make_interval(years => CAST(:years AS integer))
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
AND ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
CAST(:radius_m AS double precision)
)
"""
)
def compute_dtp_stats(
db: Any,
lat: float,
lon: float,
*,
radius_m: int = DEFAULT_DTP_RADIUS_M,
years: int = DEFAULT_YEARS_LOOKBACK,
) -> DtpAreaStats:
"""Посчитать агрегаты ДТП в радиусе `radius_m` вокруг точки за последние `years` лет.
Graceful fallback: `dtp_incidents` пуста (рефреш ещё не запускался на этом
окружении, source dormant по умолчанию миграция 295) -> status="unavailable",
нули НЕ фабрикуются. Ноль инцидентов при непустой таблице легитимный "ok"
результат (в радиусе правда ничего не было), отличается статусом от "нет данных".
"""
total = db.execute(text("SELECT count(*) FROM dtp_incidents")).scalar() or 0
if total == 0:
logger.warning(
"dtp_index: dtp_incidents пуста (рефреш ещё не запускался на этом "
"окружении) — unavailable, без сфабрикованных нулей"
)
return DtpAreaStats(
status="unavailable",
radius_m=radius_m,
years=years,
incidents_count=0,
severe_count=0,
dead=0,
injured=0,
)
bbox_south, bbox_north, bbox_west, bbox_east = _bbox_from_point(lat, lon, radius_m)
row = (
db.execute(
_DTP_STATS_SQL,
{
"lat": lat,
"lon": lon,
"radius_m": radius_m,
"years": years,
"bbox_south": bbox_south,
"bbox_north": bbox_north,
"bbox_west": bbox_west,
"bbox_east": bbox_east,
},
)
.mappings()
.first()
)
if row is None:
return DtpAreaStats(
status="ok",
radius_m=radius_m,
years=years,
incidents_count=0,
severe_count=0,
dead=0,
injured=0,
)
return DtpAreaStats(
status="ok",
radius_m=radius_m,
years=years,
incidents_count=int(row["incidents_count"] or 0),
severe_count=int(row["severe_count"] or 0),
dead=int(row["dead_total"] or 0),
injured=int(row["injured_total"] or 0),
)

View file

@ -0,0 +1,281 @@
"""dtp-stat.ru loader: слой ДТП по Свердловской обл. в `dtp_incidents` (#3410).
CONTEXT: карточка района МЕРЫ хочет "фактор безопасности" сколько ДТП/тяжёлых/
погибших/раненых было рядом за последние годы. dtp-stat.ru агрегирует открытые данные
ГИБДД в GeoJSON по регионам, без auth.
ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip ZIP
(~5 МБ) с одним файлом `sverdlovskaia-oblast.geojson` (~71 МБ распакованного,
FeatureCollection, ~31k Point-фич). Проверено живьём 08.09.2026: HTTP 200,
5 047 685 байт. Домен обычный (не RU-gov минцифровский сертификат) verify=False
НЕ ставим, лишний.
МОНИТОРИНГ ПРОТУХАНИЯ (не реализован в этом PR зафиксировано, чтобы следующий не
считал пустую дельту багом): на момент проверки 08.09.2026 дамп источника заморожен,
Last-Modified 26.02.2026. TRUNCATE+INSERT переливает те же ~31k строк каждый рефреш
это ожидаемо, а не признак сломанного парсинга. Если нужен freshness-монитор см.
паттерн `recon_macro.md` (СберИндекс), здесь не сделан осознанно (вне скоупа #3410).
СТРИМИНГ (обязательно, файл большой): `json.load()` целиком держал бы ~71 МБ + объектный
граф в памяти разом. Используем `ijson.items(stream, "features.item")` поверх
файлового объекта из `zipfile.ZipFile.open(name)` постоянная память вне зависимости
от размера файла, каждая feature обрабатывается и отбрасывается по одной.
ПДн НЕ СОХРАНЯЕМ (осознанное решение, зафиксировано и в 294_dtp_incidents.sql):
`properties.vehicles[].participants[]` источника несёт пол/роль/нарушения физлиц
это персональные данные конкретных людей, продукту не нужны и не разрешены. Парсер
(`_parse_feature`) НИКОГДА не читает ключи `vehicles`/`participants` ни в raw-jsonb,
ни отдельной колонкой. Из `properties` берём только неперсональные агрегаты и атрибуты
происшествия (see DtpIncidentRow).
Идемпотентность: TRUNCATE + bulk INSERT в одной транзакции (тот же паттерн, что
`app/tasks/osm_poi_ekb_refresh.py` полная замена, не upsert; повторный прогон с тем
же дампом даёт тот же результат).
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
Массивы (`weather`, `nearby`, тип `text[]`) psycopg v3 адаптирует Python `list[str]`
в `text[]` напрямую через `CAST(:x AS text[])` (тот же идиом, что
`app/tasks/deactivate_stale_avito.py`).
"""
from __future__ import annotations
import io
import logging
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import IO, Any
import httpx
import ijson
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
SRC_URL = "https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip"
DOWNLOAD_TIMEOUT_SEC = 180
INSERT_CHUNK_SIZE = 2000
# properties.datetime — "2015-01-01 03:00:00", без явной таймзоны в источнике.
# Храним как naive → колонка timestamptz получит его в timezone сессии БД (обычно UTC).
# Источник не публикует TZ, поэтому точная привязка к МСК не гарантирована — для
# радиусных агрегатов по годам (dtp_index.py) это несущественно.
_DT_FMT = "%Y-%m-%d %H:%M:%S"
@dataclass(slots=True)
class DtpIncidentRow:
"""Одна строка ДТП — ТОЛЬКО неперсональные атрибуты (см. докстринг модуля)."""
source_id: str
dtp_at: datetime | None
category: str | None
severity: str | None
dead: int | None
injured: int | None
address: str | None
light: str | None
weather: list[str] | None
nearby: list[str] | None
lat: float
lon: float
def _parse_datetime(raw: Any) -> datetime | None:
if not isinstance(raw, str) or not raw.strip():
return None
try:
return datetime.strptime(raw.strip(), _DT_FMT)
except ValueError:
logger.warning("dtp_stat_loader: не распарсен datetime %r", raw)
return None
def _parse_str_list(raw: Any) -> list[str] | None:
if not isinstance(raw, list) or not raw:
return None
return [str(item) for item in raw if item is not None]
def _parse_int(raw: Any) -> int | None:
if isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
return None
# Санити-рамка координат. Выгрузка dtp-stat по Свердловской области содержит битые
# точки: на реальном дампе 2026-02-26 из 31 253 записей 74 лежат вне широт региона,
# в том числе 10 ровно в (0.0, 0.0) и такие значения, как (1.0, 1.0) и (41.0, 47.0).
# Пускать их в geo-таблицу нельзя: слой кормит радиусные агрегаты локации.
# Рамка намеренно шире официальных границ области (~56.0-62.3 N, ~57.2-66.2 E),
# чтобы не срезать легитимные приграничные точки.
REGION_LAT_RANGE = (55.0, 63.5)
REGION_LON_RANGE = (56.0, 67.5)
def _coords_plausible(lat: float, lon: float) -> bool:
"""Точка похожа на реальное ДТП в регионе выгрузки, а не на мусор источника."""
if lat == 0.0 and lon == 0.0:
return False
if not (REGION_LAT_RANGE[0] <= lat <= REGION_LAT_RANGE[1]):
return False
return REGION_LON_RANGE[0] <= lon <= REGION_LON_RANGE[1]
def _parse_feature(feature: dict[str, Any]) -> DtpIncidentRow | None:
"""Чистая функция: одна GeoJSON Feature -> DtpIncidentRow, либо None (пропуск).
НЕ читает `properties.vehicles` / `properties.participants` намеренно (ПДн,
см. докстринг модуля). Пропускает фичи без валидной Point-геометрии/id, а также
с координатами вне санити-рамки региона (см. `_coords_plausible`).
"""
geometry = feature.get("geometry") or {}
if geometry.get("type") != "Point":
return None
coords = geometry.get("coordinates")
if not isinstance(coords, list) or len(coords) < 2:
return None
try:
lon, lat = float(coords[0]), float(coords[1])
except (TypeError, ValueError):
return None
if not _coords_plausible(lat, lon):
return None
props = feature.get("properties") or {}
source_id = props.get("id")
if source_id is None:
return None
return DtpIncidentRow(
source_id=str(source_id),
dtp_at=_parse_datetime(props.get("datetime")),
category=props.get("category") if isinstance(props.get("category"), str) else None,
severity=props.get("severity") if isinstance(props.get("severity"), str) else None,
dead=_parse_int(props.get("dead_count")),
injured=_parse_int(props.get("injured_count")),
address=props.get("address") if isinstance(props.get("address"), str) else None,
light=props.get("light") if isinstance(props.get("light"), str) else None,
weather=_parse_str_list(props.get("weather")),
nearby=_parse_str_list(props.get("nearby")),
lat=lat,
lon=lon,
)
def iter_incidents(stream: IO[bytes]) -> Iterator[DtpIncidentRow]:
"""Потоковый парс GeoJSON FeatureCollection -> DtpIncidentRow, по одной фиче за раз.
`stream` бинарный файловый объект (из `zipfile.ZipFile.open()` или обычного
`open(path, "rb")`). Использует `ijson.items(..., "features.item")` постоянная
память, НЕ читает файл целиком.
"""
for feature in ijson.items(stream, "features.item"):
row = _parse_feature(feature)
if row is not None:
yield row
def _open_geojson_in_zip(zf: zipfile.ZipFile) -> IO[bytes]:
names = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
if not names:
raise ValueError(f"zip не содержит .geojson записей: {zf.namelist()!r}")
return zf.open(names[0])
def download_dtp_zip(*, client: httpx.Client) -> bytes:
"""Скачать ZIP dtp-stat.ru. Открытые данные, без auth/PII — стандартный verify."""
resp = client.get(SRC_URL, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
_TRUNCATE_SQL = text("TRUNCATE dtp_incidents")
_INSERT_SQL = text(
"""
INSERT INTO dtp_incidents
(source_id, dtp_at, category, severity, dead, injured, address, light,
weather, nearby, lat, lon, geom)
VALUES
(CAST(:source_id AS text), CAST(:dtp_at AS timestamptz),
CAST(:category AS text), CAST(:severity AS text),
CAST(:dead AS integer), CAST(:injured AS integer),
CAST(:address AS text), CAST(:light AS text),
CAST(:weather AS text[]), CAST(:nearby AS text[]),
CAST(:lat AS double precision), CAST(:lon AS double precision),
ST_SetSRID(ST_MakePoint(CAST(:lon AS double precision), CAST(:lat AS double precision)),
4326))
ON CONFLICT (source_id) DO NOTHING
"""
)
def _row_params(row: DtpIncidentRow) -> dict[str, Any]:
return {
"source_id": row.source_id,
"dtp_at": row.dtp_at,
"category": row.category,
"severity": row.severity,
"dead": row.dead,
"injured": row.injured,
"address": row.address,
"light": row.light,
"weather": row.weather,
"nearby": row.nearby,
"lat": row.lat,
"lon": row.lon,
}
def load_dtp_incidents(
db: Session,
*,
src_path: str | Path | None = None,
client: httpx.Client | None = None,
chunk_size: int = INSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""TRUNCATE dtp_incidents; потоковый парс + bulk INSERT из ZIP dtp-stat.ru.
`src_path` локальный ZIP (тесты/ручной прогон); если None качаем `SRC_URL`
через `client` (обязателен параметром, чтобы был чем подменить в тестах;
создаётся временный `httpx.Client()` если не передан).
`dry_run=True` парсит и считает строки, НЕ трогает БД (ни TRUNCATE, ни INSERT).
Не коммитит коммитит caller (app/tasks/dtp_stat_refresh.py).
"""
owns_client = client is None
client = client or httpx.Client()
try:
if src_path is not None:
with zipfile.ZipFile(src_path) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
else:
data = download_dtp_zip(client=client)
with zipfile.ZipFile(io.BytesIO(data)) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
finally:
if owns_client:
client.close()
parsed = len(rows)
inserted = 0
if not dry_run:
db.execute(_TRUNCATE_SQL)
for i in range(0, len(rows), chunk_size):
chunk = rows[i : i + chunk_size]
result = db.execute(_INSERT_SQL, [_row_params(r) for r in chunk])
inserted += result.rowcount or 0
result_counts = {"parsed": parsed, "inserted": inserted}
logger.info("dtp_stat_loader load DONE (dry_run=%s): %s", dry_run, result_counts)
return result_counts

View file

@ -51,6 +51,7 @@ from scraper_kit.providers.yandex.valuation import (
YandexValuationResult,
YandexValuationScraper,
)
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -65,6 +66,7 @@ from app.schemas.trade_in import (
PriceTrendPoint,
TradeInEstimateInput,
)
from app.services import regions as regions_mod
from app.services.dadata import DadataAddressResult
# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга
@ -79,7 +81,7 @@ from app.services.geocoder import (
)
from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house
from app.services.scraper_adapters import RealScraperConfig
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
from app.services.scraper_settings import get_scraper_delay
from app.tasks.asking_to_sold_ratio import area_bucket
@ -343,22 +345,24 @@ DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ →
# глобальный пол 50k ₽/м² для дешёвых городов области НЕ anti-outlier guard, а
# cut-off легитимного рынка (Североуральск median ≈21.7k, Новоуральск ≈36k,
# Асбест ≈41k — все ниже 50k → 46.6% не-ЕКБ сделок молча дропались).
# deal_city_price_bands (миграция 178) — per-city [ppm2_min, ppm2_max] band из
# перцентилей p1/p99 реальных rosreestr-сделок города (hard floor/ceiling
# 8000/800000 всё равно режут доли/опечатки). Екатеринбург НАМЕРЕННО исключён
# из таблицы — .get(city, ...) fallback на глобальные DEAL_MIN_PPM2/MAX_PPM2
# ниже гарантирует byte-identical ЕКБ-поведение.
# deal_city_price_bands (миграция 178, ключ (region_code, city) — миграция 298
# #3051) — per-(region, city) [ppm2_min, ppm2_max] band из перцентилей p1/p99
# реальных rosreestr-сделок города (hard floor/ceiling 8000/800000 всё равно
# режут доли/опечатки). Екатеринбург (region_code=66) НАМЕРЕННО исключён из
# таблицы — .get((region_code, city), ...) fallback на глобальные
# DEAL_MIN_PPM2/MAX_PPM2 ниже гарантирует byte-identical ЕКБ-поведение.
_CITY_PRICE_BANDS_CACHE_TTL_S = 300.0
_city_price_bands_cache: tuple[dict[str, tuple[int, int]], float] | None = None
_city_price_bands_cache: tuple[dict[tuple[int, str], tuple[int, int]], float] | None = None
def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
"""#2478: {city: (ppm2_min, ppm2_max)} из deal_city_price_bands (миграция 178).
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.
Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) таблица рефрешится
периодическим ре-запуском миграции, не на каждый estimate. Таблицы нет /
любая ошибка {} (graceful) вызывающий код fallback'ит на глобальные
DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е. поведение как до #2478.
периодическим ре-запуском derivation (298 / deal_city_price_bands_refresh),
не на каждый estimate. Таблицы нет / любая ошибка {} (graceful)
вызывающий код fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е.
поведение как до #2478.
"""
global _city_price_bands_cache
if _city_price_bands_cache is not None:
@ -367,14 +371,18 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
return bands
try:
rows = (
db.execute(text("SELECT city, ppm2_min, ppm2_max FROM deal_city_price_bands"))
db.execute(
text("SELECT region_code, city, ppm2_min, ppm2_max FROM deal_city_price_bands")
)
.mappings()
.all()
)
except Exception as exc:
logger.warning("deal_city_price_bands lookup failed (graceful): %s", exc)
return {}
bands = {r["city"]: (int(r["ppm2_min"]), int(r["ppm2_max"])) for r in rows}
bands = {
(int(r["region_code"]), r["city"]): (int(r["ppm2_min"]), int(r["ppm2_max"])) for r in rows
}
_city_price_bands_cache = (bands, time.monotonic())
return bands
@ -794,6 +802,11 @@ async def _get_or_fetch_imv_cached(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
# #3386: без provider'а `curl_proxy_url` считает use_pool=False (флаг AND
# provider is not None) и уходит на env-прокси SCRAPER_PROXY_URL — мёртвый
# узел (#2613). Один lease на вызов: acquire/release внутри curl_proxy_url,
# release в finally на всех выходах (исключение/таймаут — тоже).
proxy_provider=RealProxyProvider(),
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
logger.info(
@ -827,6 +840,7 @@ async def _get_or_fetch_imv_cached(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
proxy_provider=RealProxyProvider(), # #3386, см. первый вызов выше
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
logger.info(
@ -852,7 +866,14 @@ async def _get_or_fetch_imv_cached(
logger.warning("imv: transient error, skipping retry in estimator context: %s", e)
return None
except Exception as e:
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
if caused_by_no_proxy(e):
# #3386: «пул прокси пуст» — НАША инфраструктура, не сбой фетча и не
# transient-ошибка площадки; HTTP-запрос вообще не уходил. Отдельный текст,
# чтобы в логах /estimate это не читалось как «Авито отвалился».
# Возврат None — тот же graceful путь: ответ отдаётся без IMV-якоря, не 5xx.
logger.warning("imv: пул прокси пуст — estimator продолжает без IMV: %s", e)
else:
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
return None
@ -892,8 +913,18 @@ def _defer_external_refresh(label: str, work: Callable[[Session], Awaitable[obje
db = SessionLocal()
try:
await work(db)
except Exception:
logger.exception("deferred %s: догрузка не удалась (кэш не прогрет)", label)
except Exception as exc:
if caused_by_no_proxy(exc):
# #3398: пустой пул — НАША инфраструктура, HTTP-запрос не уходил вовсе
# (NoProxyAvailableError поднимается в curl_proxy_url ДО запроса). WARNING,
# не ERROR: GlitchTip слушает event_level=ERROR, а это штатная деградация
# прогрева, не сбой площадки — событие тут было бы шумом.
# На проде ESTIMATE_EXTERNAL_SOURCES_BACKGROUND=true, поэтому настоящий
# фетч уходит именно сюда: synchronous-ветка с fetch_on_miss=False отдаёт
# None ДО прокси-слоя и до своего WARNING в estimate_quality не доходит.
logger.warning("deferred %s: пул прокси пуст — кэш не прогрет: %s", label, exc)
else:
logger.exception("deferred %s: догрузка не удалась (кэш не прогрет)", label)
finally:
db.close()
@ -992,14 +1023,27 @@ async def _get_or_fetch_yandex_valuation_cached(
# Fresh fetch
try:
async with YandexValuationScraper(
RealScraperConfig(), delay_provider=get_scraper_delay
RealScraperConfig(),
delay_provider=get_scraper_delay,
# #3398: без provider'а `curl_proxy_url` считает use_pool=False (флаг AND
# provider is not None) — пул выключен по построению, curl-сессия уходит на
# env-прокси SCRAPER_PROXY_URL (мёртвый узел #2613 → 407 на каждой пробе).
# Lease один на сессию: acquire в __aenter__, release в __aexit__ — в т.ч.
# на исключении и на отмене по таймауту _with_budget.
proxy_provider=RealProxyProvider(),
) as scraper:
result = await scraper.fetch_house_history(
address=address,
offer_category=offer_category,
offer_type=offer_type,
)
except Exception:
except Exception as e:
if caused_by_no_proxy(e):
# #3398: «пул прокси пуст» — НАША инфраструктура, HTTP-запрос не уходил вовсе.
# Отдельный текст (и WARNING, не ERROR): это не сбой Яндекса, GlitchTip-событие
# здесь было бы шумом. Возврат None — тот же graceful путь, /estimate не 5xx.
logger.warning("yandex_valuation: пул прокси пуст — продолжаем без Yandex: %s", e)
return None
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
@ -1009,7 +1053,15 @@ async def _get_or_fetch_yandex_valuation_cached(
return None
if result is None:
logger.info("yandex_valuation: empty result for address=%s", address[:60])
# #3398: None здесь НЕразличимо «дом не найден» и «фетч не дошёл» — kit ловит
# ProxyError/HTTP-код внутри fetch_house_history и логирует своей строкой
# («yandex valuation fetch failed» / «returned NNN»). Прежний текст «empty result»
# читался как «у Яндекса нет данных», хотя при мёртвом прокси это был 407.
logger.info(
"yandex_valuation: пусто для address=%s — данных нет ЛИБО фетч не дошёл "
"(причина строкой выше от scraper_kit: прокси/HTTP)",
address[:60],
)
return None
# Save to cache (UPSERT on (source, cache_key))
@ -1320,6 +1372,8 @@ def _apply_imv_blend(
anchor_higher: int | None,
weight: float,
threshold: float,
market_count: int | None = None,
thin_market_threshold: int = 0,
) -> tuple[int, int, float, bool, int | None]:
"""Чистая (testable без БД) blend-трансформация для #651.
@ -1330,12 +1384,22 @@ def _apply_imv_blend(
Если A ниже медианы медиану НЕ трогаем, но диапазон можем расширить, чтобы
включить A (информативность). Null-guard: при anchor_total=None no-op.
#3323: тонкий рынок (`market_count` < `thin_market_threshold`) → якорь
статистически ненадёжен и ОТБРАСЫВАЕТСЯ из денежного пути целиком: ни blend
медианы, ни расширение range_high. Гейт стоит здесь в единственной точке,
через которую IMV влияет на деньги, а не в ветках построения якоря.
`thin_market_threshold=0` (default) = гейт выключен: market_count неизвестен
поведение прежнее.
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
anchor_used_total).
"""
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
return median_price, range_high, median_ppm2, False, None
if market_count is not None and market_count < thin_market_threshold:
return median_price, range_high, median_ppm2, False, None
blended = False
new_median = median_price
new_ppm2 = median_ppm2
@ -1775,6 +1839,7 @@ def _fetch_dkp_corridor(
rooms: int | None,
area: float | None,
city: str | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
period_months: int = DEALS_PERIOD_MONTHS,
area_tolerance: float = AREA_TOLERANCE,
) -> dict[str, Any] | None:
@ -1794,6 +1859,16 @@ def _fetch_dkp_corridor(
Тагиле/Лесном/Краснотурьинске/Серове/ЕКБ/Первоуральске) коридор превращался
в кросс-областной мусор для не-ЕКБ таргетов. None (город не определился)
фильтр не применяется, прежнее (pre-oblast) поведение сохраняется как есть.
region_code: (#3051 «Москва», PR-A) — регион целевого запроса, ОБЯЗАТЕЛЬНЫЙ
фильтр (не advisory, в отличие от city) в обоих ДКП-запросах. `city`
распознаётся только для Свердловской области (_resolve_target_city матчит
исключительно SVERDLOVSK_OBLAST_CITIES) для Москвы/любого нового региона
city=None и street-ILIKE фильтр остаётся ЕДИНСТВЕННЫМ скоупом сделки; без
region_code одноимённая улица чужого региона (или сделка без узнанного
города вовсе) утекает в коридор через тот же street ILIKE. Дефолт
regions_mod.DEFAULT_REGION_CODE (66) байт-в-байт прежнее поведение для
всех сегодняшних вызовов (все сделки в БД сейчас region_code=66).
"""
if not address or rooms is None or not area:
return None
@ -1816,8 +1891,10 @@ 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.city = d.city
LEFT JOIN deal_city_price_bands b
ON b.region_code = d.region_code AND b.city = d.city
WHERE d.source = 'rosreestr'
AND d.region_code = CAST(:region_code AS int)
AND d.address ILIKE :street_pattern
AND d.address ~* :street_regex
AND d.rooms = CAST(:rooms AS integer)
@ -1826,11 +1903,12 @@ def _fetch_dkp_corridor(
- (CAST(:period_months AS integer) || ' months')::interval
AND d.price_per_m2 > 0
{city_filter_sql}
-- #699 + #2478: режем нерыночные ppm²-выбросы из коридора
-- expected_sold. Per-city band (deal_city_price_bands, миграция
-- 178) когда для города сделки есть строка (не-ЕКБ область);
-- иначе (в т.ч. Екатеринбург, НАМЕРЕННО не в таблице) fallback
-- на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 byte-identical.
-- #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))
"""
@ -1845,6 +1923,7 @@ def _fetch_dkp_corridor(
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
"target_city": city.lower() if city else None,
"region_code": region_code,
},
)
.mappings()
@ -1897,8 +1976,10 @@ def _fetch_dkp_corridor(
"""
SELECT d.price_per_m2, d.deal_date
FROM deals d
LEFT JOIN deal_city_price_bands b ON b.city = d.city
LEFT JOIN deal_city_price_bands b
ON b.region_code = d.region_code AND b.city = d.city
WHERE d.source = 'rosreestr'
AND d.region_code = CAST(:region_code AS int)
AND d.city IS NOT NULL
AND LOWER(d.city) = CAST(:target_city AS text)
AND d.rooms = CAST(:rooms AS integer)
@ -1918,6 +1999,7 @@ def _fetch_dkp_corridor(
"period_months": period_months,
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
"region_code": region_code,
},
)
.mappings()
@ -3347,8 +3429,16 @@ def _price_from_inputs(
anchor_higher = (
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
anchor_label = "оценке Avito IMV"
_imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
# Публичная копия: клиент видит эту строку в confidence_explanation
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
# `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно».
_imv_mc = (
int(imv_anchor["market_count"])
if imv_anchor.get("market_count") is not None
else None
)
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(
@ -3379,7 +3469,10 @@ def _price_from_inputs(
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
anchor_label = "оценке Avito IMV"
# Публичная копия: клиент видит эту строку в confidence_explanation
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None),
@ -3391,17 +3484,27 @@ def _price_from_inputs(
),
)
# #audit-5b: thin-market warning.
# #audit-5b / #3323: thin-market warning. Раньше порог рождал ТОЛЬКО warning,
# а IMV всё равно двигал headline и растягивал range_high — теперь гейт в
# `_apply_imv_blend` отбрасывает якорь, и текст говорит именно это.
if avito_imv_summary is not None and avito_imv_summary.thin_market:
logger.warning(
"avito_imv thin_market #audit-5b: market_count=%s"
" (< avito_imv_thin_market_threshold=%d) — IMV reliability low",
"avito_imv thin_market #3323: market_count=%s"
" (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного"
" пути (ни blend медианы, ни расширение range_high); остаётся"
" display-only в карточке avito_imv",
avito_imv_summary.market_count,
settings.avito_imv_thin_market_threshold,
)
if anchor_total is not None:
imv_anchor_present = True
# #3323: на тонком рынке якорь отброшен гейтом ниже, значит и Guard-1b
# (#764, quarter-index) не должен глушить поправку «потому что якорь есть» —
# иначе headline не получит НИ ОДНОЙ поправки, и отброшенный IMV подвинет
# деньги вычитанием. avito_imv_summary здесь уже собран обеими ветками.
imv_anchor_present = (
not avito_imv_summary.thin_market if avito_imv_summary is not None else True
)
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
@ -3411,6 +3514,10 @@ def _price_from_inputs(
anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight,
threshold=settings.estimate_imv_blend_threshold,
market_count=(
avito_imv_summary.market_count if avito_imv_summary is not None else None
),
thin_market_threshold=settings.avito_imv_thin_market_threshold,
)
if blended:
logger.info(
@ -3442,7 +3549,11 @@ def _price_from_inputs(
# median/expected_sold/ranges блок не трогает.
if avito_imv_summary is None:
if imv_anchor is not None and imv_anchor.get("recommended_price"):
_disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
_disp_mc = (
int(imv_anchor["market_count"])
if imv_anchor.get("market_count") is not None
else None
)
avito_imv_summary = AvitoImvSummary(
recommended_price=int(imv_anchor["recommended_price"]),
lower_price=(
@ -4469,6 +4580,15 @@ async def estimate_quality(
target_city = _resolve_target_city(payload.address) or _resolve_target_city(
(dadata.canonical_address if dadata else None) or geo.full_address
)
# #3051 PR-A: региональный скоуп ДКП-коридора — target_city выше распознаёт
# ТОЛЬКО города Свердловской области (_resolve_target_city матчит
# SVERDLOVSK_OBLAST_CITIES), т.е. для Москвы/любого нового региона city=None
# и без region_code street ILIKE ловил бы одноимённую улицу ЧУЖОГО региона.
# region_for_point(None) (точка вне охвата ни одного региона) → дефолт 66,
# НЕ None — NULL в SQL-параметре обнулил бы фильтр (`= NULL` не матчит
# ничего), т.е. держим гарантию «не резолвится → 66», не пусто.
target_region = regions_mod.region_for_point(geo.lat, geo.lon)
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
dkp_raw = await asyncio.to_thread(
_fetch_dkp_corridor,
db,
@ -4476,6 +4596,7 @@ async def estimate_quality(
rooms=payload.rooms,
area=payload.area_m2,
city=target_city,
region_code=target_region_code,
)
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
@ -4569,6 +4690,21 @@ async def estimate_quality(
"deal_type": "sale",
"use_cache": True,
"house_id": target_house_id,
# #3398: без provider'а curl_proxy_url считает use_pool=False (флаг AND
# provider is not None) → env-прокси CIAN_PROXY_URL/SCRAPER_PROXY_URL, мёртвый
# узел (#2613) → «Cian valuation fetch failed: … 407». Lease живёт внутри
# curl_proxy_url: acquire до запроса, release в finally.
#
# Весь этот dict переиспользуется отложенной фоновой догрузкой ниже
# (`_defer_external_refresh` захватывает `_c_kwargs` замыканием), то есть
# `config` и `proxy_provider` — ОДИН инстанс на два вызова, которые могут идти
# одновременно (фон стартует после ответа, но живёт своей задачей). Корректно
# это ровно пока оба stateless: `RealScraperConfig` — read-only снимок настроек,
# `RealProxyProvider` не хранит полей вообще и открывает короткую сессию БД на
# каждую операцию (acquire/release/mark_health), поэтому lease'ы двух вызовов не
# пересекаются. Появится у любого из них per-вызов состояние (кэш lease'а,
# счётчик, открытая сессия) — фоновой задаче нужен СВОЙ инстанс, а не общий.
"proxy_provider": RealProxyProvider(),
}
try:
cian_val = await _with_budget(
@ -4588,13 +4724,19 @@ async def estimate_quality(
cian_val.sale_accuracy,
cian_val.external_house_id,
)
except Exception:
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
# (только breadcrumb) — config-wiring регрессия здесь была бы не видна
# мониторингу. .exception() логирует на ERROR + traceback (#2337).
logger.exception("cian_valuation: lookup failed (graceful)")
except Exception as e:
if caused_by_no_proxy(e):
# #3398: пустой пул поднимает NoProxyAvailableError ДО HTTP (curl_proxy_url),
# то есть это не сбой Циана. WARNING, не ERROR: в GlitchTip такое событие —
# шум. Оценка отдаётся без cian-источника, /estimate не 5xx.
logger.warning("cian_valuation: пул прокси пуст — продолжаем без Cian: %s", e)
else:
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
# (только breadcrumb) — config-wiring регрессия здесь была бы не видна
# мониторингу. .exception() логирует на ERROR + traceback (#2337).
logger.exception("cian_valuation: lookup failed (graceful)")
# ── Pre-fetch: same-building anchor comps ─────────────────────────────────
# Guard mirrors the original in-block guard exactly; when false → ([], None).
@ -6419,25 +6561,29 @@ def _is_plausible_deal(
area_m2: float | None = None,
price_rub: float | None = None,
city: str | None = None,
bands: dict[str, tuple[int, int]] | None = None,
bands: dict[tuple[int, str], tuple[int, int]] | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
) -> bool:
"""#699 + Mera-audit fix-2 + #2478: True если ДКП-сделка правдоподобна (не выброс).
"""#699 + Mera-audit fix-2 + #2478 + #3051 (298): True если ДКП-сделка правдоподобна.
Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep
нечем сравнивать). Проверки:
- price_per_m2 вне [ppm_min, ppm_max] drop, где (ppm_min, ppm_max) =
bands.get(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) #2478: per-city band
(deal_city_price_bands, миграция 178) для не-ЕКБ городов области, где
глобальный пол 50k /м² режет легитимно дешёвый рынок. city не в bands
(в т.ч. Екатеринбург НАМЕРЕННО не в таблице) или bands=None
fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical
сегодняшнему поведению).
bands.get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) #2478:
per-(region, city) band (deal_city_price_bands, миграция 178, ключ
(region_code, city) миграция 298) для не-ЕКБ городов области, где
глобальный пол 50k /м² режет легитимно дешёвый рынок. region_code
kw-only с дефолтом regions_mod.DEFAULT_REGION_CODE (66), позиционные
вызовы не ломаются. (region_code, city) не в bands (в т.ч. Екатеринбург
region_code=66 НАМЕРЕННО не в таблице) или bands=None fallback на
глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical сегодняшнему
поведению).
- 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(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2))
ppm_min, ppm_max = (bands or {}).get((region_code, city), (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:
@ -6462,7 +6608,7 @@ def _fetch_deals(
SELECT
source, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2, city,
price_rub, price_per_m2, city, region_code,
deal_date, days_on_market,
cadastral_number,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
@ -6492,8 +6638,11 @@ def _fetch_deals(
# #699 + Mera-audit fix-2: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm²
# / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold.
# #2478: per-city ppm² band (deal_city_price_bands) вместо глобального
# DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов, graceful {} при ошибке.
# #2478 + #3051 (298): per-(region, city) ppm² band (deal_city_price_bands)
# вместо глобального DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов,
# graceful {} при ошибке. region_code=NULL (не должно случаться для
# rosreestr-строк, см. 177/288 backfill) → DEFAULT_REGION_CODE, чтобы band
# lookup не падал на None-ключе.
bands = _load_city_price_bands(db)
deals = [dict(r) for r in rows]
clean = [
@ -6507,6 +6656,7 @@ def _fetch_deals(
d.get("price_rub"),
city=d.get("city"),
bands=bands,
region_code=d.get("region_code") or regions_mod.DEFAULT_REGION_CODE,
)
]
if len(clean) < len(deals):

View file

@ -290,8 +290,9 @@ matplotlib.rcParams["font.family"] = "Manrope"
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# через безопасный fallback в _source_pill: серый фон + «Другой источник» (#3341 —
# сырой id тоже мог бы утечь именем площадки, source.title() больше не используется)
# — код на исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
@ -303,15 +304,59 @@ _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"etazhi": ("#e30613", "#fff"), # Этажи красный
}
# Публичные лейблы — канон `frontend/src/lib/source-registry.ts::SOURCES` (#3341,
# класс #3264): клиентский PDF не должен называть площадки-источники так же, как
# веб-отчёт и лендинг перестали делать это с 31.08. Ключи — ТОЛЬКО базовые id
# (алиасы валюации/написания резолвятся через _SOURCE_CANONICAL/_canonical_source
# ДО обращения сюда — иначе два источника правды для одной площадки).
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
"avito": "Avito",
"cian": "Циан",
"domklik": "Домклик · Сбер",
"yandex": "Я.Недвижимость",
"avito": "Источник 1",
"cian": "Источник 2",
"yandex": "Источник 3",
"domklik": "Источник 4",
"etazhi": "Источник 5",
"rosreestr": "Росреестр",
"etazhi": "Этажи",
}
# Алиас → базовый id. Зеркалит группировку publicLabel/dot в
# frontend/src/lib/source-registry.ts (одна площадка = один номер/цвет там же);
# правишь один реестр — проверь другой (#3341 review: без канонизации
# estimate.sources_used = [avito, avito_imv, cian, cian_valuation, ...]
# (estimator.py `_canonical_sources`, listing+valuation union) рендерил
# ДВЕ одинаковые пилюли «Источник 1, Источник 1» и серую точку у алиаса —
# ни avito_imv/cian_valuation/yandex_valuation/domclick/etagi не было ключом
# ни в _SOURCE_LOGO_COLORS, ни (после дедупа лейблов) в _SOURCE_DISPLAY_NAMES).
_SOURCE_CANONICAL: dict[str, str] = {
"avito_imv": "avito",
"cian_valuation": "cian",
"yandex_valuation": "yandex",
"domclick": "domklik",
"etagi": "etazhi",
}
def _canonical_source(source: str) -> str:
"""Алиас (valuation-вариант / альтернативное написание) → базовый id площадки."""
return _SOURCE_CANONICAL.get(source, source)
def _public_sources(ids: list[str]) -> list[str]:
"""Канонизирует id и дедуплицирует с сохранением порядка первого появления.
`estimate.sources_used` отсортированное объединение listing-id и их
valuation-алиасов (estimator.py `_canonical_sources`), напр.
`[avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]`.
Без канонизации+дедупа ДО среза `[:5]` рендерились дубли лейблов и «Источник 3»
(yandex) мог быть вытеснен алиасом другой площадки (#3341 review)."""
seen: set[str] = set()
result: list[str] = []
for raw in ids:
canon = _canonical_source(raw)
if canon not in seen:
seen.add(canon)
result.append(canon)
return result
def _source_logo_pill(source: str) -> str:
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
@ -319,10 +364,12 @@ def _source_logo_pill(source: str) -> str:
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название из
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов)
overflow-wrap на всякий случай, если source незнаком и попадёт .title()
произвольной длины (см. fallback ниже)."""
dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
overflow-wrap на всякий случай, если source незнаком (см. fallback ниже).
Канонизирует алиасы (avito_imv avito и т.п.) ДО lookup, чтобы цвет/лейбл
совпадали с базовой площадкой (#3341 review)."""
canon = _canonical_source(source)
dot = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
"<span class='source-pill' style='display:inline-flex;align-items:center;gap:4pt;"
f"margin:0 4pt 4pt 0;background:{_CARD_BG};color:{_BODY};vertical-align:middle;"
@ -333,11 +380,13 @@ def _source_logo_pill(source: str) -> str:
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона)."""
"""Маленький source badge для table cells (без фона). Канонизирует алиасы
ДО lookup см. _source_logo_pill."""
if not source:
return f"<span style='color:{_MUTED};'>—</span>"
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
canon = _canonical_source(source)
bg, fg = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
f"font-size:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
@ -1167,12 +1216,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
f"ДомКлик и продажи агентств недвижимости"
f"сделки площадок и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
"Росреестр, ДомКлик и продажи агентств недвижимости"
"Росреестр, сделки площадок и продажи агентств недвижимости"
)
disclaimer_html = ""
@ -1357,7 +1406,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
# count «с учётом ремонта» не существует, второе число было идентично n_total.
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
sources_to_show = estimate.sources_used or []
# #3341 review: sources_used — union listing+valuation алиасов (estimator.py
# `_canonical_sources`), напр. [avito, avito_imv, cian, cian_valuation, ...] —
# _public_sources канонизирует+дедуплицирует ДО среза [:5], иначе дубли пилюль
# и «Источник 3» мог быть вытеснен алиасом другой площадки.
sources_to_show = _public_sources(estimate.sources_used or [])
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
@ -1598,10 +1651,13 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
# #3341 review: канонизация+дедуп (_public_sources) ДО фильтра/среза [:5] — те же
# причины, что и на странице листингов (см. коммент там).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
canonical_sources = _public_sources(estimate.sources_used or [])
deal_sources = [s for s in canonical_sources if s in _deal_source_keys]
if not deal_sources:
deal_sources = [s for s in (estimate.sources_used or [])]
deal_sources = canonical_sources
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
area = float(input_snapshot.get("area_m2", 0) or 0)
@ -1847,7 +1903,7 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<td style="padding:6pt 4pt;">
<div class="bold">Расходы на рекламу</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
на Циан, Авито, Я.Недвижимости</div>
на основных площадках объявлений</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
бесплатно</td>

View file

@ -0,0 +1,58 @@
"""ФНС opendata lookup по ИНН — тонкое чтение `fns_legal_entity_facts`.
CONTEXT: см. `fns_opendata_loader.py`. Этот модуль единственная точка чтения
загруженных фактов. ПОТРЕБИТЕЛЯ У НЕГО ПОКА НЕТ: ничто в продукте (скоринг
застройщика/УК, estimator и т.п.) сюда не ходит подключение решается отдельной
задачей вне этого PR.
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from typing import TypedDict
from sqlalchemy import text
from sqlalchemy.orm import Session
_LOOKUP_SQL = text(
"""
SELECT dataset, series, period, value, org_name
FROM fns_legal_entity_facts
WHERE inn = CAST(:inn AS text)
ORDER BY dataset, series, period
"""
)
class FnsFact(TypedDict):
series: str
period: date
value: float
org_name: str | None
def get_facts_by_inn(db: Session, inn: str) -> dict[str, list[FnsFact]]:
"""Факты по ИНН, сгруппированные по dataset (`revexp`/`sshr2019`/`debtam`/`snr`).
Пустой/пробельный ИНН и отсутствие данных `{}` (не исключение вызывающий код
не обязан оборачивать lookup в try/except ради нормального «нет данных»).
"""
normalized = (inn or "").strip()
if not normalized:
return {}
rows = db.execute(_LOOKUP_SQL, {"inn": normalized}).mappings().all()
out: dict[str, list[FnsFact]] = defaultdict(list)
for row in rows:
out[row["dataset"]].append(
{
"series": row["series"],
"period": row["period"],
"value": row["value"],
"org_name": row["org_name"],
}
)
return dict(out)

View file

@ -0,0 +1,497 @@
"""ФНС opendata loader: доходы/расходы, ССЧ, недоимка, спецрежимы юрлиц → lookup по ИНН.
CONTEXT: открытых данных ЕГРН по правообладателям-физлицам не существует (218-ФЗ
ст. 62) это жёсткий блокер. По ЮРЛИЦАМ данные открыты, лицензия ФНС
(nalog.gov.ru/opendata) разрешает переработку и перераспространение легальный обход
для того среза, где он в принципе доступен. Наборы (маска slug'а: 7707329152-<slug>):
revexp доходы и расходы;
sshr2019 среднесписочная численность;
debtam недоимка и задолженность;
snr спецрежимы.
ЕГРЮЛ-данных здесь НЕТ: ни адреса, ни ОКВЭД, ни учредителей только ИНН + наименование
+ показатели набора.
ПОТРЕБИТЕЛЯ У ЭТОГО СЕРВИСА ПОКА НЕТ. Этот модуль (+ fns_lookup.py) только
загрузка и lookup по ИНН. Подключение к продукту (например, скоринг застройщика/УК
в оценке) сюда сознательно не входит и не реализовано это решается отдельной
задачей вне текущего PR. `estimator.py` не тронут.
ИСТОЧНИК: https://www.nalog.gov.ru/opendata/7707329152-<slug>/ HTML-каталог набора.
Прямая ссылка на .zip вида
https://file.nalog.ru/opendata/7707329152-revexp/data-20260825-structure-20180110.zip
резолвится ДИНАМИЧЕСКИ парсом href со страницы каталога (`resolve_dataset_file_url`):
дата в имени файла меняется с каждой публикацией набора хардкодить URL нельзя,
протухнет на следующей публикации. Внутри .zip множество XML-файлов.
ПАРСИНГ XML generic-харвестер, а не хардкод конкретных тегов набора. Точная схема
атрибутов XSD structure-20180110 варьируется по набору и НЕ была вживую сверена в
этом окружении (нет сетевого доступа к file.nalog.ru отсюда). Вместо этого: любой
XML-элемент, несущий ИНН-подобный атрибут (ИННЮЛ/ИНН данные ФНС opendata, как и
ГАР/ФИАС, лежат в атрибутах элементов, не в тексте, см. gar_flats_loader.py), даёт
identity строки; ЛЮБОЙ его собственный числовой атрибут (кроме ИНН/КПП/ОКПО/ОКТМО/
ОКВЭД/ОГРН и атрибутов-имени) становится отдельным фактом (series=имя атрибута,
value=число). Это устойчиво к точным названиям показателей набора ценой чуть более
широкого набора series, чем «официальный» словарь показателей ПЕРЕД первым боевым
прогоном на реальном .zip стоит свериться с фактическим XML и, если харвестер тянет
лишнее (например служебные коды), сузить `_ID_ATTRS_EXCLUDE`.
ПАМЯТЬ: .zip целиком лежит в памяти как байты (httpx возвращает `.content` иначе
не проверить целостность архива до распаковки), но НИ ОДИН XML-член НЕ
распаковывается в память/на диск целиком: каждый открывается потоково через
`zf.open(name)` и парсится `lxml.etree.iterparse` с очисткой обработанных элементов
(тот же приём, что в gar_flats_loader.py, там на многогигабайтных standalone XML,
здесь на множестве XML внутри одного архива, открываемых по одному).
TLS: nalog.gov.ru/file.nalog.ru отдают сертификат НУЦ Минцифры httpx с default
trust store не верифицирует verify=False. Открытые данные, без auth/PII.
Прецеденты: sber_index.py, domrf_kapremont_loader.py.
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
import io
import logging
import re
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date
from urllib.parse import urljoin
import httpx
from lxml import etree
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
CATALOG_URL_TEMPLATE = "https://www.nalog.gov.ru/opendata/7707329152-{slug}/"
DATASET_SLUGS: tuple[str, ...] = ("revexp", "sshr2019", "debtam", "snr")
DOWNLOAD_TIMEOUT_SEC = 300
UPSERT_CHUNK_SIZE = 500
# Атрибуты-идентификаторы/классификаторы — не показатели, исключаем из харвеста.
_INN_ATTRS = ("ИННЮЛ", "ИНН")
_NAME_ATTRS = ("НаимОрг", "НаимОрганизации", "НаимОрганизацииПолн", "НаимЮЛПолн")
_PERIOD_ATTRS = ("ДатаСост",)
# Служебные атрибуты, которые парсятся как числа, но фактом не являются:
# идентификаторы, коды и даты документа.
_ID_ATTRS_EXCLUDE = frozenset(
{
"ИННЮЛ",
"ИНН",
"КПП",
"ОКПО",
"ОКТМО",
"ОКВЭД",
"ОГРН",
"ИдДок",
"ИдФайл",
"ДатаДок",
"ДатаСост",
"ВерсФорм",
"ВерсПрог",
"КолДок",
"ТипИнф",
*_NAME_ATTRS,
}
)
_RECORD_TAGS = frozenset({"Документ", "Док", "СвЮЛ", "Сведения"})
"""Теги, на которых собирается запись. Реальные выгрузки ФНС используют `Документ`;
остальные запас под соседние наборы. Ограничение обязательно: иначе `Файл`
переоткрывал бы уже собранные документы и удваивал записи."""
_ZIP_HREF_RE = re.compile(r'href="([^"]+\.zip)"', re.IGNORECASE)
_PUBLISH_DATE_RE = re.compile(r"data-(\d{8})-")
# ─────────────────────────────────────────────────────────────────────────────
# Резолв ссылки со страницы каталога (URL меняется на каждой публикации)
# ─────────────────────────────────────────────────────────────────────────────
def resolve_dataset_file_url(html: str, *, base_url: str) -> tuple[str, date | None]:
"""Ссылка на .zip актуальной версии набора, снятая со страницы каталога.
Ищет href, оканчивающийся на .zip и несущий токен даты `data-YYYYMMDD-` эта
дата меняется с каждой публикацией (см. docstring модуля), поэтому URL нельзя
хардкодить. Если на странице несколько подходящих ссылок (несколько версий
структуры/публикаций), берёт с МАКСИМАЛЬНОЙ датой детерминированно, без сети.
Возвращает (абсолютный_url, published_on). published_on None, если ссылка
найдена, но токен даты не распарсился (защитный случай, не должен происходить
для ссылок, прошедших регэксп даты).
Raises ValueError, если на странице нет ни одной ссылки вида *data-YYYYMMDD-*.zip.
"""
candidates: list[tuple[str, date]] = []
for m in _ZIP_HREF_RE.finditer(html):
href = m.group(1)
date_m = _PUBLISH_DATE_RE.search(href)
if not date_m:
continue
token = date_m.group(1)
try:
published = date(int(token[:4]), int(token[4:6]), int(token[6:8]))
except ValueError:
continue
candidates.append((href, published))
if not candidates:
raise ValueError(f"каталог {base_url}: не найдено ссылок вида href=*data-YYYYMMDD-*.zip")
href, published = max(candidates, key=lambda c: c[1])
return urljoin(base_url, href), published
# ─────────────────────────────────────────────────────────────────────────────
# In-memory модель факта
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class FnsRecord:
inn: str
dataset: str
series: str
period: date
value: float
org_name: str | None
def to_params(self) -> dict[str, object]:
return {
"inn": self.inn,
"dataset": self.dataset,
"series": self.series,
"period": self.period,
"value": self.value,
"org_name": self.org_name,
}
# ─────────────────────────────────────────────────────────────────────────────
# Стриминговый парсер XML внутри ZIP (lxml iterparse + очистка, без extractall)
# ─────────────────────────────────────────────────────────────────────────────
def _find_attr(elem: etree._Element, candidates: tuple[str, ...]) -> str | None:
for attr in candidates:
raw = elem.get(attr)
if raw:
stripped = raw.strip()
if stripped:
return stripped
return None
def _parse_numeric(raw: str) -> float | None:
stripped = raw.strip()
if not stripped:
return None
try:
return float(stripped.replace(",", "."))
except ValueError:
return None
def _find_attr_deep(elem: etree._Element, candidates: tuple[str, ...]) -> str | None:
"""Найти атрибут на самом элементе ИЛИ на любом его потомке.
В реальных выгрузках ФНС ИНН и показатели лежат на РАЗНЫХ соседних элементах
внутри `<Документ>` см. докстринг `harvest_element`.
"""
found = _find_attr(elem, candidates)
if found is not None:
return found
for child in elem.iterdescendants():
found = _find_attr(child, candidates)
if found is not None:
return found
return None
def _period_from_element(elem: etree._Element, fallback: date) -> date:
"""Отчётная дата документа из `ДатаСост` (ДД.ММ.ГГГГ), иначе — переданная."""
raw = _find_attr_deep(elem, _PERIOD_ATTRS)
if raw is None:
return fallback
try:
day, month, year = (int(part) for part in raw.split("."))
return date(year, month, day)
except (ValueError, TypeError):
return fallback
def _numeric_attrs(node: etree._Element, seen: set[str]) -> list[tuple[str, float]]:
"""Числовые не-служебные атрибуты узла, без повторов по имени серии."""
out: list[tuple[str, float]] = []
for key, raw in node.attrib.items():
if key in _ID_ATTRS_EXCLUDE or key in seen:
continue
value = _parse_numeric(raw)
if value is None:
continue
seen.add(key)
out.append((key, value))
return out
def harvest_element(elem: etree._Element, *, dataset: str, period: date) -> list[FnsRecord]:
"""Один XML-элемент → 0..N FnsRecord (один на каждый числовой не-id атрибут).
Реальная форма выгрузки (проверено на data-20260825 набора revexp, 2118 XML
в архиве, 40 002 факта на первых 20 001 организаций)::
<Документ ИдДок="..." ДатаДок="25.08.2026" ДатаСост="31.12.2025">
<СведНП НаимОрг="ООО ..." ИННЮЛ="4205406898"/>
<СведДохРасх СумДоход="341864000.00" СумРасход="282224000.00"/>
</Документ>
То есть ИНН живёт на `СведНП`, а показатели на СОСЕДНЕМ элементе. Поэтому
носители ИНН ищутся по всему поддереву, а значения без собственного ИНН
(`СведДохРасх` и подобные) привязываются к организации ТОЛЬКО когда носитель в
поддереве один иначе непонятно, чьи это цифры, и мы их не выдумываем.
Отчётный период берётся из `ДатаСост` документа, если он есть; переданный
`period` запасное значение.
Чистая функция (без БД/сети). Поддерево без ИНН даёт пустой список.
"""
nodes = [elem, *elem.iterdescendants()]
holders = [n for n in nodes if _find_attr(n, _INN_ATTRS) is not None]
if not holders:
return []
doc_period = _period_from_element(elem, period)
shared = [n for n in nodes if n not in holders] if len(holders) == 1 else []
out: list[FnsRecord] = []
for holder in holders:
inn = _find_attr(holder, _INN_ATTRS)
if inn is None: # pragma: no cover - отфильтровано выше
continue
org_name = _find_attr(holder, _NAME_ATTRS) or (
_find_attr_deep(elem, _NAME_ATTRS) if len(holders) == 1 else None
)
seen: set[str] = set()
for node in (holder, *shared):
for series, value in _numeric_attrs(node, seen):
out.append(
FnsRecord(
inn=inn,
dataset=dataset,
series=series,
period=doc_period,
value=value,
org_name=org_name,
)
)
return out
def _iter_xml_records(fh: object, *, dataset: str, period: date) -> Iterator[FnsRecord]:
"""Стримит FnsRecord из одного XML-потока `fh` (открытый член ZIP или файл).
`events=("end",)` + `elem.clear()` + срез предыдущих сиблингов bounded memory
на произвольно большом XML (приём из gar_flats_loader._stream_rows).
"""
context = etree.iterparse(
fh, events=("end",), recover=True, huge_tree=True, resolve_entities=False
)
for _event, elem in context:
if not isinstance(elem.tag, str) or elem.tag not in _RECORD_TAGS:
continue
yield from harvest_element(elem, dataset=dataset, period=period)
# Чистим ТОЛЬКО на границе записи. `end`-события детей приходят раньше
# родительского, поэтому безусловный `elem.clear()` на каждом элементе
# вычищал `<СведНП>`/`<СведДохРасх>` ДО закрытия `<Документ>` — и парсер
# молча извлекал ноль записей из реального дампа.
elem.clear()
parent = elem.getparent()
if parent is not None:
while elem.getprevious() is not None:
del parent[0]
del context
def iter_dataset_records(zip_bytes: bytes, *, dataset: str, period: date) -> Iterator[FnsRecord]:
"""Обходит все *.xml внутри `zip_bytes`, элемент за элементом, без extractall.
Каждый XML-член открывается через `zf.open(name)` как поток (НЕ `zf.read()`
целиком, НЕ `zf.extractall()`) см. docstring модуля § ПАМЯТЬ.
Raises ValueError, если в архиве нет ни одного .xml.
"""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = [n for n in zf.namelist() if n.lower().endswith(".xml")]
if not names:
raise ValueError(f"zip набора {dataset} не содержит .xml записей: {zf.namelist()!r}")
for name in names:
with zf.open(name) as fh:
yield from _iter_xml_records(fh, dataset=dataset, period=period)
# ─────────────────────────────────────────────────────────────────────────────
# UPSERT фактов + версия набора
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_FACTS_SQL = text(
"""
INSERT INTO fns_legal_entity_facts (inn, dataset, series, period, value, org_name, loaded_at)
VALUES (
CAST(:inn AS text), CAST(:dataset AS text), CAST(:series AS text),
CAST(:period AS date), CAST(:value AS numeric), CAST(:org_name AS text), now()
)
ON CONFLICT (inn, dataset, series, period) DO UPDATE SET
value = EXCLUDED.value,
org_name = EXCLUDED.org_name,
loaded_at = now()
WHERE fns_legal_entity_facts.value IS DISTINCT FROM EXCLUDED.value
"""
)
_SELECT_VERSION_SQL = text(
"SELECT file_url, published_on FROM fns_dataset_versions WHERE dataset = CAST(:dataset AS text)"
)
_UPSERT_VERSION_SQL = text(
"""
INSERT INTO fns_dataset_versions (dataset, file_url, published_on, loaded_at)
VALUES (CAST(:dataset AS text), CAST(:file_url AS text), CAST(:published_on AS date), now())
ON CONFLICT (dataset) DO UPDATE SET
file_url = EXCLUDED.file_url,
published_on = EXCLUDED.published_on,
loaded_at = now()
"""
)
def _chunks(items: list[FnsRecord], size: int) -> Iterator[list[FnsRecord]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def upsert_records(
db: Session, records: list[FnsRecord], *, chunk_size: int = UPSERT_CHUNK_SIZE
) -> int:
"""Батчевый UPSERT в fns_legal_entity_facts. НЕ коммитит (коммитит caller).
SAVEPOINT на каждый батч сбойный батч откатывается изолированно, остальные
доезжают (тот же приём, что и gar_flats_loader.upsert_gar_houses).
"""
upserted = 0
failed_batches = 0
for batch in _chunks(records, chunk_size):
params = [r.to_params() for r in batch]
try:
with db.begin_nested():
db.execute(_UPSERT_FACTS_SQL, params)
upserted += len(batch)
except Exception:
failed_batches += 1
logger.warning(
"fns_opendata upsert: батч из %d строк сбойнул (пропущен)",
len(batch),
exc_info=True,
)
if failed_batches:
logger.warning("fns_opendata upsert: сбойных батчей=%d", failed_batches)
return upserted
def get_loaded_version(db: Session, dataset: str) -> tuple[str, date | None] | None:
"""Уже загруженная версия набора (file_url, published_on) или None."""
row = db.execute(_SELECT_VERSION_SQL, {"dataset": dataset}).first()
if row is None:
return None
return row[0], row[1]
def record_dataset_version(
db: Session, dataset: str, file_url: str, published_on: date | None
) -> None:
"""UPSERT версии набора. НЕ коммитит (коммитит caller)."""
db.execute(
_UPSERT_VERSION_SQL,
{"dataset": dataset, "file_url": file_url, "published_on": published_on},
)
# ─────────────────────────────────────────────────────────────────────────────
# Orchestration
# ─────────────────────────────────────────────────────────────────────────────
def _download(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def load_dataset(
db: Session,
slug: str,
*,
client: httpx.Client | None = None,
dry_run: bool = False,
force: bool = False,
chunk_size: int = UPSERT_CHUNK_SIZE,
) -> dict[str, int | str]:
"""Резолвит актуальную ссылку набора `slug` → скачивает .zip → парсит → UPSERT.
Пропускает скачивание, если `fns_dataset_versions` уже содержит ТУ ЖЕ ссылку
(force=True форсирует перекачку). dry_run резолв ссылки происходит (проверяем
каталог доступен), скачивание/парс/запись нет. `client` для тестов
(httpx.MockTransport); если не передан, открывается и закрывается свой.
НЕ коммитит коммитит caller.
"""
if slug not in DATASET_SLUGS:
raise ValueError(f"неизвестный slug набора: {slug!r}, ожидались {DATASET_SLUGS}")
owns_client = client is None
if client is None:
# TLS: см. docstring модуля § TLS — verify=False, открытые данные без auth/PII.
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
catalog_url = CATALOG_URL_TEMPLATE.format(slug=slug)
resp = client.get(catalog_url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
file_url, published_on = resolve_dataset_file_url(resp.text, base_url=catalog_url)
existing = get_loaded_version(db, slug)
if not force and existing is not None and existing[0] == file_url:
logger.info("fns_opendata %s: версия %s уже загружена, skip", slug, file_url)
return {"dataset": slug, "skipped": 1, "records": 0, "upserted": 0}
if dry_run:
logger.info(
"fns_opendata %s: dry_run — резолвлен %s (published_on=%s), скачивание пропущено",
slug,
file_url,
published_on,
)
return {"dataset": slug, "skipped": 0, "records": 0, "upserted": 0}
zip_bytes = _download(file_url, client=client)
period = published_on or date.today()
records = list(iter_dataset_records(zip_bytes, dataset=slug, period=period))
upserted = upsert_records(db, records, chunk_size=chunk_size)
record_dataset_version(db, slug, file_url, published_on)
result: dict[str, int | str] = {
"dataset": slug,
"skipped": 0,
"records": len(records),
"upserted": upserted,
}
logger.info("fns_opendata load %s DONE (dry_run=%s): %s", slug, dry_run, result)
return result
finally:
if owns_client:
client.close()
def load_datasets(
db: Session,
slugs: tuple[str, ...] = DATASET_SLUGS,
*,
client: httpx.Client | None = None,
dry_run: bool = False,
force: bool = False,
) -> dict[str, dict[str, int | str]]:
"""load_dataset для каждого slug из `slugs`. НЕ коммитит между наборами (caller)."""
return {
slug: load_dataset(db, slug, client=client, dry_run=dry_run, force=force) for slug in slugs
}

View file

@ -0,0 +1,688 @@
"""АИС ППК «ФРТ» (бывш. Реформа ЖКХ) loader: houses.area_land/foundation_type/
elevators_total + добор year_built/material_walls/total_floors/entrances/is_emergency/
flat_count/heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
CONTEXT: houses.area_land и houses.foundation_type отсутствовали вовсе; elevators_total
новая колонка (в источнике ОБЩЕЕ число лифтов, houses раздельно хранит только
passenger_elevators/cargo_elevators, поэтому мапить в них нельзя потеряли бы тип).
Остальные поля (year_built, material_walls, ...) уже существуют и заполнены частично
другими источниками (ДОМ.РФ капремонт, ГИС-ЖКХ) этот loader их ДОБИРАЕТ через
COALESCE (только NULL), никогда не перезаписывает.
ИСТОЧНИК: АИС ППК ФРТ open data, https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/{node_id}
node 110 = реестр МКД региона 66 (Свердловская обл.) проверено живьём 08.09.2026:
HTTP 200, application/octet-stream, zip один CSV `export-reestrmkd-66-*.csv`,
UTF-8 BOM, разделитель ';', 60 колонок, ~41.8k строк по СО.
robots.txt источника запрещает /opendata/export/ и требует Crawl-delay 10. Один прогон
этого loader'а делает РОВНО ОДИН GET (один node_id) — задержка не нужна. Если когда-нибудь
понадобится тянуть несколько node_id за один запуск (напр. node 427 аварийный фонд РФ,
node 1 реестр УО РФ, оба вне скоупа этого PR) между запросами обязательна пауза
CRAWL_DELAY_SEC (константа ниже), иначе нарушаем Crawl-delay из robots.txt.
ЧТО НЕ ДЕЛАЕМ (осознанно):
* project_type НЕ мапим в houses.series_name свободный текст, фактический дубль
материала стен (замер: пусто 10635, «кирпичный» 1754, «нет данных» 1496,
«панельный» 1012, «Блочный»/«блочный» двумя разными строками). Как серию
использовать нельзя.
* energy_efficiency НЕ добавляем в houses миграция 284 уже приняла это решение;
реальный класс присвоен лишь ~10% домов (у большинства «Не присвоен»).
* elevators_count НЕ мапим в houses.passenger_elevators источник отдаёт общее
число лифтов без разбивки, а houses хранит passenger/cargo раздельно. Пишем в
отдельную houses.elevators_total.
* playground/sportsground id справочника (498/499/500), не булев в staging как
есть, houses.has_playground не трогаем.
* estimator.py не трогаем встраивание признаков дома в подбор аналогов вне скоупа.
МУСОРНЫЕ ЗНАЧЕНИЯ источника (встречаются как обычные строки текстовых полей):
'', 'нет данных', 'Не заполнено', 'отсутствует', 'данные отсутствуют', 'нет'.
Единый хелпер `clean_text()` отфильтровывает их при парсе КАЖДОГО текстового поля.
TLS: домен xn--80adsazqn.xn--p1aee.xn--p1ai отдаёт RU-сертификат НУЦ Минцифры, не
верифицируемый дефолтным trust store'ом httpx — та же ситуация, что sber_index.py и
domrf_kapremont_loader.py. Открытые данные без auth/PII verify=False приемлем.
psycopg v3: SQL через `text(...)` использует CAST(:x AS type), НИКОГДА :x::type.
"""
from __future__ import annotations
import csv
import io
import logging
import tempfile
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass, fields
from datetime import date
from pathlib import Path
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Константы
# ─────────────────────────────────────────────────────────────────────────────
BASE_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai"
DEFAULT_NODE_ID = 110 # реестр МКД региона 66 — проверено живьём 08.09.2026
DEFAULT_REGION_CODE = 66
DOWNLOAD_TIMEOUT_SEC = 180
UPSERT_CHUNK_SIZE = 500
# robots.txt источника: Crawl-delay 10. Используется только если когда-нибудь понадобится
# тянуть несколько node_id за один запуск (сейчас — ровно один GET на прогон).
CRAWL_DELAY_SEC = 10
# Мусорные значения текстовых полей источника — не данные, а «пусто» в других обёртках.
_NOISE_VALUES = frozenset(
{"", "нет данных", "не заполнено", "отсутствует", "данные отсутствуют", "нет"}
)
# Правдоподобные границы, используются и на парсе (staging), и повторно в backfill SQL.
YEAR_BUILT_MIN = 1850
YEAR_BUILT_FUTURE_SLACK = 2
FLOOR_COUNT_MAX_BOUND = 100
ENTRANCE_COUNT_MAX_BOUND = 50
ELEVATORS_COUNT_MAX_BOUND = 50
FLAT_COUNT_MAX_BOUND = 3000
AREA_LAND_MAX_BOUND = 500_000 # м2, с большим запасом для дворовых территорий МКД
def plausible_year_max() -> int:
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
return date.today().year + YEAR_BUILT_FUTURE_SLACK
# ─────────────────────────────────────────────────────────────────────────────
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
# ─────────────────────────────────────────────────────────────────────────────
def clean_text(raw: str | None) -> str | None:
"""Строка источника → строка|None, мусорные значения-заглушки схлопываются в None."""
if raw is None:
return None
s = raw.strip()
if not s or s.lower() in _NOISE_VALUES:
return None
return s
def parse_decimal_comma(raw: str | None) -> float | None:
"""«930,60» / «930.60» / «» / мусор → float|None. Запятая — decimal separator АИС ФРТ."""
s = clean_text(raw)
if s is None:
return None
try:
return float(s.replace(",", "."))
except ValueError:
return None
def parse_int_field(
raw: str | None, *, min_value: int | None = None, max_value: int | None = None
) -> int | None:
"""Устойчивый str → int|None с опциональным sanity-гейтом [min_value, max_value]."""
s = clean_text(raw)
if s is None:
return None
value: int | None = None
try:
value = int(s)
except ValueError:
try:
value = int(float(s.replace(",", ".")))
except ValueError:
return None
if min_value is not None and value < min_value:
return None
if max_value is not None and value > max_value:
return None
return value
def parse_bool_da_net(raw: str | None) -> bool | None:
"""«Да»/«Нет» (регистронезависимо) → bool|None. Пусто/иное → None.
НЕ идёт через clean_text() «нет» само по себе входит в _NOISE_VALUES (заглушка
текстовых полей типа «отсутствует»/«нет»), и там это совпадение уместно. Но для
is_alarm «Нет» валидный содержательный ответ («не аварийный»), а не отсутствие
данных, поэтому здесь разбираем сырую строку напрямую.
"""
if raw is None:
return None
s = raw.strip()
if not s:
return None
low = s.lower()
if low == "да":
return True
if low == "нет":
return False
return None
@dataclass(slots=True)
class FrtMkdRow:
"""Одна строка реестра МКД АИС ФРТ (house-per-row), готова к UPSERT в staging."""
houseguid: str
region_code: int
address: str | None
built_year: int | None
exploitation_start_year: int | None
project_type: str | None
house_type: str | None
is_alarm: bool | None
floor_count_max: int | None
floor_count_min: int | None
entrance_count: int | None
elevators_count: int | None
energy_efficiency: str | None
quarters_count: int | None
living_quarters_count: int | None
unliving_quarters_count: int | None
area_total: float | None
area_residential: float | None
area_non_residential: float | None
area_common_property: float | None
area_land: float | None
parking_square: float | None
playground: int | None
sportsground: int | None
other_beautification: str | None
foundation_type: str | None
floor_type: str | None
wall_material: str | None
basement_area: float | None
chute_type: str | None
chute_count: int | None
heating_type: str | None
hot_water_type: str | None
cold_water_type: str | None
sewerage_type: str | None
gas_type: str | None
ventilation_type: str | None
firefighting_type: str | None
drainage_type: str | None
management_organization_id: int | None
method_of_forming_overhaul_fund: str | None
def parse_frt_mkd_csv(path: str | Path, *, region_code: int) -> dict[str, FrtMkdRow]:
"""CSV реестра МКД АИС ФРТ → dict по houseguid. Строки без houseguid пропускаются.
Дубликаты houseguid в источнике РЕАЛЬНЫ и взаимодополняющи: в выгрузке региона 66
за 2026-09-01 из 41 790 строк 912 guid'ов повторяются (968 лишних строк), причём у
одной строки пары заполнен, например, `area_total`, а у другой нет. Поэтому
«последняя побеждает» терять нельзя: дубликаты СЛИВАЮТСЯ по полям, побеждает первое
непустое значение (`_merge_rows`). Иначе бэкфилл недосчитывался бы заполненных полей
примерно у 2 % домов области.
"""
rows: dict[str, FrtMkdRow] = {}
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f, delimiter=";")
for raw in reader:
houseguid = clean_text(raw.get("houseguid"))
if not houseguid:
continue
parsed = FrtMkdRow(
houseguid=houseguid,
region_code=region_code,
address=clean_text(raw.get("address")),
built_year=parse_int_field(
raw.get("built_year"), min_value=YEAR_BUILT_MIN, max_value=plausible_year_max()
),
exploitation_start_year=parse_int_field(
raw.get("exploitation_start_year"),
min_value=YEAR_BUILT_MIN,
max_value=plausible_year_max(),
),
project_type=clean_text(raw.get("project_type")),
house_type=clean_text(raw.get("house_type")),
is_alarm=parse_bool_da_net(raw.get("is_alarm")),
floor_count_max=parse_int_field(
raw.get("floor_count_max"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
),
floor_count_min=parse_int_field(
raw.get("floor_count_min"), min_value=1, max_value=FLOOR_COUNT_MAX_BOUND
),
entrance_count=parse_int_field(
raw.get("entrance_count"), min_value=1, max_value=ENTRANCE_COUNT_MAX_BOUND
),
elevators_count=parse_int_field(
raw.get("elevators_count"), min_value=0, max_value=ELEVATORS_COUNT_MAX_BOUND
),
energy_efficiency=clean_text(raw.get("energy_efficiency")),
quarters_count=parse_int_field(raw.get("quarters_count"), min_value=0),
living_quarters_count=parse_int_field(
raw.get("living_quarters_count"), min_value=0, max_value=FLAT_COUNT_MAX_BOUND
),
unliving_quarters_count=parse_int_field(
raw.get("unliving_quarters_count"), min_value=0
),
area_total=parse_decimal_comma(raw.get("area_total")),
area_residential=parse_decimal_comma(raw.get("area_residential")),
area_non_residential=parse_decimal_comma(raw.get("area_non_residential")),
area_common_property=parse_decimal_comma(raw.get("area_common_property")),
area_land=parse_decimal_comma(raw.get("area_land")),
parking_square=parse_decimal_comma(raw.get("parking_square")),
# playground/sportsground — id справочника благоустройства, не булев флаг.
playground=parse_int_field(raw.get("playground"), min_value=0),
sportsground=parse_int_field(raw.get("sportsground"), min_value=0),
other_beautification=clean_text(raw.get("other_beautification")),
foundation_type=clean_text(raw.get("foundation_type")),
floor_type=clean_text(raw.get("floor_type")),
wall_material=clean_text(raw.get("wall_material")),
basement_area=parse_decimal_comma(raw.get("basement_area")),
chute_type=clean_text(raw.get("chute_type")),
chute_count=parse_int_field(raw.get("chute_count"), min_value=0),
heating_type=clean_text(raw.get("heating_type")),
hot_water_type=clean_text(raw.get("hot_water_type")),
cold_water_type=clean_text(raw.get("cold_water_type")),
sewerage_type=clean_text(raw.get("sewerage_type")),
gas_type=clean_text(raw.get("gas_type")),
ventilation_type=clean_text(raw.get("ventilation_type")),
firefighting_type=clean_text(raw.get("firefighting_type")),
drainage_type=clean_text(raw.get("drainage_type")),
management_organization_id=parse_int_field(
raw.get("management_organization_id"), min_value=0
),
method_of_forming_overhaul_fund=clean_text(
raw.get("method_of_forming_overhaul_fund")
),
)
prev = rows.get(houseguid)
rows[houseguid] = _merge_rows(prev, parsed) if prev is not None else parsed
return rows
def _merge_rows(prev: FrtMkdRow, new: FrtMkdRow) -> FrtMkdRow:
"""Слить дубликат houseguid: для каждого поля берём первое непустое значение.
Источник отдаёт повторы одного дома разными строками с частично заполненными
полями (см. `parse_frt_mkd_csv`). Побеждает уже накопленное значение; новое
подставляется только туда, где накопленного нет.
"""
merged: dict[str, object] = {}
for field in fields(FrtMkdRow):
current = getattr(prev, field.name)
merged[field.name] = current if current is not None else getattr(new, field.name)
return FrtMkdRow(**merged) # type: ignore[arg-type]
# ─────────────────────────────────────────────────────────────────────────────
# HTTP: скачивание zip + извлечение CSV
# ─────────────────────────────────────────────────────────────────────────────
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
with zipfile.ZipFile(io.BytesIO(data)) as zf:
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
if not csv_names:
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
extracted = zf.extract(csv_names[0], dest_dir)
return Path(extracted)
def fetch_frt_mkd_csv(dest_dir: Path, *, node_id: int, client: httpx.Client) -> Path:
"""Скачивает zip export/{node_id}, распаковывает CSV в dest_dir, возвращает путь."""
url = f"{BASE_URL}/opendata/export/{node_id}"
return _extract_csv_from_zip(_download_zip(url, client=client), dest_dir)
# ─────────────────────────────────────────────────────────────────────────────
# UPSERT staging (frt_mkd, мигр. 290)
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_SQL = text(
"""
INSERT INTO frt_mkd (
houseguid, region_code, address, built_year, exploitation_start_year,
project_type, house_type, is_alarm, floor_count_max, floor_count_min,
entrance_count, elevators_count, energy_efficiency, quarters_count,
living_quarters_count, unliving_quarters_count, area_total, area_residential,
area_non_residential, area_common_property, area_land, parking_square,
playground, sportsground, other_beautification, foundation_type, floor_type,
wall_material, basement_area, chute_type, chute_count, heating_type,
hot_water_type, cold_water_type, sewerage_type, gas_type, ventilation_type,
firefighting_type, drainage_type, management_organization_id,
method_of_forming_overhaul_fund, loaded_at
)
VALUES (
CAST(:houseguid AS text), CAST(:region_code AS smallint), CAST(:address AS text),
CAST(:built_year AS smallint), CAST(:exploitation_start_year AS smallint),
CAST(:project_type AS text), CAST(:house_type AS text), CAST(:is_alarm AS boolean),
CAST(:floor_count_max AS smallint), CAST(:floor_count_min AS smallint),
CAST(:entrance_count AS smallint), CAST(:elevators_count AS smallint),
CAST(:energy_efficiency AS text), CAST(:quarters_count AS int),
CAST(:living_quarters_count AS int), CAST(:unliving_quarters_count AS int),
CAST(:area_total AS numeric), CAST(:area_residential AS numeric),
CAST(:area_non_residential AS numeric), CAST(:area_common_property AS numeric),
CAST(:area_land AS numeric), CAST(:parking_square AS numeric),
CAST(:playground AS int), CAST(:sportsground AS int),
CAST(:other_beautification AS text), CAST(:foundation_type AS text),
CAST(:floor_type AS text), CAST(:wall_material AS text),
CAST(:basement_area AS numeric), CAST(:chute_type AS text),
CAST(:chute_count AS smallint), CAST(:heating_type AS text),
CAST(:hot_water_type AS text), CAST(:cold_water_type AS text),
CAST(:sewerage_type AS text), CAST(:gas_type AS text),
CAST(:ventilation_type AS text), CAST(:firefighting_type AS text),
CAST(:drainage_type AS text), CAST(:management_organization_id AS bigint),
CAST(:method_of_forming_overhaul_fund AS text), now()
)
ON CONFLICT (houseguid) DO UPDATE SET
region_code = EXCLUDED.region_code,
address = EXCLUDED.address,
built_year = EXCLUDED.built_year,
exploitation_start_year = EXCLUDED.exploitation_start_year,
project_type = EXCLUDED.project_type,
house_type = EXCLUDED.house_type,
is_alarm = EXCLUDED.is_alarm,
floor_count_max = EXCLUDED.floor_count_max,
floor_count_min = EXCLUDED.floor_count_min,
entrance_count = EXCLUDED.entrance_count,
elevators_count = EXCLUDED.elevators_count,
energy_efficiency = EXCLUDED.energy_efficiency,
quarters_count = EXCLUDED.quarters_count,
living_quarters_count = EXCLUDED.living_quarters_count,
unliving_quarters_count = EXCLUDED.unliving_quarters_count,
area_total = EXCLUDED.area_total,
area_residential = EXCLUDED.area_residential,
area_non_residential = EXCLUDED.area_non_residential,
area_common_property = EXCLUDED.area_common_property,
area_land = EXCLUDED.area_land,
parking_square = EXCLUDED.parking_square,
playground = EXCLUDED.playground,
sportsground = EXCLUDED.sportsground,
other_beautification = EXCLUDED.other_beautification,
foundation_type = EXCLUDED.foundation_type,
floor_type = EXCLUDED.floor_type,
wall_material = EXCLUDED.wall_material,
basement_area = EXCLUDED.basement_area,
chute_type = EXCLUDED.chute_type,
chute_count = EXCLUDED.chute_count,
heating_type = EXCLUDED.heating_type,
hot_water_type = EXCLUDED.hot_water_type,
cold_water_type = EXCLUDED.cold_water_type,
sewerage_type = EXCLUDED.sewerage_type,
gas_type = EXCLUDED.gas_type,
ventilation_type = EXCLUDED.ventilation_type,
firefighting_type = EXCLUDED.firefighting_type,
drainage_type = EXCLUDED.drainage_type,
management_organization_id = EXCLUDED.management_organization_id,
method_of_forming_overhaul_fund = EXCLUDED.method_of_forming_overhaul_fund,
loaded_at = now()
WHERE frt_mkd.address IS DISTINCT FROM EXCLUDED.address
OR frt_mkd.built_year IS DISTINCT FROM EXCLUDED.built_year
OR frt_mkd.is_alarm IS DISTINCT FROM EXCLUDED.is_alarm
OR frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material
OR frt_mkd.foundation_type IS DISTINCT FROM EXCLUDED.foundation_type
OR frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land
OR frt_mkd.elevators_count IS DISTINCT FROM EXCLUDED.elevators_count
"""
)
def _chunk_rows(items: list[FrtMkdRow], size: int) -> Iterator[list[FrtMkdRow]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def upsert_frt_mkd(
db: Session, rows: list[FrtMkdRow], *, chunk_size: int = UPSERT_CHUNK_SIZE
) -> int:
"""UPSERT списка FrtMkdRow в frt_mkd, чанками по SAVEPOINT. Не коммитит (caller).
Идемпотентно (IS DISTINCT FROM gate на ключевых полях в _UPSERT_SQL). Сбойный чанк
откатывается изолированно (SAVEPOINT-паттерн domrf_kapremont_loader/zhkh_flats_loader).
"""
upserted = 0
for chunk in _chunk_rows(rows, chunk_size):
try:
with db.begin_nested():
for r in chunk:
res = db.execute(
_UPSERT_SQL,
{
"houseguid": r.houseguid,
"region_code": r.region_code,
"address": r.address,
"built_year": r.built_year,
"exploitation_start_year": r.exploitation_start_year,
"project_type": r.project_type,
"house_type": r.house_type,
"is_alarm": r.is_alarm,
"floor_count_max": r.floor_count_max,
"floor_count_min": r.floor_count_min,
"entrance_count": r.entrance_count,
"elevators_count": r.elevators_count,
"energy_efficiency": r.energy_efficiency,
"quarters_count": r.quarters_count,
"living_quarters_count": r.living_quarters_count,
"unliving_quarters_count": r.unliving_quarters_count,
"area_total": r.area_total,
"area_residential": r.area_residential,
"area_non_residential": r.area_non_residential,
"area_common_property": r.area_common_property,
"area_land": r.area_land,
"parking_square": r.parking_square,
"playground": r.playground,
"sportsground": r.sportsground,
"other_beautification": r.other_beautification,
"foundation_type": r.foundation_type,
"floor_type": r.floor_type,
"wall_material": r.wall_material,
"basement_area": r.basement_area,
"chute_type": r.chute_type,
"chute_count": r.chute_count,
"heating_type": r.heating_type,
"hot_water_type": r.hot_water_type,
"cold_water_type": r.cold_water_type,
"sewerage_type": r.sewerage_type,
"gas_type": r.gas_type,
"ventilation_type": r.ventilation_type,
"firefighting_type": r.firefighting_type,
"drainage_type": r.drainage_type,
"management_organization_id": r.management_organization_id,
"method_of_forming_overhaul_fund": r.method_of_forming_overhaul_fund,
},
)
upserted += res.rowcount
except Exception:
logger.warning(
"frt_mkd upsert: чанк из %d строк сбойнул (откат savepoint)",
len(chunk),
exc_info=True,
)
return upserted
def load_frt_mkd(
db: Session,
*,
src_path: str | Path | None = None,
work_dir: str | Path | None = None,
node_id: int = DEFAULT_NODE_ID,
region_code: int = DEFAULT_REGION_CODE,
chunk_size: int = UPSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачивает (если src_path не задан) реестр МКД node_id, парсит, UPSERT в frt_mkd.
src_path локальный CSV (пропустить скачивание; тесты/ops-дебаг). work_dir куда
распаковывать скачанный zip (по умолчанию временный каталог, удаляется после).
dry_run парс происходит, но НИ ОДНОЙ записи в БД не делается. Не коммитит (caller).
"""
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
if src_path is None:
if work_dir is not None:
dest = Path(work_dir)
dest.mkdir(parents=True, exist_ok=True)
else:
tmp_ctx = tempfile.TemporaryDirectory()
dest = Path(tmp_ctx.name)
# verify=False: источник отдаёт RU-сертификат НУЦ Минцифры, не верифицируемый
# дефолтным trust store'ом (та же ситуация, что sber_index.py и
# domrf_kapremont_loader.py). Открытые данные без auth/PII — приемлемо.
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
src_path = fetch_frt_mkd_csv(dest, node_id=node_id, client=client)
try:
rows_by_guid = parse_frt_mkd_csv(src_path, region_code=region_code)
rows = list(rows_by_guid.values())
upserted = 0 if dry_run else upsert_frt_mkd(db, rows, chunk_size=chunk_size)
finally:
if tmp_ctx is not None:
tmp_ctx.cleanup()
result = {"rows": len(rows), "upserted": upserted}
logger.info("frt_mkd load DONE (dry_run=%s): %s", dry_run, result)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Backfill houses (COALESCE-семантика — только NULL-поля)
# ─────────────────────────────────────────────────────────────────────────────
# Матч: s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid) —
# тот же приоритетный COALESCE-джойн, что domrf_kapremont_loader/zhkh_flats_loader.
# Каждое поле СВОИМ гейтом "h.col IS NULL AND s.col-в-границах" — ни одно поле, уже
# заполненное другим источником, не перезаписывается. frt_matched_at проставляется один
# раз (COALESCE(h.frt_matched_at, now())) при ЛЮБОМ найденном матче — метка «дом уже
# сверялся с этим источником», даже если дозаполнять было уже нечего.
_BACKFILL_HOUSES_SQL = text(
"""
UPDATE houses h
SET year_built = CASE
WHEN h.year_built IS NULL
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN s.built_year
ELSE h.year_built
END,
material_walls = COALESCE(h.material_walls, s.wall_material),
material_floors = COALESCE(h.material_floors, s.floor_type),
total_floors = CASE
WHEN h.total_floors IS NULL
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int)
THEN s.floor_count_max
ELSE h.total_floors
END,
entrances = CASE
WHEN h.entrances IS NULL
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int)
THEN s.entrance_count
ELSE h.entrances
END,
elevators_total = CASE
WHEN h.elevators_total IS NULL
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int)
THEN s.elevators_count
ELSE h.elevators_total
END,
is_emergency = COALESCE(h.is_emergency, s.is_alarm),
flat_count = CASE
WHEN h.flat_count IS NULL
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int)
THEN s.living_quarters_count
ELSE h.flat_count
END,
heat_supply_type = COALESCE(h.heat_supply_type, s.heating_type),
gas_supply_type = COALESCE(h.gas_supply_type, s.gas_type),
hot_water = COALESCE(h.hot_water, s.hot_water_type),
area_land = CASE
WHEN h.area_land IS NULL
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric)
THEN s.area_land
ELSE h.area_land
END,
foundation_type = COALESCE(h.foundation_type, s.foundation_type),
frt_matched_at = COALESCE(h.frt_matched_at, now())
FROM frt_mkd s
WHERE s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
AND (
(h.year_built IS NULL
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
OR (h.total_floors IS NULL
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
OR (h.entrances IS NULL
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
OR (h.elevators_total IS NULL
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
OR (h.flat_count IS NULL
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
OR (h.area_land IS NULL
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
OR h.frt_matched_at IS NULL
)
"""
)
_BACKFILL_HOUSES_COUNT_SQL = text(
"""
SELECT count(*)
FROM houses h
JOIN frt_mkd s
ON s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
WHERE (
(h.year_built IS NULL
AND s.built_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
OR (h.material_walls IS NULL AND s.wall_material IS NOT NULL)
OR (h.material_floors IS NULL AND s.floor_type IS NOT NULL)
OR (h.total_floors IS NULL
AND s.floor_count_max BETWEEN 1 AND CAST(:floor_max AS int))
OR (h.entrances IS NULL
AND s.entrance_count BETWEEN 1 AND CAST(:entrance_max AS int))
OR (h.elevators_total IS NULL
AND s.elevators_count BETWEEN 0 AND CAST(:elevator_max AS int))
OR (h.is_emergency IS NULL AND s.is_alarm IS NOT NULL)
OR (h.flat_count IS NULL
AND s.living_quarters_count BETWEEN 0 AND CAST(:flat_max AS int))
OR (h.heat_supply_type IS NULL AND s.heating_type IS NOT NULL)
OR (h.gas_supply_type IS NULL AND s.gas_type IS NOT NULL)
OR (h.hot_water IS NULL AND s.hot_water_type IS NOT NULL)
OR (h.area_land IS NULL
AND s.area_land BETWEEN 0 AND CAST(:area_land_max AS numeric))
OR (h.foundation_type IS NULL AND s.foundation_type IS NOT NULL)
OR h.frt_matched_at IS NULL
)
"""
)
def backfill_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
"""COALESCE-добор houses.* из frt_mkd (только NULL-поля). Не коммитит (caller).
dry_run ноль записей, только SELECT count(*) по тому же предикату.
"""
bounds = {
"ymin": YEAR_BUILT_MIN,
"ymax": plausible_year_max(),
"floor_max": FLOOR_COUNT_MAX_BOUND,
"entrance_max": ENTRANCE_COUNT_MAX_BOUND,
"elevator_max": ELEVATORS_COUNT_MAX_BOUND,
"flat_max": FLAT_COUNT_MAX_BOUND,
"area_land_max": AREA_LAND_MAX_BOUND,
}
if dry_run:
would_update = db.execute(_BACKFILL_HOUSES_COUNT_SQL, bounds).scalar_one()
result = {"would_update": would_update, "houses_updated": 0}
logger.info("backfill_houses (frt_mkd) DRY-RUN: %s", result)
return result
updated = db.execute(_BACKFILL_HOUSES_SQL, bounds).rowcount
result = {"houses_updated": updated}
logger.info("backfill_houses (frt_mkd) DONE: %s", result)
return result

View file

@ -28,9 +28,16 @@ from tenacity import retry, stop_after_attempt, wait_exponential
from app.core.config import settings
from app.services import dadata
from app.services.regions import REGIONS as _ALL_REGIONS
from app.services.regions import Region, is_within_bbox
_REGION_66 = _ALL_REGIONS[66]
# #3051: маркер `address.state` Nominatim по региону, для region cross-check
# в `_nominatim_region_ok` (см. использование в `_nominatim_query`). Регионы
# без записи здесь получают `marker=None` → cross-check пропускается
# (fallback на bbox-only, прежнее поведение).
_REGION_STATE_MARKERS: dict[int, str] = {66: "свердловск", 77: "москва"}
logger = logging.getLogger(__name__)
# ── Общий ограничитель темпа обращений к Nominatim (#2953) ──────────────────
@ -170,8 +177,15 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
SVERDLOVSK_OBLAST_CITIES = _REGION_66.cities # #3051: список — в реестре регионов
def known_city_hint(value: str | None) -> str | None:
"""`value` как city_hint, если это узнаваемое имя города региона 66, иначе None.
def known_city_hint(value: str | None, region_code: int = 66) -> str | None:
"""`value` как city_hint, если это узнаваемое имя города `REGIONS[region_code]`, иначе None.
#3051: `region_code` (дефолт 66) — параметризация под трек «Москва»: словарь
городов берётся из `REGIONS[region_code].cities`, а не жёстко из
`SVERDLOVSK_OBLAST_CITIES`. Для `region_code=66` (дефолт, все существующие
вызовы без аргумента) byte-identical прежнему поведению: `REGIONS[66].cities
is SVERDLOVSK_OBLAST_CITIES` (тот же frozenset-объект, см. модульный уровень).
Неизвестный `region_code` ValueError (явная ошибка, не молчаливый None).
Для callers, которые берут город из КОЛОНКИ БД и передают его в `geocode()`
(#2603): `deals.city` — росреестровое поле, заполнено на 100%, но в хвосте
@ -200,7 +214,14 @@ def known_city_hint(value: str | None) -> str | None:
"""
if not value:
return None
return value if " ".join(value.lower().split()) in SVERDLOVSK_OBLAST_CITIES else None
if region_code == 66:
cities = SVERDLOVSK_OBLAST_CITIES
else:
try:
cities = _ALL_REGIONS[region_code].cities
except KeyError as exc:
raise ValueError(f"unknown region_code={region_code!r}") from exc
return value if " ".join(value.lower().split()) in cities else None
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
@ -231,20 +252,41 @@ _OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"})
def _has_oblast_marker(text_lower: str) -> bool:
"""True если текст уже содержит упоминание области/города региона 66.
# region_code → скомпилированный regex городов региона (word-boundary), кэш по
# коду. 66 — literal reuse `_OBLAST_CITY_RE` (тот же объект, byte-identical),
# остальные регионы строятся из `REGIONS[region_code].cities` при первом
# обращении (#3051).
_REGION_CITY_RE: dict[int, re.Pattern[str]] = {66: _OBLAST_CITY_RE}
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
уже привязан к другому городу/области иначе получим двойной город
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
Матчинг по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
def _region_city_re(region_code: int) -> re.Pattern[str]:
cached = _REGION_CITY_RE.get(region_code)
if cached is not None:
return cached
cities = _ALL_REGIONS[region_code].cities
compiled = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in cities) + r")\b")
_REGION_CITY_RE[region_code] = compiled
return compiled
def _has_oblast_marker(text_lower: str, region_code: int = 66) -> bool:
"""True если текст уже содержит упоминание области/города `region_code`.
Используется чтобы НЕ навязывать "Екатеринбург, "/"Москва, " в запрос,
когда адрес уже привязан к другому городу/области иначе получим двойной
город ("Екатеринбург, Нижний Тагил, Ленина 10" / "Москва, Москва, Тверская
1", #3051 п. б) и провайдер вернёт мусор/пусто.
Матчинг по границе слова/фразы (`_region_city_re`), НЕ substring и с
исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
`region_code=66` (дефолт) дополнительно матчит "свердловск*" областной
маркер без города; у прочих регионов такого обобщённого маркера нет,
город региона уже покрывает случай (для 77 "москва" в `region.cities`).
"""
normalized = " ".join(text_lower.split())
if _OBLAST_MARKER_RE.search(normalized):
if region_code == 66 and _OBLAST_MARKER_RE.search(normalized):
return True
for m in _OBLAST_CITY_RE.finditer(normalized):
for m in _region_city_re(region_code).finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
@ -252,12 +294,14 @@ def _has_oblast_marker(text_lower: str) -> bool:
return False
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
def _resolve_city_for_geocode(
address: str, city_hint: str | None, region_code: int = 66
) -> tuple[str | None, bool]:
"""Определяет, какой город подставлять в запрос внешнему провайдеру
(Nominatim), когда сам текст адреса города не называет.
Приоритет:
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
1. Адрес уже содержит маркер города/области `region_code` (`_has_oblast_marker`)
город уже указан пользователем в тексте адреса, ничего подставлять не
нужно. Возвращает (None, True).
2. `city_hint` передан вызывающим кодом (например, фронт знает выбранный
@ -266,15 +310,18 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
привязки к конкретному городу. Возвращает
провайдер ищет по region-viewbox/bbox (см. `OBLAST66_VIEWBOX`,
`_region_viewbox`), без привязки к конкретному городу. Возвращает
(None, False) второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
`region_code` (дефолт 66, #3051) — byte-identical прежнему поведению для
всех вызовов без аргумента.
Returns:
(city_or_none, city_specified_by_user).
"""
if _has_oblast_marker(address.lower()):
if _has_oblast_marker(address.lower(), region_code):
return None, True
hint = (city_hint or "").strip()
if hint:
@ -678,13 +725,15 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
def _nominatim_region_ok(item: dict) -> bool | None:
def _nominatim_region_ok(item: dict, region_code: int = 66) -> bool | None:
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
True/False если state однозначно про/не про Свердловскую область. None если
поле отсутствует/не строка тогда accept-логика падает обратно на bbox.
True/False если state однозначно про/не про регион `region_code`. None если
поле отсутствует/не строка, ИЛИ регион не имеет записи в `_REGION_STATE_MARKERS`
тогда accept-логика падает обратно на bbox.
Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри генерального OBLAST66_BBOX
(специально щедрого), но их state явно другой регион.
(специально щедрого), но их state явно другой регион. `region_code=66`
(дефолт) byte-identical прежнему поведению (`"свердловск" in state.lower()`).
"""
addr = item.get("address")
if not isinstance(addr, dict):
@ -692,22 +741,41 @@ def _nominatim_region_ok(item: dict) -> bool | None:
state = addr.get("state")
if not isinstance(state, str) or not state:
return None
return "свердловск" in state.lower()
marker = _REGION_STATE_MARKERS.get(region_code)
if marker is None:
return None
return marker in state.lower()
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
def _region_viewbox(region: Region) -> str:
"""Nominatim `viewbox` (lon_min,lat_max,lon_max,lat_min) из `region.bbox_region`.
Для region 66 см. `OBLAST66_VIEWBOX["viewbox"]` литеральная константа
(byte-identical), эта функция для 66 не вызывается.
"""
lat_min, lat_max, lon_min, lon_max = region.bbox_region
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
async def _nominatim_query(
client: httpx.AsyncClient, address: str, region_code: int = 66
) -> dict | None:
"""Single Nominatim search. Возвращает лучший item или None.
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
опечатках не возвращать Пермский край / Челябинск но не резать легитимные
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
ВАЖНО: фильтруем результаты по bbox региона `region_code` прямо тут, чтобы
при опечатках не возвращать Пермский край / Челябинск но не резать
легитимные Нижний Тагил / Серов и т.д. (генеральный bbox всего региона).
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
не первым) иначе первый кандидат внутри OBLAST66. Плюс region cross-check
(`address.state`) отсекает кандидатов ЯВНО из другого региона (Тюмень и
т.п.), даже если координаты попали в генеральный bbox.
bbox региона (byte-identical для region_code=66, даже если Nominatim
ранжировал его не первым) иначе первый кандидат внутри генерального bbox
региона. Плюс region cross-check (`address.state`) отсекает кандидатов
ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в
генеральный bbox. `region_code=66` (дефолт) byte-identical прежнему
поведению (те же bbox-значения и та же viewbox-строка).
"""
region = _ALL_REGIONS[region_code]
viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region)
await _nominatim_throttle()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -717,8 +785,8 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
"limit": "3",
"countrycodes": "ru",
"addressdetails": "1",
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", # строго в пределах области (region 66)
"viewbox": viewbox,
"bounded": "1", # строго в пределах региона
},
)
response.raise_for_status()
@ -730,11 +798,11 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
lon_f = float(item["lon"])
except Exception:
continue
if _nominatim_region_ok(item) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
if _nominatim_region_ok(item, region_code) is False:
continue # регион явно не тот, что запрошен — не рассматриваем
if is_within_bbox(lat_f, lon_f, region.bbox_tight):
return item # tight-bbox приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_bbox(lat_f, lon_f, region.bbox_region):
oblast_fallback = item
return oblast_fallback
@ -756,21 +824,25 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
async def _nominatim_lookup(
address: str, city_hint: str | None = None, region_code: int = 66
) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
Бан-policy: User-Agent с email обязателен.
Tier 1: bounded область (region 66) на оригинальный адрес.
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга Цвиллинга).
Tier 1: bounded регион `region_code` на оригинальный адрес.
Tier 2: bounded регион `region_code` на typo-варианты (Цвилинга Цвиллинга).
#2580 (C): city_hint, если известен, подставляется в текст запроса — без
него `_nominatim_query` полагается ТОЛЬКО на oblast66-bbox фильтр + tie-break
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
(напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не различает город.
Эмпирически подтверждено: "Ленина 1" без города случайное село внутри
области; "Нижний Тагил, Ленина 1" корректно резолвится. Nominatim
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
city_hint должен реально влиять на его результат, не только на кэш-ключ.
него `_nominatim_query` полагается ТОЛЬКО на region-bbox фильтр + tie-break
(предпочитает tight bbox региона), который для одноимённых улиц ВНУТРИ
региона (напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не
различает город. Эмпирически подтверждено: "Ленина 1" без города
случайное село внутри области; "Нижний Тагил, Ленина 1" корректно
резолвится. Nominatim единственный живой внешний провайдер (#2593:
Yandex Geocoder удалён) city_hint должен реально влиять на его результат,
не только на кэш-ключ. `region_code=66` (дефолт, #3051) — byte-identical
прежнему поведению для всех вызовов без аргумента.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
@ -778,18 +850,18 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
"Accept-Language": "ru,en;q=0.8",
"Referer": "https://tradein-mvp.local/",
}
city, _ = _resolve_city_for_geocode(address, city_hint)
city, _ = _resolve_city_for_geocode(address, city_hint, region_code)
query = f"{city}, {address}" if city else address
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
# Tier 1: оригинал
item = await _nominatim_query(client, query)
item = await _nominatim_query(client, query, region_code)
# Tier 2: typo-variants
if item is None:
for variant in _typo_variants(address, limit=4):
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant_city, _ = _resolve_city_for_geocode(variant, city_hint, region_code)
variant_query = f"{variant_city}, {variant}" if variant_city else variant
item = await _nominatim_query(client, variant_query)
item = await _nominatim_query(client, variant_query, region_code)
if item is not None:
logger.info("nominatim typo-fixed: %s%s", address, variant)
break
@ -1753,7 +1825,9 @@ async def suggest(
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
async def geocode(
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
) -> GeocodeResult | None:
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
@ -1771,8 +1845,21 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
за собой чужие оценки. Координаты НЕ выбрасываются деградация честная и
видимая, а не отказ.
`region_code` (дефолт 66, #3051) — какой `REGIONS`-регион искать (bbox,
city-словарь, ЕКБ-only локальные тиры). Неизвестный код `ValueError`
сразу, а не глубоко внутри `_nominatim_query`. Все существующие вызовы без
аргумента получают region_code=66 byte-identical прежнему поведению.
`_city_substituted` region_code не принимает: инвариант завязан на
ЕКБ-bbox координат результата (`is_within_ekb_bbox`), который для другого
региона (Москва и т.п.) структурно не совпадает условие 3 инварианта
никогда не сработает, ложного понижения confidence до "locality" не будет.
"""
result = await _geocode_resolve(address, db, city_hint)
try:
_ALL_REGIONS[region_code]
except KeyError as exc:
raise ValueError(f"geocode: unknown region_code={region_code!r}") from exc
result = await _geocode_resolve(address, db, city_hint, region_code)
if result is None or not _city_substituted(address, result):
return result
logger.warning(
@ -1788,7 +1875,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
async def _geocode_resolve(
address: str, db: Session, city_hint: str | None = None
address: str, db: Session, city_hint: str | None = None, region_code: int = 66
) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
@ -1802,6 +1889,10 @@ async def _geocode_resolve(
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали
друг друга.
region_code: регион покрытия (дефолт 66, #3051). ЕКБ-only локальные
тиры (geoportal/cad_buildings/houses) применяются ТОЛЬКО при 66
это ЕКБ-специфичные реестры, у других регионов данных в них нет.
Прокидывается в Nominatim-тир (bbox/viewbox/city-словарь).
Returns:
GeocodeResult или None если ни один провайдер не отвечает.
@ -1812,7 +1903,7 @@ async def _geocode_resolve(
if not address or len(address.strip()) < 3:
return None
_, city_specified = _resolve_city_for_geocode(address, city_hint)
_, city_specified = _resolve_city_for_geocode(address, city_hint, region_code)
city_ambiguous = not city_specified
addr_norm = _cache_key(normalize_address(address), city_hint)
@ -1838,7 +1929,14 @@ async def _geocode_resolve(
# Раньше решение по тексту адреса принималось от противного (список из 37
# городов — «нет в списке → считаем ЕКБ»), из-за чего любой другой регион
# РФ (Ялта, Трёхгорный) молча резолвился в координаты ЕКБ (#2582).
use_local_ekb = _ekb_local_tiers_allowed(address, city_hint)
#
# #3051: `region_code != 66` закрывает эти тиры целиком, ДО вызова
# `_ekb_local_tiers_allowed` — geoportal/cad_buildings/houses физически не
# содержат данных других регионов (не "город не распознан словарём 66", а
# "реестра для этого региона нет вовсе"), а сама `_ekb_local_tiers_allowed`
# (её ЕКБ-словари: `_names_non_ekb_city`/`_names_unrecognized_locality`)
# region_code не принимает — умышленно не трогаем её сигнатуру.
use_local_ekb = region_code == 66 and _ekb_local_tiers_allowed(address, city_hint)
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
if use_local_ekb and parsed is not None:
@ -1914,7 +2012,7 @@ async def _geocode_resolve(
# 3. Nominatim fallback
try:
result = await _nominatim_lookup(address, city_hint)
result = await _nominatim_lookup(address, city_hint, region_code)
if result is not None:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)

View file

@ -126,7 +126,14 @@ class TBankClient:
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(url, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
# Родитель всех транспортных отказов, а не пара TimeoutException +
# NetworkError: RemoteProtocolError (банк оборвал ответ), ProxyError и
# UnsupportedProtocol мимо той пары летели наружу голым httpx-исключением.
# Вызывающая сторона ловит только TBankApiError, поэтому строка платежа
# оставалась NEW без payment_url — то есть невидимой для
# _find_live_payment, но видимой предикату UNIQUE миграции 279, и
# покупатель запирался на _ABANDONED_AFTER_MINUTES из-за сбоя банка.
except httpx.TransportError as exc:
if attempt > max_retries:
logger.error(
"tbank client: %s — network error после %d попыток: %s",

View file

@ -367,7 +367,27 @@ async def _job_yandex_newbuilding_sweep(
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
processed = int(counters.get("processed") or 0)
succeeded = int(counters.get("succeeded") or 0)
if processed > 0 and succeeded == 0:
if result.no_proxy_stop:
# #3197: прогон оборван на пустом пуле — к площадке не ходили вовсе.
# Это отказ нашей инфраструктуры, а не «ЖК не разрешились»: называть
# такой прогон успешным нельзя, и причина должна быть отличима.
#
# INFO, как у соседей (scheduler.py:175, avito_detail_backfill.py:1050,
# domclick:626): причина уже записана в mark_failed + counters.no_proxy_stop=1,
# а ERROR ставил её в один разряд с падением задачи (logger.exception ниже).
logger.info(
"yandex_newbuilding_sweep run_id=%d: пул прокси пуст — прогон оборван "
"(обработано %d)",
run_id,
processed,
)
ctx.runs.mark_failed(
db,
run_id,
"пул прокси пуст — прогон оборван, к площадке не ходили",
counters,
)
elif processed > 0 and succeeded == 0:
logger.warning(
"yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — "
"помечаю прогон неуспешным",
@ -524,6 +544,19 @@ async def _job_osm_poi_ekb_refresh(
)
# ── dtp_stat_refresh — sync ZIP-скачивание+парс ДТП в executor ────────────────
async def _job_dtp_stat_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.dtp_stat_refresh import run_dtp_stat_refresh
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_dtp_stat_refresh(db, run_id=run_id, params=params),
)
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
async def _job_house_imv_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -641,6 +674,133 @@ async def _job_domrf_kapremont_load(
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── fns_opendata_load — bulk-дампы ФНС по юрлицам → fns_legal_entity_facts ────
# Открытые данные ФНС по ЮРЛИЦАМ (лицензия nalog.gov.ru/opendata разрешает
# переработку/перераспространение) — легальный обход того, что открытых данных
# ЕГРН по правообладателям-физлицам не существует (218-ФЗ ст. 62). Потребителя у
# fns_legal_entity_facts на момент добавления НЕТ (см. docstring
# app/services/fns_opendata_loader.py и fns_lookup.py) — расписание сидируется
# enabled=false, включение отдельным осознанным шагом.
async def _job_fns_opendata_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать наборы ФНС opendata (revexp/sshr2019/debtam/snr) → fns_legal_entity_facts.
Тело переиспользует load_dataset (тот же дизайн-инвариант модуля, что у
_job_domrf_kapremont_load: не дублируем логику CLI). commit() после каждого
набора сбой на debtam не должен откатывать уже загруженный revexp.
"""
from app.services.fns_opendata_loader import DATASET_SLUGS, load_dataset
datasets = params.get("datasets") or list(DATASET_SLUGS)
def _run() -> dict[str, int]:
total_records = 0
total_upserted = 0
for slug in datasets:
result = load_dataset(db, slug)
db.commit()
total_records += int(result["records"])
total_upserted += int(result["upserted"])
return {
"records": total_records,
"upserted": total_upserted,
# см. докстринг _job_domrf_kapremont_load: выделенные колонки прогона +
# гейт «три подряд нулевых прогона».
"total_seen": total_records,
"new_count": total_upserted,
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: fns_opendata_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── frt_mkd_load — АИС ППК ФРТ, реестр МКД region 66 (issue #frt-mkd) ────────
async def _job_frt_mkd_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать реестр МКД АИС ФРТ (node_id) → staging frt_mkd → backfill houses.
Тело переиспользует те же функции, что и CLI (app/tasks/frt_mkd_load.py) не
дублируем логику. node_id/region_code берутся из scrape_schedules.default_params
(мигр. 291), с фоллбеком на дефолты loader'а.
"""
from app.services.frt_mkd_loader import (
DEFAULT_NODE_ID,
DEFAULT_REGION_CODE,
backfill_houses,
load_frt_mkd,
)
node_id = int(params.get("node_id", DEFAULT_NODE_ID))
region_code = int(params.get("region_code", DEFAULT_REGION_CODE))
def _run() -> dict[str, int]:
load_counts = load_frt_mkd(db, node_id=node_id, region_code=region_code)
db.commit()
houses_counts = backfill_houses(db)
db.commit()
return {
"rows": load_counts["rows"],
"upserted": load_counts["upserted"],
"houses_updated": houses_counts["houses_updated"],
"total_seen": load_counts["rows"],
"new_count": houses_counts["houses_updated"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: frt_mkd_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── cbr_macro_pull — макро-ряды ЦБ РФ (ипотека по субъектам + ключевая ставка) ─
async def _job_cbr_macro_pull(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Ипотечные ряды (XLSX) + ключевая ставка (SOAP) → cbr_mortgage_series/cbr_key_rate.
Тело переиспользует те же функции, что и CLI (app/tasks/cbr_macro_pull.py)
дизайн-инвариант модуля: не дублируем логику. Lifecycle не свой
mark_done/mark_failed здесь, как у _job_domrf_kapremont_load.
"""
from app.services.cbr_macro import load_key_rate, pull_cbr_mortgage
def _run() -> dict[str, int]:
mortgage_counts = pull_cbr_mortgage(db)
db.commit()
key_rate_counts = load_key_rate(db)
db.commit()
return {
"mortgage_upserted": mortgage_counts["upserted"],
"mortgage_skipped": mortgage_counts["skipped"],
"mortgage_errors": mortgage_counts["errors"],
"key_rate_upserted": key_rate_counts["upserted"],
# выделенные колонки прогона + гейт «три подряд нулевых прогона» (#2625).
"total_seen": mortgage_counts["upserted"] + key_rate_counts["rows"],
"new_count": mortgage_counts["upserted"] + key_rate_counts["upserted"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: cbr_macro_pull crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -730,6 +890,13 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
post_claim=reschedule_after_minutes(param="interval_minutes", default=360),
),
"rosreestr_dkp_import": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import"),
# Wildcard (#3051 п.3): rosreestr_dkp_import_77 (Москва, миграция 288) и любой
# будущий region_code-суффикс из той же семьи резолвятся сюда через
# resolve_handler по префиксу (тот же механизм, что deactivate_stale_* /
# avito_city_sweep_* — см. scraper_kit.orchestration.scheduler.resolve_handler).
# region_code берётся из default_params строки расписания (import_rosreestr_dkp
# сам валидирует его через app.services.regions.REGIONS), Handler-тело общее.
"rosreestr_dkp_import_*": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import_*"),
"listing_source_snapshot": Handler(_job_listing_source_snapshot, "listing_source_snapshot"),
"asking_to_sold_ratio_refresh": Handler(
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
@ -803,9 +970,13 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
"frt_mkd_load": Handler(_job_frt_mkd_load, "frt_mkd_load"),
"cbr_macro_pull": Handler(_job_cbr_macro_pull, "cbr_macro_pull"),
"fns_opendata_load": Handler(_job_fns_opendata_load, "fns_opendata_load"),
"purge_expired_trade_in_data": Handler(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
),

View file

@ -54,7 +54,10 @@ Self-healing (#2600):
WARNING (пул надо пополнять, #2638).
- Ручное снятие `clear_source_bans` (ложный бан детектора капчи, #2642) плюс
автоматическое после успешной ротации exit-IP: бан привязан к proxy_id, а банился
IP, поэтому смена адреса делает строку недействительной.
IP, поэтому смена адреса делает строку недействительной. С #3404 «снятие» гасит
строку (`banned_until = now()`, `ban_count = 0`, `cleared_at`/`cleared_reason`),
а не удаляет её строка живёт до штатного purge (SOURCE_BAN_PURGE_DAYS), но для
выдачи и для эскалации следующего бана это неотличимо от прежнего DELETE.
Ручное выключение vs авто-выключение (#2610):
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
@ -143,6 +146,7 @@ __all__ = [
"STALE_LEASE_MINUTES",
"ProxyLease",
"acquire",
"attribute_run_proxy",
"clear_source_bans",
"mark_banned",
"mark_browser_health",
@ -441,6 +445,11 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
{"run_id": lease_marker, "id": proxy_id},
)
db.commit()
if run_id is not None and run_id != NON_RUN_LEASE_MARKER:
# #3404: одна точка, покрывающая ВСЕ пути выдачи (curl — acquire на каждый
# вызов, браузер — sticky lease на весь прогон, ре-acquire при ротации узла
# mid-run) — см. attribute_run_proxy docstring.
attribute_run_proxy(db, run_id, proxy_id)
if fallback_used:
logger.warning(
"proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity "
@ -474,6 +483,78 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
)
def attribute_run_proxy(db: Session, run_id: int, proxy_id: int) -> None:
"""Записать узел, через который идёт прогон run_id, в scrape_runs (#3404).
Единственный писатель `acquire()` сразу после выдачи lease'а: покрывает и
curl-путь (acquire на каждый вызов), и браузерный sticky lease (один acquire на
весь прогон), и ре-acquire при ротации узла mid-run (`browser_fetcher`,
`_LEASE_ROTATE_AFTER_FAILS`) то есть смена узла ЗА прогон фиксируется сама,
без отдельного вызова с чьей-либо стороны.
`scrape_runs.proxy_id` ПОСЛЕДНИЙ использованный узел (перезаписывается при
каждой новой выдаче); полная цепочка узлов, если она менялась, в
`counters.proxy_ids` (список id, без дублей). Пишем через `||`-мерж
`counters` (тот же контракт, что у `runs.update_heartbeat`/`mark_done`)
чужие ключи (чекпоинт, метка interrupted) не затираются.
Идемпотентно: повторная выдача ТОГО ЖЕ узла не дублирует его в `proxy_ids`
(`@>`-проверка перед append). Best-effort: любой сбой (например, run_id уже
не существует гонка с финализацией) логируется WARNING и проглатывается
атрибуция прогону не должна ронять выдачу прокси, это диагностика, а не
часть контракта lease'а. 0 rows (run_id не найден) — DEBUG, не ошибка: сама
выдача при этом уже произошла и коммитнута предыдущим db.commit() в acquire().
"""
try:
# Строку прогона параллельно обновляет heartbeat/финализатор из ДРУГОЙ сессии
# (короткие транзакции, каждая со своим commit). Пересечение маловероятно, но
# ждать на блокировке в пути выдачи прокси нельзя — диагностика не должна
# тормозить сбор. Не дождались за 2с — уходим в except ниже (WARNING, lease цел).
db.execute(text("SET LOCAL lock_timeout = '2s'"))
row = db.execute(
text(
"""
UPDATE scrape_runs
SET proxy_id = CAST(:proxy_id AS bigint),
counters = COALESCE(counters, '{}'::jsonb) || jsonb_build_object(
'proxy_ids',
CASE
WHEN COALESCE(counters -> 'proxy_ids', '[]'::jsonb)
@> to_jsonb(CAST(:proxy_id AS bigint))
THEN COALESCE(counters -> 'proxy_ids', '[]'::jsonb)
ELSE COALESCE(counters -> 'proxy_ids', '[]'::jsonb)
|| jsonb_build_array(CAST(:proxy_id AS bigint))
END
)
WHERE id = CAST(:run_id AS bigint)
RETURNING id
"""
),
{"proxy_id": proxy_id, "run_id": run_id},
).first()
db.commit()
if row is None:
logger.debug(
"proxy_pool: attribute_run_proxy no-op — run_id=%d not found (already "
"finalized?)",
run_id,
)
except Exception:
# Best-effort (см. docstring) — атрибуция диагностическая, не часть
# контракта lease'а: lease уже выдан и не должен теряться из-за неё.
logger.warning(
"proxy_pool: attribute_run_proxy failed run_id=%d proxy_id=%d — lease "
"issued regardless",
run_id,
proxy_id,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def release(db: Session, proxy_id: int) -> None:
"""Освободить прокси (leased_by/leased_at → NULL). Идемпотентно (0-row если уже свободен)."""
db.execute(
@ -935,15 +1016,21 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None =
)
)
ON CONFLICT (proxy_id, source) DO UPDATE
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
banned_until = now() + make_interval(hours => CAST(
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
banned_until = now() + make_interval(hours => CAST(
LEAST(
CAST(:base_hours AS integer)
* power(2, LEAST(scrape_proxy_source_bans.ban_count, 16)),
CAST(:max_hours AS integer)
) AS integer)),
reason = CAST(:reason AS text),
updated_at = now()
reason = CAST(:reason AS text),
-- #3404: строка могла быть погашена clear_source_bans (banned_until
-- в прошлом попадает в WHERE ниже) новый бан затирает её метки
-- гашения, иначе на СНОВА забаненной паре висели бы cleared_at/
-- cleared_reason от предыдущего, уже неактуального гашения.
cleared_at = NULL,
cleared_reason = NULL,
updated_at = now()
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
-- свою же (обычная эскалация) или перебиваем боевым сбором он сильнее
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
@ -1058,12 +1145,25 @@ def clear_source_bans(
банила IP, а строка бана привязана к proxy_id и пережила бы смену адреса,
держа узел вне выдачи уже без причины.
source=None снять все баны узла; конкретный source только его. DELETE, а не
`banned_until = now()`: строка живёт ещё и ради `ban_count` (память об эскалации),
а здесь мы как раз объявляем историю недействительной новый бан начнётся с базовых
SOURCE_BAN_BASE_HOURS.
source=None снять все баны узла; конкретный source только его. Гасим строку
(`banned_until = now()`, `ban_count = 0`, `cleared_at`/`cleared_reason`), а НЕ
удаляем (#3404, было DELETE): строка доживает до штатного purge'а
(`run_proxy_healthcheck`, SOURCE_BAN_PURGE_DAYS), но перестаёт блокировать
выдачу немедленно и перестаёт нести историю эскалации `ban_count = 0` даёт
следующему бану той же пары ровно те же SOURCE_BAN_BASE_HOURS, что и раньше
после DELETE (формула `mark_banned` берёт ПРЕДЫДУЩИЙ ban_count показателем
степени: 0 база, без множителя). Причина держать строку трассируемость
(видно, что бан БЫЛ и когда/кем снят), а не поведение: для читателей ниже
погашенная строка неотличима от отсутствующей (см. риски в шапке PR #3404).
`reason` идёт только в лог (человекочитаемый повод «manual enable», «ip rotated»).
Идемпотентно и в другую сторону: повторный вызов на уже погашенной строке
(последний предикат в WHERE) её не трогает 0 rows, `banned_until` НЕ
сдвигается вперёд. Без этого условия повторный `PATCH enabled=true` двигал бы
`banned_until` на каждый вызов и отодвигал бы purge на неопределённый срок.
`reason` идёт в лог (человекочитаемый повод «manual enable», «ip rotated») и
теперь ЕЩЁ в колонку `cleared_reason` постоянный след того, кто и почему
погасил бан.
`only_reason` ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
@ -1075,14 +1175,22 @@ def clear_source_bans(
rows = db.execute(
text(
"""
DELETE FROM scrape_proxy_source_bans
UPDATE scrape_proxy_source_bans
SET banned_until = now(),
ban_count = 0,
cleared_at = now(),
cleared_reason = CAST(:reason AS text),
updated_at = now()
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
-- Уже погашенная строка (гейт покоя, см. докстринг) не трогаем: без
-- него повторный вызов сдвигал бы banned_until вперёд и отодвигал purge.
AND NOT (cleared_at IS NOT NULL AND ban_count = 0 AND banned_until <= now())
RETURNING source
"""
),
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason, "reason": reason},
).fetchall()
db.commit()
if rows:
@ -1465,6 +1573,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
# Снесём раньше — узел, который площадка банит каждые сутки, каждый раз начинал бы с
# 6 часов и никогда не доходил до длинных пауз. Отложенный purge и есть механизм сброса:
# неделя без нового бана = пара считается чистой, эскалация с нуля. НЕ «оптимизировать».
# #3404: под этот же порог теперь попадают и ПОГАШЕННЫЕ clear_source_bans строки —
# для них banned_until == момент гашения (== cleared_at), т.е. таймер до purge
# отсчитывается от гашения, а не от исходного истечения бана. ban_count у них уже
# 0 к моменту гашения, так что покидающий purge их не «сбрасывает» повторно —
# он просто убирает уже неактуальный след из таблицы.
purged = len(
db.execute(
text(

View file

@ -47,6 +47,15 @@ class Region:
Регион без тира должен деградировать ЯВНО (потребитель
спрашивает unsupported_tier_reason и логирует/маркирует),
а не молча считать дальше без источника.
canonical_city #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
строк, приходящих из источника без надёжного city-поля
(Росреестр по Москве отдаёт муниципальный округ/поселение
вместо города «Раменки», «Сосенское» а не «Москва»).
None источник несёт свой city как есть, без override
(регион 66: byte-for-byte прежнее поведение). Not-None
потребитель (import_rosreestr_dkp) подставляет это имя
вместо city источника и НЕ фильтрует по city IS NOT NULL
(иначе на 77 теряется ~10% строк с пустым city).
"""
code: int
@ -58,6 +67,7 @@ class Region:
city_token: str
cities: frozenset[str]
enrichment_tiers: frozenset[str]
canonical_city: str | None = None
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
@ -144,6 +154,10 @@ REGIONS: dict[int, Region] = {
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
enrichment_tiers=frozenset(),
# #3051: Росреестр по Москве отдаёт в city муниципальный округ/поселение
# ("муниципальный округ Раменки", "поселение Сосенское"), не сам город —
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
canonical_city="Москва",
),
}

View file

@ -25,13 +25,16 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
from __future__ import annotations
import json
import logging
from typing import Any
# kit_runs.update_heartbeat (в отличие от локального runs_mod.update_heartbeat) мержит
# counters (`counters || :counters`) вместо замены — нужен для чекпоинта курсора
# import_rosreestr_dkp (issue #3168), чтобы resume-вердикт, записанный на старте, не
# затирался последующими per-batch heartbeat'ами того же прогона.
# kit_runs — ТОТ ЖЕ модуль, что и runs_mod ниже: с #3390 `app.services.scrape_runs`
# его алиас, реализация одна и counters везде МЕРЖАТСЯ (`counters || :counters`). До
# #3390 копии было две, и app-копия counters ЗАМЕНЯЛА — тогда чекпоинт курсора
# import_rosreestr_dkp (#3168) обязан был писаться именно kit-именем, иначе resume-вердикт
# со старта затирался первым же per-batch пульсом. Имя оставлено как есть: теперь это
# один объект, и переименование в runs_mod ничего не чинит и ничего не ломает.
from scraper_kit.orchestration import runs as kit_runs
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
@ -42,11 +45,13 @@ from scraper_kit.orchestration import runs as kit_runs
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
from scraper_kit.orchestration.scheduler import compute_next_run_at
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.shutdown import shutdown_requested
from app.services import scrape_runs as runs_mod
from app.services.regions import REGIONS
__all__ = ["compute_next_run_at", "has_running_run"]
@ -126,8 +131,16 @@ async def _execute_cian_backfill(
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
ронять уже идущую работу прогон в худшем случае вернётся к прежнему
поведению (пометка 'zombie' на 6-м часу)."""
nonlocal counters
# #3384: снимок измеренного едет не только в БД, но и в `counters` — этот словарь
# уезжает в mark_failed из общего except ниже. Мерж (#3390) спасает лишь ключи,
# которых в payload нет; одноимённые он ПЕРЕЗАПИСЫВАЕТ, поэтому предынициализированные
# нули без этого присваивания легли бы поверх измеренного, и SQL-разбор простоя
# (#3288/#3367) прочитал бы «к площадке не ходили» про прогон, который ходил.
# Присваивание ДО записи в БД: сбой heartbeat'а не должен стирать сам факт замера.
counters = _counters(progress)
try:
runs_mod.update_heartbeat(db, run_id, _counters(progress))
runs_mod.update_heartbeat(db, run_id, counters)
except Exception:
logger.warning(
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
@ -135,6 +148,8 @@ async def _execute_cian_backfill(
exc_info=True,
)
# Стартовые нули: прогон виден в админке до первого прогресса. Держатся здесь ровно
# до первого `_heartbeat` — дальше в `counters` лежит измеренное (см. выше).
counters: dict[str, int] = {
"listings_processed": 0,
"listings_succeeded": 0,
@ -161,6 +176,28 @@ async def _execute_cian_backfill(
# #3196: отказ detail-фетча теперь несёт диагноз (HTTP-статус последнего ответа
# сайдкара). В 'banned' переводим ТОЛЬКО прогон, который отказы видел и не
# обогатил НИЧЕГО, — частичный успех остаётся 'done', как и был.
if result.no_proxy_stop:
# #3197 (как #3288 у avito / #3283 у домклика): остановка из-за пустого пула —
# НЕ блок, поэтому и не mark_banned: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
counters["no_proxy_stop"] = 1
runs_mod.mark_failed(
db, run_id, "пул прокси пуст — к площадке не ходили (#3197)", counters
)
# INFO, как у соседей (avito_detail_backfill.py:1050, domclick:626): причина
# уже записана в mark_failed + counters.no_proxy_stop, а ERROR на финальной
# строке ставил в один разряд с падением задачи (logger.exception ниже).
logger.info(
"scheduler: cian_history_backfill run_id=%d СТОП (пул пуст) — "
"listings=%d/%d houses=%d/%d %.1fs",
run_id,
result.listings_succeeded,
result.listings_total,
result.houses_succeeded,
result.houses_total,
result.duration_sec,
)
return
if result.ban_kinds and (result.listings_succeeded + result.houses_succeeded) == 0:
counters["blocked"] = result.listings_blocked
# Полная перепись диагнозов, а не только доминирующий вид (#3196) — иначе
@ -186,12 +223,41 @@ async def _execute_cian_backfill(
)
except Exception as exc:
logger.exception("scheduler: cian_history_backfill run_id=%d failed", run_id)
if caused_by_no_proxy(exc):
# #3384: пул был пуст ещё ДО первого объявления — lease берётся в
# BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из
# самого `async with` (cian_history_backfill.py:218) мимо стоп-механики
# внутри цикла, которая и ставит no_proxy_stop. Без этого ключа прогон,
# который к площадке не ходил ВООБЩЕ, неотличим от любого другого падения:
# причина только в тексте, а разбор простоя идёт SQL'ём по
# counters.no_proxy_stop (#3288/#3367). Ключ тот же, что у ветки выше.
# Флаг ДОБАВЛЯЕТСЯ к последнему снимку `_heartbeat`, а не подменяет его:
# опустевший между стадиями пул — это отказ ПОСЛЕ реальной работы.
counters["no_proxy_stop"] = 1
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise
_DKP_SOURCE = "rosreestr_dkp_import"
def _dkp_source_for_region(region_code: int) -> str:
"""Имя scrape_runs.source для чекпоинта данного региона (#3051 п.3).
66 байт-в-байт прежнее имя ('rosreestr_dkp_import'), под которым годами
писались scrape_runs. Остальные регионы получают суффикс кода тот же
формат, что и у строки scrape_schedules ('rosreestr_dkp_import_77',
seed миграция 289), которую резолвит wildcard 'rosreestr_dkp_import_*'
в product_handlers.py. Изоляция чекпоинтов между регионами держится именно
на разных source: _resume_dkp_cursor ищет ПРЕДЫДУЩИЙ прогон с ТЕМ ЖЕ source,
поэтому курсор региона 77 никогда не подхватит last_id региона 66 (и
наоборот) они просто разные строки в scrape_runs.source.
"""
if region_code == 66:
return _DKP_SOURCE
return f"{_DKP_SOURCE}_{region_code}"
# Потолок возраста чекпоинта: старше — last_id прошлого прогона не подхватываем, прогон
# стартует с id=0 (issue #3168). У предиката `id > last_id` нет протухания в смысле
# свипов (он остаётся корректным сколь угодно долго), но апстрим
@ -217,7 +283,9 @@ _DKP_RESUME_CANDIDATE_SQL = text("""
""")
def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
def _resume_dkp_cursor(
db: Session, run_id: int, source: str = _DKP_SOURCE
) -> tuple[int, dict[str, Any]]:
"""Продолжить last_id прошлого прогона или начать с 0 — решение + explain (issue #3168).
last_id раньше жил только в памяти процесса (init на 0 при каждом запуске): heartbeat
@ -225,7 +293,14 @@ def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
обратно. Обрыв (деплой/OOM/рестарт хоста) откатывал прогресс на 0 и заставлял
пере-сканировать источник с начала.
Кандидат ПОСЛЕДНИЙ прогон этого source (тот же принцип, что и
`source` (#3051 п.3) — per-region ключ чекпоинта (см. _dkp_source_for_region):
дефолт _DKP_SOURCE сохраняет прежнее поведение вызовов без явного аргумента
(регион 66). Кандидат ищется СТРОГО по этому source прогон региона 77
(source='rosreestr_dkp_import_77') никогда не видит last_id региона 66
(source='rosreestr_dkp_import') и наоборот: разные регионы физически не
матчат друг друга в WHERE source = :source ниже.
Кандидат ПОСЛЕДНИЙ прогон ЭТОГО source (тот же принцип, что и
scraper_kit.orchestration.scheduler._pick_resume, локальная копия ладдера контракт
другой: нет params/interval_days, курсор числовой, а не bucket-set):
- 'running' / 'zombie' прогон, которого не завершили штатно.
@ -239,7 +314,7 @@ def _resume_dkp_cursor(db: Session, run_id: int) -> tuple[int, dict[str, Any]]:
кодом (kit_runs.update_heartbeat merge, не замена), чтобы решение было видно в
scrape_runs, а не только в логе.
"""
row = db.execute(_DKP_RESUME_CANDIDATE_SQL, {"source": _DKP_SOURCE, "rid": run_id}).fetchone()
row = db.execute(_DKP_RESUME_CANDIDATE_SQL, {"source": source, "rid": run_id}).fetchone()
verdict: dict[str, Any] = {"resume_from": None}
if row is None:
@ -276,48 +351,92 @@ def import_rosreestr_dkp(
) -> None:
"""Import ДКП-сделок из gendesign rosreestr_deals через postgres_fdw.
Python-порт import-rosreestr.sh (Variant C из #563).
Python-порт import-rosreestr.sh (Variant C из #563). #3051 п.3: параметризовано
по региону (params["region_code"], реестр app.services.regions.REGIONS)
было хардкод region_code=66.
Источник: foreign table gendesign_rosreestr_deals (создана в migration 072).
Источник: foreign table gendesign_rosreestr_deals (создана в migration 072,
okato/quarter_cad_number/district добавлены миграцией 289).
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). address и deals.city строятся из реального city
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
источника (= 66 при текущем фильтре).
Область покрытия задаётся параметром, а не литералом (#3051 п.6): region_code
приходит из params, дефолт 66 = вся Свердловская область (не только Екатеринбург
прежний ILIKE-фильтр по подстроке города снят, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). Неизвестный код региона (нет в REGIONS) ValueError,
прогон падает явно, а не молча импортирует мусор с чужим region_code.
region_code=66 (регион БЕЗ canonical_city в реестре) поведение байт-в-байт
прежнее: city/address строятся из city источника, обязателен фильтр
city IS NOT NULL AND trim(city) != ''.
Регион С canonical_city (77 Москва): Росреестр отдаёт в city муниципальный
округ/поселение ("муниципальный округ Раменки", "поселение Сосенское"), НЕ
город city/address подставляют region.canonical_city, а не city источника;
фильтр city IS NOT NULL НЕ применяется (иначе теряется ~10% строк с пустым
city источника). Исходные city/okato/quarter_cad_number/district уходят в
raw_payload (jsonb) единственная ветка SQL решает это через bind-параметр
:canonical_city (CASE WHEN ... IS NOT NULL), а не отдельный Python if/else на
конкретный код региона.
Типы документов тоже параметр (#3051 п.3): doc_types, дефолт ['ДКП'] = прежнее
поведение (только вторичка #549 / Fix_Rosreestr_Dkp_Filter_May24). Для Москвы
ДДУ идут по ценам котлована и медиану развалят, поэтому смешивать их с ДКП можно
только осознанно и с колонкой deals.doc_type (миграция 288), которая теперь
заполняется на импорте.
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
- region_code = 66 (вся Свердловская область, все города)
- city IS NOT NULL AND trim(city) != '' (непустой город корректный address)
- region_code = :region_code (параметризовано, было хардкод 66)
- city IS NOT NULL AND trim(city) != '' ТОЛЬКО если у региона нет canonical_city
- realestate_type_code = '002001003000' (квартира)
- area BETWEEN 18 AND 200
- deal_price BETWEEN 1000000 AND 100000000
- street IS NOT NULL AND trim(street) != ''
- doc_type = 'ДКП' (только вторичка #549 / Fix_Rosreestr_Dkp_Filter_May24)
- doc_type = ANY(:doc_types) (param, default ['ДКП'])
- period_start_date >= since (default '2024-01-01')
dedup_hash: 'ros:dkp:' || id плоский натуральный ключ (инъективный, без коллизий,
human-readable). До #576 здесь был md5('ros:dkp:' || id); миграция 077 конвертировала
существующие строки. source_id хранит исходный rosreestr id (дедуп переустанавливаем).
Префикс ':dkp:' НАМЕРЕННО оставлен неизменным после параметризации doc_types: id
уникален в источнике сам по себе, независимо от типа документа, поэтому ключ и без
того не коллизирует; а вот смена формы ключа осиротила бы все уже загруженные строки
(их пришлось бы конвертировать ещё одной миграцией ровно то, что делала 077).
Rooms: выводятся из площади (Росреестр не отдаёт кол-во комнат).
Batch-процессинг: читаем из FDW батчами по batch_size через cursor-based пагинацию
(WHERE id > last_id ORDER BY id). Heartbeat обновляется каждый батч (= checkpoint),
мержем (kit_runs.update_heartbeat), а не заменой. На старте _resume_dkp_cursor решает
продолжить с last_id прошлого прогона или начать с 0 чекпоинт переживает рестарт
процесса (деплой/OOM/SIGTERM), пока не старше суток (issue #3168).
процесса (деплой/OOM/SIGTERM), пока не старше суток (issue #3168). Курсор — ПЕР
РЕГИОН (#3051 п.3): _resume_dkp_cursor вызывается с source=_dkp_source_for_region
(region_code), поэтому last_id региона 77 никогда не подхватывает last_id региона
66 они разные scrape_runs.source ('rosreestr_dkp_import' vs
'rosreestr_dkp_import_77'), см. докстринг _dkp_source_for_region.
SAVEPOINT per row один сбойный row не откатывает батч.
Координаты: NULL после импорта геокодинг остаётся follow-up (geocode-deals).
TODO (follow-up): запустить geocode backfill после import.
Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549).
Cleanup: удаляет legacy строки address='Екатеринбург, реальная сделка' (pre-#549,
region-agnostic синтетические строки существовали только для ЕКБ).
"""
since: str = str(params.get("since", "2024-01-01"))
batch_size: int = int(params.get("batch_size", 2000))
# #3051 п.6: регион — параметр, дефолт 66 сохраняет текущее прод-поведение
# (расписание получает явный region_code в миграции 288).
region_code: int = int(params.get("region_code", 66))
# #3051 п.3: типы документов — параметр, дефолт ['ДКП'] = прежний литерал.
doc_types: list[str] = [str(t) for t in params.get("doc_types") or ["ДКП"]]
region = REGIONS.get(region_code)
if region is None:
raise ValueError(
f"rosreestr_dkp_import: region_code={region_code} не найден в "
f"app.services.regions.REGIONS (известны: {sorted(REGIONS)}) — "
"прогон остановлен, чтобы не импортировать сделки с неизвестным "
"региональным контекстом (city/address-правила для него не определены)"
)
dkp_source = _dkp_source_for_region(region_code)
counters: dict[str, int] = {
"rows_fetched": 0,
@ -354,7 +473,7 @@ def import_rosreestr_dkp(
)
db.rollback()
last_id, resume_verdict = _resume_dkp_cursor(db, run_id)
last_id, resume_verdict = _resume_dkp_cursor(db, run_id, source=dkp_source)
total_batches = 0
kit_runs.update_heartbeat(db, run_id, resume_verdict)
logger.info(
@ -404,9 +523,20 @@ def import_rosreestr_dkp(
id,
id AS source_id_src,
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
trim(city) || ', ' || trim(street) AS address,
-- #3051: регион с canonical_city (Москва) подставляет его вместо
-- city источника (округ/поселение, не город) CASE на bind-параметре,
-- не Python if/else на код региона.
CASE
WHEN CAST(:canonical_city AS text) IS NOT NULL
THEN CAST(:canonical_city AS text) || ', ' || trim(street)
ELSE trim(city) || ', ' || trim(street)
END AS address,
region_code,
trim(city) AS city,
CASE
WHEN CAST(:canonical_city AS text) IS NOT NULL
THEN CAST(:canonical_city AS text)
ELSE trim(city)
END AS city,
CASE
WHEN area < 30 THEN 0
WHEN area < 44 THEN 1
@ -426,21 +556,45 @@ def import_rosreestr_dkp(
year_build AS year_built,
round(deal_price)::bigint AS price_rub,
round(price_per_sqm)::int AS price_per_m2,
period_start_date AS deal_date
period_start_date AS deal_date,
doc_type,
-- Исходный city/okato/quarter_cad_number/district ТОЛЬКО когда
-- city перезаписан canonical_city выше (иначе NULL, регион 66
-- byte-for-byte прежний: raw_payload не заполнялся и не заполняется).
CASE
WHEN CAST(:canonical_city AS text) IS NOT NULL THEN
jsonb_build_object(
'src_city', city,
'okato', okato,
'quarter_cad_number', quarter_cad_number,
'district', district
)
ELSE NULL
END AS raw_payload
FROM gendesign_rosreestr_deals
WHERE region_code = 66
AND city IS NOT NULL AND trim(city) <> ''
WHERE region_code = CAST(:region_code AS int)
AND (
CAST(:canonical_city AS text) IS NOT NULL
OR (city IS NOT NULL AND trim(city) <> '')
)
AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000
AND street IS NOT NULL AND trim(street) <> ''
AND doc_type = 'ДКП'
AND doc_type = ANY(CAST(:doc_types AS text[]))
AND period_start_date >= CAST(:since AS date)
AND id > CAST(:last_id AS bigint)
ORDER BY id
LIMIT CAST(:batch_size AS int)
"""),
{"since": since, "last_id": last_id, "batch_size": batch_size},
{
"since": since,
"last_id": last_id,
"batch_size": batch_size,
"region_code": region_code,
"canonical_city": region.canonical_city,
"doc_types": doc_types,
},
)
.mappings()
.all()
@ -478,7 +632,7 @@ def import_rosreestr_dkp(
INSERT INTO deals (
source, dedup_hash, source_id, address, region_code, city,
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
deal_date
deal_date, doc_type, raw_payload
)
VALUES (
'rosreestr',
@ -493,7 +647,9 @@ def import_rosreestr_dkp(
CAST(:year_built AS int),
CAST(:price_rub AS bigint),
CAST(:price_per_m2 AS int),
CAST(:deal_date AS date)
CAST(:deal_date AS date),
CAST(:doc_type AS text),
CAST(:raw_payload AS jsonb)
)
ON CONFLICT (dedup_hash) DO UPDATE SET
address = EXCLUDED.address,
@ -505,7 +661,9 @@ def import_rosreestr_dkp(
year_built = EXCLUDED.year_built,
price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2,
deal_date = EXCLUDED.deal_date
deal_date = EXCLUDED.deal_date,
doc_type = EXCLUDED.doc_type,
raw_payload = EXCLUDED.raw_payload
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
OR deals.city IS DISTINCT FROM EXCLUDED.city
@ -516,6 +674,8 @@ def import_rosreestr_dkp(
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
OR deals.doc_type IS DISTINCT FROM EXCLUDED.doc_type
OR deals.raw_payload IS DISTINCT FROM EXCLUDED.raw_payload
RETURNING (xmax = 0) AS was_inserted
"""),
{
@ -531,6 +691,12 @@ def import_rosreestr_dkp(
"price_rub": row["price_rub"],
"price_per_m2": row["price_per_m2"],
"deal_date": row["deal_date"],
"doc_type": row["doc_type"],
"raw_payload": (
json.dumps(row["raw_payload"], ensure_ascii=False)
if row["raw_payload"] is not None
else None
),
},
).fetchone()
if result is None:
@ -564,8 +730,9 @@ def import_rosreestr_dkp(
counters["last_id"] = last_id # type: ignore[assignment]
# Heartbeat = checkpoint: allows zombie detection + resume visibility.
# kit_runs (merge, не замена) — иначе этот REPLACE стёр бы resume_verdict,
# записанный _resume_dkp_cursor'ом перед циклом (issue #3168).
# Пульс МЕРЖИТ counters (`counters || :counters`), поэтому resume_verdict,
# записанный _resume_dkp_cursor'ом перед циклом, переживает per-batch запись
# (issue #3168; с #3390 мерж — единственная семантика, см. scrape_runs).
kit_runs.update_heartbeat(db, run_id, counters)
logger.info(
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "

File diff suppressed because it is too large Load diff

View file

@ -221,10 +221,17 @@ class RealProxyProvider:
def acquire(self, provider: str) -> ProxyLease | None:
from scraper_kit.contracts import ProxyLease as _KitProxyLease
from scraper_kit.orchestration.run_context import current_run_id
# #3404: протокол ProxyProvider.acquire(provider) не несёт run_id (этот
# адаптер — один объект на весь scheduler_main.py), поэтому берём его из
# ContextVar, который выставляет runs.create_run. None — вызов вне прогона
# (health-check, эстиматор) — proxy_pool.acquire в этом случае лизит под
# NON_RUN_LEASE_MARKER, как и раньше, атрибуцию в scrape_runs не пишет.
run_id = current_run_id.get()
db = _SessionLocal()
try:
lease = _proxy_pool.acquire(db, provider)
lease = _proxy_pool.acquire(db, provider, run_id=run_id)
finally:
db.close()
if lease is None:

View file

@ -140,6 +140,16 @@ def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int:
{"listing_id": listing_id},
).fetchone()
# ПОТОЛОК: гейта сдвига разряда (#3376) здесь НЕТ, и он бы тут не
# сработал. drop_decimal_slips требует двух свидетелей — скачка ×10
# к предыдущей точке и подтверждения у следующей. На этом пути серия
# максимум из двух точек (последняя лежащая в БД + текущая), у
# последней точки свидетель — текущая цена лота, а она и ЕСТЬ эта
# точка: свидетель совпадает с подозреваемым, отношение всегда 1.0.
# То есть проводка была бы декорацией: ветка, которая по построению
# не может выбросить ни одной точки. Отлов ×10 у yandex требует
# другого механизма — сравнения со СЛЕДУЮЩИМ наблюдением, а значит
# DELETE уже вставленной строки. Отдельная задача: #3385.
if latest is None:
# История пуста — seed (+ опционально previous-точка).
prev = lot.price_previous_rub

View file

@ -80,6 +80,7 @@ from scraper_kit.providers.avito.detail import (
save_detail_enrichment,
)
from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.proxy_errors import NoProxyAvailableError
from scraper_kit.snapshot_writer import upsert_listing_snapshot
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -173,6 +174,34 @@ def _top_failure(census: Counter[str]) -> str | None:
return f"{reason} ({hits} из {sum(census.values())})"
def _iter_causes(exc: BaseException) -> list[BaseException]:
"""Цепочка причин исключения, без зацикливания (копия приёма из
domclick_detail_backfill там же он и обкатан на #3283)."""
seen: set[int] = set()
out: list[BaseException] = []
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
out.append(cur)
seen.add(id(cur))
cur = cur.__cause__ or cur.__context__
return out
def _caused_by_empty_pool(exc: BaseException) -> bool:
"""Прячется ли за этим «блоком» пустой пул прокси (#3288, как #3283 у домклика).
`NoProxyAvailableError` документирован ровно как «НАША инфраструктура, не
внешний блок», и поднимается ДО HTTP-запроса: к площадке мы не ходили вовсе.
Сюда он попадает под видом блокировки, потому что fetch_detail заворачивает
в `AvitoSidecarUnavailableError` любое исключение фетча.
Опора ТИП в цепочке `__cause__`/`__context__`, а не подстрока «no proxy
available» в тексте: текст обёртки её действительно содержит, но ровно так
#3272 уже один раз объявил блоком пользовательское описание квартиры.
"""
return any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(exc))
# Провайдер mobileproxy.space почти всегда возвращает "rt" (секунды на переподключение
# канала после ротации) в RotationResult.reconnect_delay_s -- см. app.services.
# proxy_rotation docstring. Дефолт нужен ТОЛЬКО если провайдер его не прислал
@ -529,6 +558,8 @@ async def run_avito_detail_backfill(
consecutive_failures = 0
aborted_by_blocks = False
abort_reason: str | None = None
# #3288: прогон оборван пустым пулом прокси — «нечем ходить», а не бан.
no_proxy_stop = False
do_sleep = False
items_since_warm = 0
# Счётчик попыток (успех ИЛИ отказ — оба тратят бюджет IP одинаково, см.
@ -684,6 +715,28 @@ async def run_avito_detail_backfill(
)
if save_detail_enrichment(db, enrichment):
counters.enriched += 1
else:
# #3338 (та же дыра, что #3332 у domclick): карточка взята и
# разобрана, а UPDATE не задел ни одной строки — объявление
# удалено/деактивировано между снимком и записью. Попытка была,
# исхода не было: attempted переставал сходиться с
# enriched + blocked + gone + failed, и расхождение читается как
# потерянный отказ площадки. Исход failed: непрошедший UPDATE — не
# успех, не блок и не gone (снятие метит is_active=FALSE сам, по 404).
counters.failed += 1
# Печатаем ОБА идентификатора: WHERE в save ключуется по
# source_id из разобранного HTML (item_id), а не по row-id из
# снимка. При редиректе/подмене карточки строка listing_id
# существует и жива — не нашлась строка с source_id=item_id.
logger.warning(
"avito_detail_backfill: run_id=%d listing %s -- карточка "
"разобрана, но UPDATE не нашёл строку: listing_id=%s item_id=%s "
"(WHERE по item_id из HTML)",
run_id,
source_url,
row["id"],
enrichment.item_id,
)
if use_curl:
items_since_warm += 1
breaker.record_success()
@ -743,10 +796,33 @@ async def run_avito_detail_backfill(
)
except (AvitoBlockedError, AvitoRateLimitedError) as e:
breaker.record_block()
# #3288: пустой пул — не блок и не отказ площадки: запрос не
# уходил вовсе, следующая карточка упрётся ровно в то же самое.
# Сюда он приезжает под видом блокировки, потому что fetch_detail
# заворачивает в AvitoSidecarUnavailableError любое исключение
# фетча. Исход честно failed (тот же разряд, что транспортные
# сбои ниже) — тождество attempted = enriched + blocked + gone +
# failed остаётся целым (#3338), а причину несёт no_proxy_stop=1
# и mark_failed с текстом про пул, как у домклика после #3283.
if _caused_by_empty_pool(e):
counters.failed += 1
logger.error(
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
run_id,
counters.enriched,
counters.attempted,
)
no_proxy_stop = True
break
ban_kind = ban_kind_of_exception(e)
# #3288: в окно доли идёт только 'platform' — infra (отказ нашего
# сайдкара) уходит в знаменатель, см. BlockRatioBreaker.record_block.
breaker.record_block(ban_kind)
counters.blocked += 1
failure_census[_failure_signature(e)] += 1
block_ban_kinds[ban_kind_of_exception(e)] += 1
block_ban_kinds[ban_kind] += 1
do_sleep = False
# #3251/#3283g: на настоящий бан ПЛОЩАДКОЙ (AvitoBlockedError и подтипы:
# AvitoContentBlockedError, AvitoWarmupCookiesMissingError; НЕ на
@ -960,6 +1036,29 @@ async def run_avito_detail_backfill(
# и без этого ключа "banned" опять не отличить по причине (#3178).
if abort_reason is not None:
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
if no_proxy_stop:
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(
db,
run_id,
"пул прокси пуст — к площадке не ходили (#3288)",
current_counters,
)
logger.info(
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
run_id,
counters.attempted,
counters.enriched,
counters.blocked,
counters.gone,
counters.failed,
counters.duration_sec,
)
return counters
runs_mod.mark_backfill_finished(
db,
run_id,
@ -989,7 +1088,16 @@ async def run_avito_detail_backfill(
run_id,
counters.duration_sec,
)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
current_counters = counters.to_dict()
if _caused_by_empty_pool(exc):
# #3384: пул был пуст ещё ДО первой карточки — lease берётся в
# BrowserFetcher.__aenter__ (строка 427), поэтому NoProxyAvailableError
# вылетает мимо стоп-механики цикла, которая и ставит no_proxy_stop. Без
# ключа такой прогон (attempted=0, к площадке не ходили) неотличим от
# любого другого падения: разбор простоя идёт SQL'ём по
# counters.no_proxy_stop (#3288/#3367), а не грепом текста ошибки.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters)
raise
finally:

View file

@ -0,0 +1,95 @@
"""CLI: макро-ряды ЦБ РФ — ипотека по субъектам + ключевая ставка (#issue см. vault).
Запуск из контейнера:
python -m app.tasks.cbr_macro_pull # всё: ипотека + ставка
python -m app.tasks.cbr_macro_pull --dry-run
python -m app.tasks.cbr_macro_pull --series rate_rub debt_rub
python -m app.tasks.cbr_macro_pull --skip-mortgage --from-date 2024-01-01 --to-date 2024-12-31
python -m app.tasks.cbr_macro_pull --skip-key-rate
Делегирует всю логику app.services.cbr_macro (download parse upsert); этот
модуль только argparse + lifecycle сессии + commit между стадиями.
"""
from __future__ import annotations
import argparse
import logging
from datetime import date
from app.core.db import SessionLocal
from app.services.cbr_macro import (
CBR_MORTGAGE_SERIES,
load_key_rate,
pull_cbr_mortgage,
)
logger = logging.getLogger(__name__)
_SERIES_SLUGS = [s.slug for s in CBR_MORTGAGE_SERIES]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Загрузка макро-рядов ЦБ РФ")
parser.add_argument("--dry-run", action="store_true", help="только посчитать, без upsert")
parser.add_argument(
"--series",
nargs="*",
choices=_SERIES_SLUGS,
default=None,
help="подмножество серий ипотеки (по умолчанию — все)",
)
parser.add_argument("--skip-mortgage", action="store_true", help="не тянуть ипотечные серии")
parser.add_argument("--skip-key-rate", action="store_true", help="не тянуть ключевую ставку")
parser.add_argument(
"--from-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата от"
)
parser.add_argument(
"--to-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата до"
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
parser = build_parser()
args = parser.parse_args()
if args.skip_mortgage and args.skip_key_rate:
parser.error("--skip-mortgage и --skip-key-rate вместе не имеют смысла")
mortgage_counts: dict[str, int] = {}
key_rate_counts: dict[str, int] = {}
db = SessionLocal()
try:
if not args.skip_mortgage:
series_list = None
if args.series:
series_list = [s for s in CBR_MORTGAGE_SERIES if s.slug in args.series]
mortgage_counts = pull_cbr_mortgage(
db, series_list=series_list, dry_run=args.dry_run
)
if not args.dry_run:
db.commit()
if not args.skip_key_rate:
key_rate_counts = load_key_rate(
db, from_date=args.from_date, to_date=args.to_date, dry_run=args.dry_run
)
if not args.dry_run:
db.commit()
logger.info(
"cbr_macro_pull DONE: dry_run=%s mortgage=%s key_rate=%s",
args.dry_run,
mortgage_counts,
key_rate_counts,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -38,8 +38,11 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
from scraper_kit.orchestration.runs import BAN_KIND_UNKNOWN
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -77,6 +80,10 @@ class CianBackfillResult:
# у него не проставлялся вовсе.
listings_blocked: int = 0
ban_kinds: Counter[str] = field(default_factory=Counter)
# #3197: прогон оборван, потому что пул прокси пуст — к площадке не ходили вовсе.
# Не блок и не отказ Циана: caller (scheduler) обязан пометить прогон failed, а не
# banned, иначе запись утверждает про площадку то, чего не было.
no_proxy_stop: bool = False
@property
def ban_kind(self) -> str:
@ -91,18 +98,32 @@ class CianBackfillResult:
return _dominant_ban_kind(self.ban_kinds)
def _note_refusal(result: CianBackfillResult, status: int | None) -> str | None:
"""Записать отказ detail-фетча, если его природа диагностируема по HTTP-статусу (#3196).
def _note_refusal(
result: CianBackfillResult, status: int | None, exc: BaseException | None = None
) -> str | None:
"""Записать отказ detail-фетча, если его природа установлена — типом или статусом.
Инвариант: непустой `ban_kinds` мы видели ответ с 403/429/5xx. `ban_kind_from_status`
отвечает диагнозом только на такие статусы; на всё прочее (в т.ч. статуса нет сайдкар
не дошёл до навигации, или это HTTP 200 с промахом нашего парсера дрейф разметки, а
не отказ площадки) возвращает None, и тогда эта функция НЕ инкрементит ни
`listings_blocked`, ни `ban_kinds` только возвращает None вызывающему. Раньше
недиагностируемые случаи писались как 'unknown' и это в scrape_runs.mark_banned
(scheduler.py) превращало наши собственные сбои в фиктивный бан площадки.
Инвариант (#3196) прежний: непустой `ban_kinds` ⟺ отказ был ПОКАЗАН, а не назначен.
Установить его можно двумя способами, и статуса одного мало:
* ТИП исключения (#3402 follow-up) — `CianBlockedError` (капча Циана, отказ
сайдкара `SidecarBanPageError`, WAF-403) наследует `ProxyBanError`, то есть по
построению означает «площадка себя показала»: `ban_kind_of_exception` даёт
'platform'. Капча приезжает с HTTP 200 (свой детект по <title>) или вообще без
статуса (сайдкар не дошёл до навигации), поэтому по статусу она диагностировалась
как «не разобрали»: рос только `listings_failed_fetch`, `ban_kinds` оставался
пустым и следующая капча-волна снова выглядела бы дрейфом нашей разметки;
* HTTP-статус ответа (403/429/5xx) прежний путь для всего остального.
Всё, что не установлено ни тем, ни другим ('unknown' по типу И None по статусу), НЕ
инкрементит ни `listings_blocked`, ни `ban_kinds`: недиагностируемые случаи, записанные
как 'unknown', в scrape_runs.mark_banned (scheduler.py) превращали наши собственные
сбои в фиктивный бан площадки (#2764).
"""
kind = ban_kind_from_status(status)
kind = ban_kind_of_exception(exc) if exc is not None else BAN_KIND_UNKNOWN
if kind == BAN_KIND_UNKNOWN:
# Тип ничего не доказал — спрашиваем статус (прежнее поведение).
kind = ban_kind_from_status(status)
if kind is None:
return None
result.listings_blocked += 1
@ -203,7 +224,20 @@ async def backfill_cian_history(
else:
# One BrowserFetcher instance shared across all listings in this batch.
# priceChanges requires JS rendering — curl_cffi returns empty list (#1574).
async with BrowserFetcher(source="cian", endpoint=settings.browser_http_endpoint) as bf:
# proxy_provider/use_pool/environment (#3197, шаг A2): без этих трёх сайдкар
# берёт свой env-прокси (SCRAPER_PROXY_URL) — прогон шёл мимо пула из 4 узлов
# целиком (ни выбора узла, ни scrape_proxy_source_bans, ни ротации), а
# прод-отказ «пул пуст → не ходить на env/direct» (#2616) на этом пути был
# мёртв: он смотрит на environment, который сюда не доезжал. Образец —
# domclick_detail_backfill.py:403 и house_imv_backfill.py:749 (#2698/#3197).
_cfg = RealScraperConfig()
async with BrowserFetcher(
source="cian",
endpoint=settings.browser_http_endpoint,
proxy_provider=RealProxyProvider(),
use_pool=_cfg.use_proxy_pool_browser,
environment=_cfg.environment,
) as bf:
for row in rows:
listing_id: int = row["id"]
source_url: str = row["source_url"]
@ -215,7 +249,26 @@ async def backfill_cian_history(
try:
enrichment = await fetch_detail(source_url, browser_fetcher=bf)
except Exception as exc:
kind = _note_refusal(result, bf.last_response_status)
# #3197: пустой пул — не отказ площадки: запрос не уходил вовсе,
# следующее объявление упрётся ровно в то же самое (иначе батч
# крутит впустую весь список). Опора — тип в цепочке причин, а не
# текст: fetch_detail заворачивает сбой фетча в своё исключение.
if caused_by_no_proxy(exc):
result.no_proxy_stop = True
result.listings_failed_fetch += 1
logger.error(
"cian_history_backfill: СТОП — пул прокси пуст, к площадке "
"не ходили. listing_id=%s processed=%d succeeded=%d",
listing_id,
result.listings_processed,
result.listings_succeeded,
)
break
# exc, а не только статус: капча Циана — подтверждённый отказ
# площадки (CianBlockedError), но приходит с HTTP 200/без статуса
# (#3402 follow-up). Без типа волна капчи писалась в счётчик
# «не разобрали» и прогон отдавал пустой ban_kinds.
kind = _note_refusal(result, bf.last_response_status, exc)
logger.warning(
"cian_detail fetch failed for listing_id=%s url=%s: %s "
"(http=%s ban_kind=%s)",
@ -272,7 +325,9 @@ async def backfill_cian_history(
await asyncio.sleep(delay)
# ── 2. Houses: missing houses_price_dynamics ──────────────────────────────
if do_houses:
# no_proxy_stop (#3197): пул пуст — дома идут через тот же пул (fetch_newbuilding с
# RealProxyProvider ниже), крутить их незачем.
if do_houses and not result.no_proxy_stop:
# Kit's fetch_newbuilding() now accepts config= (issue #2322 fixed) — pass
# RealScraperConfig() at the call site below so BrowserFetcher gets a real
# endpoint instead of degrading to endpoint=None (#2397 Part D2).
@ -363,7 +418,7 @@ async def backfill_cian_history(
await asyncio.sleep(delay)
# ── 3. Cian listings без external_valuations (price prediction backfill) ──
if do_valuations:
if do_valuations and not result.no_proxy_stop: # #3197: пул пуст — см. блок домов
rows = (
db.execute(
text("""

View file

@ -15,17 +15,29 @@ asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды счита
свежему срезу deals, что и ratio-таблица того же дня.
SQL derivation ниже БАЙТ-В-БАЙТ та же логика, что seed в
data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered:
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 для полного обоснования тиров и hard floor/ceiling клампов).
комментарий в 194/298 для полного обоснования тиров и hard floor/ceiling клампов).
#3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и
city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице
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 видят
ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals.
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
паттерна) множество городов монотонно растёт (rosreestr_dkp_import только
INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city
(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто
перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <>
'Екатеринбург') estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2
для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён).
паттерна) множество (region_code, city) монотонно растёт
(rosreestr_dkp_import только INSERT/ON CONFLICT DO UPDATE, никогда не удаляет
сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город,
перешедший в другой tier, просто перезаписывается на следующем refresh.
Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND
city = 'Екатеринбург')) estimator.py fallback на глобальные
DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из
178/194/298 сохранён).
"""
from __future__ import annotations
@ -39,22 +51,28 @@ from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ─────────────────────────────────
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 298, region-aware) ──────────────────
_REDERIVE_SQL = text(
"""
WITH region_stats AS (
SELECT GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
SELECT
region_code,
GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
FROM deals
WHERE source = 'rosreestr'
AND doc_type = 'ДКП'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
AND region_code IS NOT NULL
AND NOT (region_code = 66 AND city = 'Екатеринбург')
GROUP BY region_code
),
city_stats AS (
SELECT
region_code,
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_p1,
@ -63,13 +81,15 @@ _REDERIVE_SQL = text(
count(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND doc_type = 'ДКП'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
AND region_code IS NOT NULL
AND NOT (region_code = 66 AND city = 'Екатеринбург')
GROUP BY region_code, city
),
tiered AS (
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
'full'::text AS tier
FROM city_stats
WHERE n_deals >= 30
@ -77,23 +97,24 @@ _REDERIVE_SQL = text(
UNION ALL
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
'rough'::text AS tier
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
UNION ALL
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
'region_fallback'::text AS tier
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max,
c.n_deals, 'region_fallback'::text AS tier
FROM city_stats c
CROSS JOIN region_stats r
JOIN region_stats r ON r.region_code = c.region_code
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
INSERT INTO deal_city_price_bands
(region_code, city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT region_code, city, ppm2_min, ppm2_max, n_deals, tier, now()
FROM tiered
ON CONFLICT (city) DO UPDATE
ON CONFLICT (region_code, city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
@ -103,13 +124,18 @@ _REDERIVE_SQL = text(
)
# ── Post-insert counters ──────────────────────────────────────────────────────
# #3051 (298): regions — число различных region_code в таблице после re-derive
# (Свердловская обл. + Москва после включения региона 77). Добавлено в конец
# SELECT-списка, прежние 4 счётчика на тех же местах — контракт _COUNTERS_SQL
# (rows_written/full_rows/rough_rows/region_fallback_rows) не ломается.
_COUNTERS_SQL = text(
"""
SELECT
COUNT(*) AS rows_written,
COUNT(*) FILTER (WHERE tier = 'full') AS full_rows,
COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows,
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows,
COUNT(DISTINCT region_code) AS regions
FROM deal_city_price_bands
"""
)
@ -124,13 +150,15 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}.
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..,
"regions": ..}.
"""
counters: dict[str, int] = {
"rows_written": 0,
"full_rows": 0,
"rough_rows": 0,
"region_fallback_rows": 0,
"regions": 0,
}
try:
db.execute(_REDERIVE_SQL)
@ -141,17 +169,19 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
counters["full_rows"] = int(row["full_rows"] or 0)
counters["rough_rows"] = int(row["rough_rows"] or 0)
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
counters["regions"] = int(row["regions"] or 0)
db.commit()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"refresh_deal_city_price_bands run_id=%d done: "
"rows_written=%d full=%d rough=%d region_fallback=%d",
"rows_written=%d full=%d rough=%d region_fallback=%d regions=%d",
run_id,
counters["rows_written"],
counters["full_rows"],
counters["rough_rows"],
counters["region_fallback_rows"],
counters["regions"],
)
return counters
except Exception as exc:

View file

@ -447,6 +447,19 @@ async def run_domclick_detail_backfill(
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
else:
# #3332, та же дыра в тождестве, что и у пустого пула: карточку
# забрали, а строки уже нет (удалена/деактивирована между
# снимком и UPDATE) — попытка была, исхода не было. Тихо
# терять её нельзя: расхождение читается как потерянный блок.
counters.failed += 1
logger.warning(
"domclick_detail_backfill: run_id=%d listing %s — карточка "
"разобрана, но UPDATE не нашёл строку id=%d",
run_id,
source_url,
listing_id,
)
consecutive_blocks = 0
consecutive_soft = 0
@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
# Прогон 5399 умер именно так: три «блока» подряд, из них два
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
if _caused_by_empty_pool(e):
# #3332: попытка уже посчитана в attempted (строка выше), а этот
# выход из цикла шёл мимо ВСЕХ исходов — тождество
# attempted = enriched + failed + blocked ломалось ровно на 1
# (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом).
# Исход честно failed, а не blocked: к площадке не ходили, это
# отказ нашей стороны — тот же разряд, что у транспортных сбоев
# ниже. Причина не теряется: в записи прогона стоит
# no_proxy_stop=1 и mark_failed с текстом про пул.
counters.failed += 1
logger.error(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
@ -620,5 +642,14 @@ async def run_domclick_detail_backfill(
run_id,
counters.duration_sec,
)
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters.to_dict())
current_counters = counters.to_dict()
if _caused_by_empty_pool(exc):
# #3384: пул был пуст ещё ДО первой карточки — lease берётся в
# BrowserFetcher.__aenter__, поэтому NoProxyAvailableError вылетает из
# самого `async with` (строка 403) мимо стоп-механики цикла, которая и
# ставит no_proxy_stop. Без ключа такой прогон (attempted=0, к площадке не
# ходили) неотличим от любого другого падения: разбор простоя идёт SQL'ём
# по counters.no_proxy_stop (#3283/#3367), а не грепом текста ошибки.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(db, run_id, str(exc)[:1000], current_counters)
raise

View file

@ -0,0 +1,119 @@
"""ДТП (dtp-stat.ru) refresh — CLI + scheduler run-lifecycle wrapper (#3410).
Наполняет `dtp_incidents` (пустая при деплое, миграция 294) через
`app/services/dtp_stat_loader.load_dtp_incidents` (скачивание ZIP + потоковый парс +
TRUNCATE+INSERT). Scheduler source='dtp_stat_refresh' (seed 293, enabled=false
источник заморожен, см. докстринг loader'а; первый прогон ручной).
Запуск из контейнера:
python -m app.tasks.dtp_stat_refresh
python -m app.tasks.dtp_stat_refresh --dry-run
python -m app.tasks.dtp_stat_refresh --src-path /path/to/local.zip
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
"""
from __future__ import annotations
import argparse
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
from app.services.dtp_stat_loader import load_dtp_incidents
logger = logging.getLogger(__name__)
@dataclass
class DtpStatRefreshResult:
parsed: int = 0
inserted: int = 0
duration_sec: float = field(default=0.0)
def to_counters(self) -> dict[str, int]:
return {
"parsed": self.parsed,
"inserted": self.inserted,
"duration_sec": int(self.duration_sec),
}
def run_dtp_stat_refresh(db: Session, *, run_id: int, params: dict) -> DtpStatRefreshResult:
"""Run-lifecycle wrapper для scheduler'а (source='dtp_stat_refresh') и ручного прогона.
params:
dry_run bool, по умолчанию False. True: парсит и считает, БД не трогает.
src_path str, локальный путь к ZIP (для отладки/тестов, минуя скачивание).
Финализирует scrape_runs (mark_done / mark_failed) со счётчиками.
"""
dry_run = bool(params.get("dry_run", False))
src_path = params.get("src_path")
counters: dict[str, int] = {"parsed": 0, "inserted": 0}
start = time.monotonic()
try:
runs_mod.update_heartbeat(db, run_id, counters)
load_counts = load_dtp_incidents(db, src_path=src_path, dry_run=dry_run)
if not dry_run:
db.commit()
result = DtpStatRefreshResult(
parsed=load_counts["parsed"],
inserted=load_counts["inserted"],
duration_sec=time.monotonic() - start,
)
counters = result.to_counters()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"run_dtp_stat_refresh: run_id=%d DONE dry_run=%s parsed=%d inserted=%d duration=%.1fs",
run_id,
dry_run,
result.parsed,
result.inserted,
result.duration_sec,
)
return result
except Exception as exc:
logger.exception("run_dtp_stat_refresh: run_id=%d FAILED", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src-path", default=None, help="Локальный ZIP вместо скачивания SRC_URL")
parser.add_argument("--dry-run", action="store_true", help="Парсить и считать, не писать в БД")
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
args = build_parser().parse_args()
from app.core.db import SessionLocal
db = SessionLocal()
try:
counts = load_dtp_incidents(db, src_path=args.src_path, dry_run=args.dry_run)
if not args.dry_run:
db.commit()
logger.info("dtp_stat_refresh CLI DONE: dry_run=%s %s", args.dry_run, counts)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,71 @@
"""CLI: ФНС opendata (revexp/sshr2019/debtam/snr) → fns_legal_entity_facts.
Только загрузка + lookup (`app/services/fns_lookup.py`) потребителя у данных пока
нет, см. docstring `app/services/fns_opendata_loader.py`.
Запуск из контейнера tradein-backend/tradein-scraper (нужен интернет к nalog.gov.ru):
python -m app.tasks.fns_opendata_load # все 4 набора
python -m app.tasks.fns_opendata_load --datasets revexp # один набор
python -m app.tasks.fns_opendata_load --dry-run # без записи, только резолв ссылки
python -m app.tasks.fns_opendata_load --force # перекачать, даже если версия та же
В режиме --dry-run скачивание/парс/запись не происходят только резолв актуальной
ссылки со страницы каталога (проверка доступности + логирование того, что было бы
скачано).
"""
from __future__ import annotations
import argparse
import logging
from app.core.db import SessionLocal
from app.services.fns_opendata_loader import DATASET_SLUGS, load_dataset
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
parser = argparse.ArgumentParser(
description="ФНС opendata loader: revexp/sshr2019/debtam/snr → fns_legal_entity_facts"
)
parser.add_argument(
"--datasets",
nargs="+",
choices=list(DATASET_SLUGS),
default=list(DATASET_SLUGS),
help="список наборов (по умолчанию — все 4)",
)
parser.add_argument(
"--dry-run", action="store_true", help="без записи в БД — только резолв ссылки/подсчёт"
)
parser.add_argument(
"--force", action="store_true", help="перекачать, даже если версия совпадает с загруженной"
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
args = build_parser().parse_args()
db = SessionLocal()
try:
results: dict[str, object] = {}
for slug in args.datasets:
result = load_dataset(db, slug, dry_run=args.dry_run, force=args.force)
if not args.dry_run:
db.commit()
results[slug] = result
logger.info("fns_opendata_load DONE: dry_run=%s %s", args.dry_run, results)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,127 @@
"""CLI: АИС ППК ФРТ (реестр МКД) → houses.area_land/foundation_type/elevators_total +
добор year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
heat_supply_type/gas_supply_type/hot_water (issue #frt-mkd).
Тянет CSV реестра МКД (export/{node_id}, по умолчанию 110 = region 66) с открытых
данных АИС ППК ФРТ, UPSERT'ит в staging `frt_mkd` (мигр. 290), затем backfill_houses —
COALESCE-добор перечисленных houses.* полей (только NULL, никогда не перезаписывает).
Запуск из контейнера tradein-scraper (у него есть интернет к источнику):
python -m app.tasks.frt_mkd_load # полный прогон (node 110, region 66)
python -m app.tasks.frt_mkd_load --dry-run # без записи, только подсчёт
python -m app.tasks.frt_mkd_load --load-only # только staging, без backfill
python -m app.tasks.frt_mkd_load --backfill-only # staging уже загружена — только backfill
python -m app.tasks.frt_mkd_load --src-path /tmp/frt.csv # локальный CSV, без скачивания
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
в БД не делается.
"""
from __future__ import annotations
import argparse
import logging
from app.core.db import SessionLocal
from app.services.frt_mkd_loader import (
DEFAULT_NODE_ID,
DEFAULT_REGION_CODE,
backfill_houses,
load_frt_mkd,
)
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
parser = argparse.ArgumentParser(
description=(
"АИС ППК ФРТ loader: реестр МКД → frt_mkd staging → "
"houses.area_land/foundation_type/elevators_total + добор смежных полей"
)
)
parser.add_argument(
"--src-path", default=None, help="локальный CSV реестра МКД (пропустить скачивание)"
)
parser.add_argument(
"--work-dir",
default=None,
help="каталог для скачанного/распакованного CSV (по умолчанию — временный, "
"удаляется после)",
)
parser.add_argument(
"--node-id",
type=int,
default=DEFAULT_NODE_ID,
help=f"id ноды export АИС ФРТ (по умолчанию {DEFAULT_NODE_ID} = реестр МКД region 66)",
)
parser.add_argument(
"--region-code",
type=int,
default=DEFAULT_REGION_CODE,
help=f"код региона выгрузки для тега staging-строк (по умолчанию {DEFAULT_REGION_CODE})",
)
parser.add_argument(
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
)
parser.add_argument(
"--load-only",
action="store_true",
help="только скачать+распарсить+UPSERT staging, без backfill houses",
)
parser.add_argument(
"--backfill-only",
action="store_true",
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
"или предыдущим прогоном) — только backfill houses",
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = build_parser()
args = parser.parse_args()
if args.load_only and args.backfill_only:
parser.error("--load-only и --backfill-only взаимоисключающи")
db = SessionLocal()
try:
load_counts: dict[str, int] = {}
if not args.backfill_only:
load_counts = load_frt_mkd(
db,
src_path=args.src_path,
work_dir=args.work_dir,
node_id=args.node_id,
region_code=args.region_code,
dry_run=args.dry_run,
)
if not args.dry_run:
db.commit()
logger.info("frt_mkd_load: load stage done: %s", load_counts)
houses_counts: dict[str, int] = {}
if not args.load_only:
houses_counts = backfill_houses(db, dry_run=args.dry_run)
if not args.dry_run:
db.commit()
logger.info(
"frt_mkd_load DONE: dry_run=%s load=%s houses=%s",
args.dry_run,
load_counts,
houses_counts,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -110,10 +110,10 @@ logger = logging.getLogger(__name__)
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
REJECTION_RULE = (
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
)
@ -123,7 +123,7 @@ NOTE = (
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
"она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ. "
"строка выглядит как промах расчёта, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "

View file

@ -305,9 +305,12 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
).first()
if row is not None and row.n:
n = int(row.n)
# Публичная подпись метрики: площадка не называется (норма publicLabel,
# решение 31.08.2026); фактически это Домклик — единственный источник,
# где триггер пишет стартовую цену. Текст персистится в landing_stats.note.
base_note = (
f"Только Домклик (единственный источник, где триггер пишет стартовую цену), "
f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
"Только одна из площадок (единственный источник, где фиксируется "
f"стартовая цена), наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
)
metrics.append(

View file

@ -44,6 +44,14 @@ Why curl_cffi and not YandexDetailScraper.fetch_detail:
non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса
(ban_kind_from_status: 403/429 platform, 5xx infra, прочее unknown), а не
из текстовых маркеров страницы, снятых с чужой площадки.
Полнота страницы (#3191): HTTP 200 + разобравшийся parse ещё не означают карточку.
Недорендеренная страница (1,8 МБ вместо 3,9, без блока контактов) парсится молча и
раньше уезжала в БД с detail_enriched_at, выбывая из очереди навсегда. Теперь она
отсеивается ДО parse (detail_incomplete_reason: структурный маркер контактов +
размерный порог settings.yandex_detail_min_html_bytes) и считается исходом
incomplete failed обогащения нет, значит следующий снапшот
(detail_enriched_at IS NULL) возьмёт её снова.
"""
from __future__ import annotations
@ -57,10 +65,15 @@ from dataclasses import dataclass, field
from curl_cffi.requests import AsyncSession
from scraper_kit.browser_fetcher import ban_kind_from_status
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment
from scraper_kit.providers.yandex.detail import (
YandexDetailScraper,
detail_incomplete_reason,
save_detail_enrichment,
)
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services import scrape_runs as runs_mod
from app.services.proxy_egress import resolve_proxy_url
from app.services.scrape_runs import BAN_KIND_UNKNOWN
@ -136,6 +149,11 @@ class YandexDetailBackfillResult:
# обогащения») не переписываем. Без этого счётчика ветка перевода прогона в
# 'banned' у Яндекса недостижима по построению (#3196).
blocked: int = 0
# Недогруженные страницы среди попыток (#3191): HTTP 200, валидный HTML, но без
# блока контактов / заметно меньше нормы. Тоже подмножество failed — попытка была,
# обогащения не случилось; detail_enriched_at не проставляется, объявление остаётся
# в очереди (снапшот берётся по detail_enriched_at IS NULL).
incomplete: int = 0
failed: int = 0
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
@ -150,6 +168,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"blocked": self.blocked,
"incomplete": self.incomplete,
"failed": self.failed,
"url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
@ -419,6 +438,47 @@ async def run_yandex_detail_backfill(
break
continue
# Недогруз — отказ, а не успех (#3191). Проверка ДО parse: parse
# частичную страницу разберёт молча (JSON-состояние на месте), и
# объявление уедет в БД с detail_enriched_at, выбыв из очереди
# навсегда. Здесь оно исхода 'enriched' не получает, значит в
# следующем прогоне снова попадёт в снапшот (detail_enriched_at
# IS NULL). Серия таких страниц двигает consecutive_none — тот же
# брейкер, что у parse→None: вечно недогружаемая карточка упрётся
# в max_consecutive_blocks и оборвёт прогон, а не будет молотиться
# (per-listing счётчика попыток в схеме нет, см. отчёт #3191).
incomplete_reason = detail_incomplete_reason(
resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes
)
if incomplete_reason is not None:
counters.incomplete += 1
counters.failed += 1
consecutive_none += 1
# Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196).
consecutive_blocks = 0
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
"-> недогруженная карточка, отказ: %s (consecutive=%d)",
run_id,
listing_id,
source_url,
incomplete_reason,
consecutive_none,
)
if consecutive_none >= max_consecutive_blocks:
logger.error(
# consecutive_none смешанный (фетч-ошибка + parse-None +
# недогруз) — «недогруженных» назвало бы только один вклад.
"yandex_detail_backfill: run_id=%d ABORT -- %d подряд "
"без обогащения. enriched=%d attempted=%d",
run_id,
consecutive_none,
counters.enriched,
counters.attempted,
)
break
continue
enrichment = scraper.parse(resp.text, offer_url=source_url)
if enrichment is None:
@ -458,6 +518,21 @@ async def run_yandex_detail_backfill(
consecutive_blocks = 0
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
else:
# #3338 (та же дыра, что #3332 у domclick): страница взята и
# разобрана, а UPDATE не задел ни одной строки — объявление
# удалено/деактивировано между снимком и записью. Попытка была,
# исхода не было: attempted переставал сходиться с enriched +
# failed, и расхождение читается как потерянный отказ площадки.
# Исход failed: непрошедший UPDATE — не успех и не блок.
counters.failed += 1
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
"-- карточка разобрана, но UPDATE не задел ни одной строки",
run_id,
listing_id,
source_url,
)
except Exception as exc:
counters.failed += 1
@ -487,11 +562,12 @@ async def run_yandex_detail_backfill(
)
logger.info(
"yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
"blocked=%d failed=%d duration=%.1fs",
"blocked=%d incomplete=%d failed=%d duration=%.1fs",
run_id,
counters.attempted,
counters.enriched,
counters.blocked,
counters.incomplete,
counters.failed,
counters.duration_sec,
)

View file

@ -43,10 +43,11 @@ import random
import time
from dataclasses import dataclass, field, fields
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.scraper_adapters import RealScraperConfig
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
from app.services.scraper_settings import get_scraper_delay
logger = logging.getLogger(__name__)
@ -78,10 +79,21 @@ class YandexNewbuildingSweepResult:
failed_fetch: int = 0 # fetch_jk вернул None / упал
rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых)
# Пул прокси пуст → прогон оборван, к площадке не ходили (#3197).
no_proxy_stop: bool = False
duration_sec: float = field(default=0.0)
def to_dict(self) -> dict[str, int | float]:
return {f.name: getattr(self, f.name) for f in fields(self)}
"""Счётчики прогона; `no_proxy_stop` — 1/0, а не JSON true/false.
Формат один с соседями (`scheduler.py:168` cian, `avito_detail_backfill.py:1043`,
`domclick_detail_backfill.py:610`): SQL-монитор ищет
`counters->>'no_proxy_stop' = '1'` и мимо `true` промахнётся молча.
"""
out: dict[str, int | float] = {f.name: getattr(self, f.name) for f in fields(self)}
out["no_proxy_stop"] = int(self.no_proxy_stop)
return out
# ── SQL ────────────────────────────────────────────────────────────────────────
@ -331,9 +343,25 @@ async def enrich_yandex_newbuilding_sweep(
try:
resolved = await resolve_yandex_jk_slug(
ext_id, city=city, config=RealScraperConfig()
ext_id,
city=city,
config=RealScraperConfig(),
proxy_provider=RealProxyProvider(),
)
except Exception as exc:
# #3197: пустой пул — не отказ площадки: запрос не уходил вовсе, и
# следующий дом упрётся ровно в то же самое.
if caused_by_no_proxy(exc):
result.no_proxy_stop = True
result.failed_resolve += 1
logger.error(
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
"house_id=%s processed=%d succeeded=%d",
house_id,
result.processed,
result.succeeded,
)
break
logger.warning(
"resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s",
house_id,
@ -383,9 +411,22 @@ async def enrich_yandex_newbuilding_sweep(
# ── Fetch через BrowserFetcher ────────────────────────────────────
info = None
try:
scraper = YandexNewbuildingScraper(config=RealScraperConfig())
scraper = YandexNewbuildingScraper(
config=RealScraperConfig(), proxy_provider=RealProxyProvider()
)
info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city)
except Exception as exc:
if caused_by_no_proxy(exc): # #3197 — см. блок resolve выше
result.no_proxy_stop = True
result.failed_fetch += 1
logger.error(
"yandex-nb-sweep: СТОП — пул прокси пуст, к площадке не ходили. "
"house_id=%s processed=%d succeeded=%d",
house_id,
result.processed,
result.succeeded,
)
break
logger.warning(
"fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s",
house_id,

View file

@ -0,0 +1,131 @@
-- 284_houses_total_units_from_raw.sql
-- Перелить дом-поля карточек Домклика из listings.raw_payload в колонки houses (#3253).
--
-- ЧТО БЫЛО НЕ ТАК
-- Парсер карточки Домклика читает блок дома (houseInfo.info) и кладёт его ЦЕЛИКОМ
-- в listings.raw_payload: wall_type, floor_type, quarters_count, плюс серию дома,
-- энергоэффективность и число подъездов. В колонки houses не переносил ничего.
-- Замер 29.08.2026: houses.total_units = 0 у ВСЕХ источников без исключения, хотя
-- у Домклика число квартир уже лежало в собранных payload'ах. Код перенос чинит
-- для будущих карточек (providers/domclick/detail.py, тот же PR); эта миграция
-- отрабатывает задним числом по уже собранному — ни одного запроса к площадке.
--
-- СВЯЗЬ ОБЪЯВЛЕНИЕ → ДОМ
-- listings.house_id_fk — та же связь, которой пользуется код-перенос и соседний
-- avito-путь (#3036). Другой связи нет: у SERP Домклика house_source/house_ext_id
-- намеренно не заполняются (#3064), а domclick_building_guid из карточки лежит в
-- raw_payload и ключом дома пока не является.
--
-- ЧТО НЕ ДЕЛАЕМ
-- Серия дома, энергоэффективность и число подъездов — колонок под них в houses нет,
-- схему ради этого не расширяем: остаются в raw_payload.
--
-- СЛОВАРЬ СТЕН И ПЕРЕКРЫТИЙ — НЕ СЫРЬЁ ПЛОЩАДКИ
-- houses.material_walls уже заполнена словарём ДОМ.РФ (капремонт КР1.2, #2013):
-- кирпич 2662, железобетонная панель 2107, иное 1850, монолит 754. У Домклика в
-- raw_payload свой словарь. Лить его как есть — сделать колонку двухсловарной, и
-- фильтр по одному значению перестанет видеть половину строк (ровно то, что уже
-- случилось с sale_type до #2674). Тот же CASE стоит в коде-переносе
-- (canon_wall_type / canon_floor_type, providers/domclick/detail.py).
--
-- Домклик → houses.material_walls
-- Кирпичный → кирпич
-- Панельный → железобетонная панель
-- Монолитный → монолит
-- Монолитно-кирпичный → монолит (несущий каркас монолитный, кирпич —
-- заполнение; смешанного класса у ДОМ.РФ
-- нет, «иное» потеряло бы конструктив)
-- Блочный, Деревянный → иное
-- всё прочее → NULL (CASE без ELSE; сырьё не пишем)
--
-- Домклик → houses.material_floors
-- Железобетонный → Железобетонные (форма, уже лежащая в колонке — 26
-- Железобетонные → Железобетонные строк от avito)
-- всё прочее → NULL
--
-- ИДЕМПОТЕНТНОСТЬ
-- Каждый UPDATE пишет ТОЛЬКО туда, где колонка пуста (IS NULL в WHERE) — повторный
-- прогон видит уже заполненное и трогает 0 строк. Непустое значение дома не
-- затирается никогда: у houses есть конкурирующие писатели (ДОМ.РФ капремонт,
-- Houses Catalog), карточка площадки не канон.
--
-- ВЫБОР ЗНАЧЕНИЯ ПРИ НЕСКОЛЬКИХ ОБЪЯВЛЕНИЯХ В ОДНОМ ДОМЕ
-- DISTINCT ON (house_id_fk) ... ORDER BY house_id_fk, detail_enriched_at DESC NULLS LAST,
-- id DESC — берём самую свежую обогащённую карточку. NULLS LAST обязателен: в
-- Postgres DESC по умолчанию ставит NULL ПЕРВЫМИ, и без него победителем стала бы
-- карточка вообще без отметки обогащения.
BEGIN;
-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках houses и без
-- lock_timeout встанет в очередь за чужой сессией, утащив за собой запросы приложения.
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
before_units bigint;
before_walls bigint;
before_floors bigint;
after_units bigint;
after_walls bigint;
after_floors bigint;
BEGIN
SELECT count(*) FILTER (WHERE total_units IS NOT NULL),
count(*) FILTER (WHERE material_walls IS NOT NULL),
count(*) FILTER (WHERE material_floors IS NOT NULL)
INTO before_units, before_walls, before_floors
FROM houses;
RAISE NOTICE 'houses ДО: total_units=%, material_walls=%, material_floors=%',
before_units, before_walls, before_floors;
WITH src AS (
SELECT DISTINCT ON (l.house_id_fk)
l.house_id_fk AS house_id,
NULLIF(l.raw_payload ->> 'quarters_count', '') AS quarters_count,
CASE lower(trim(NULLIF(l.raw_payload ->> 'wall_type', '')))
WHEN 'кирпичный' THEN 'кирпич'
WHEN 'панельный' THEN 'железобетонная панель'
WHEN 'монолитный' THEN 'монолит'
WHEN 'монолитно-кирпичный' THEN 'монолит'
WHEN 'блочный' THEN 'иное'
WHEN 'деревянный' THEN 'иное'
END AS wall_type,
CASE lower(trim(NULLIF(l.raw_payload ->> 'floor_type', '')))
WHEN 'железобетонный' THEN 'Железобетонные'
WHEN 'железобетонные' THEN 'Железобетонные'
END AS floor_type
FROM listings l
WHERE l.source = 'domklik'
AND l.house_id_fk IS NOT NULL
AND l.raw_payload IS NOT NULL
AND (l.raw_payload ? 'quarters_count'
OR l.raw_payload ? 'wall_type'
OR l.raw_payload ? 'floor_type')
ORDER BY l.house_id_fk, l.detail_enriched_at DESC NULLS LAST, l.id DESC
)
UPDATE houses h SET
total_units = COALESCE(
h.total_units,
CASE WHEN s.quarters_count ~ '^[0-9]+$'
THEN CAST(s.quarters_count AS integer) END
),
material_walls = COALESCE(h.material_walls, s.wall_type),
material_floors = COALESCE(h.material_floors, s.floor_type)
FROM src s
WHERE h.id = s.house_id
AND (h.total_units IS NULL AND s.quarters_count ~ '^[0-9]+$'
OR h.material_walls IS NULL AND s.wall_type IS NOT NULL
OR h.material_floors IS NULL AND s.floor_type IS NOT NULL);
SELECT count(*) FILTER (WHERE total_units IS NOT NULL),
count(*) FILTER (WHERE material_walls IS NOT NULL),
count(*) FILTER (WHERE material_floors IS NOT NULL)
INTO after_units, after_walls, after_floors
FROM houses;
RAISE NOTICE 'houses ПОСЛЕ: total_units=% (+%), material_walls=% (+%), material_floors=% (+%)',
after_units, after_units - before_units,
after_walls, after_walls - before_walls,
after_floors, after_floors - before_floors;
END $$;
COMMIT;

View file

@ -0,0 +1,132 @@
-- 285_domklik_diff_percent_recompute.sql
-- Пересчитать offer_price_history.diff_percent у domklik из соседних price_rub (#3225).
--
-- ЧТО БЫЛО НЕ ТАК
-- Загрузчик карточки Домклика клал в diff_percent поле источника priceHistory.diff,
-- а там РУБЛИ, не проценты. Замер на проде 29.08.2026: 8900 из 11 075 непустых
-- значений с |diff| > 50; перцентили p05 = 600 000, p50 = 50 010, p95 = +300 000.
-- У одного и того же listing_id 406163 в колонке соседствуют 200000.00 (рубли, из
-- загрузчика) и 0.82 (настоящий процент, из триггера record_listing_price_change).
-- Код починен в том же PR (providers/domclick/detail.py + offer_price_history.py:
-- процент считается из соседних цен, неправдоподобное значение отвергается, а не
-- зажимается). Эта миграция отрабатывает задним числом по уже собранным строкам.
--
-- ТРОГАЕМ ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
-- В таблице два писателя с РАЗНОЙ базой отсчёта, и смешивать их нельзя:
-- • загрузчик (domclick/detail.py) считает процент внутри истории самой карточки —
-- база это предыдущая запись priceHistory, то есть предыдущая строка загрузчика;
-- • триггер record_listing_price_change (131_fix_diff_percent_overflow.sql:76-86)
-- считает от listings.OLD.price_rub — от цены В КАРТОЧКЕ ЛИСТИНГА на момент
-- upsert'а. Эта база в offer_price_history может вообще не лежать отдельной
-- строкой.
-- Поэтому раннее утверждение «формула триггера = формула миграции, пересчёт даст то
-- же значение» ЛОЖНО, и опереться на признак происхождения как раз нужно. Без него
-- миграция портит честные данные двумя способами: (1) у листинга, чья история
-- начинается с триггерной строки, lag() = NULL → честный 0.82 перезаписывается в
-- NULL; (2) у триггерной строки, соседом которой по lag() оказалась строка
-- загрузчика, честный процент пересчитывается от чужой базы.
-- Признак точный, а не эвристический: триггер подставляет now() и в change_time, и
-- в recorded_at ОДНИМ INSERT'ом, а now() стабилен внутри транзакции → у триггерной
-- строки метки равны побайтово. Загрузчик пишет в change_time дату источника, а
-- recorded_at не указывает вовсе (DEFAULT NOW(), 023_offer_price_history.sql:19) →
-- расхождение в месяцы. Совпадение исторической даты с моментом вставки с точностью
-- до микросекунды недостижимо. Признак закреплён тестом
-- test_save_detail_enrichment_leaves_recorded_at_to_default.
--
-- ОКНО lag() — ТОЖЕ ТОЛЬКО ПО СТРОКАМ ЗАГРУЗЧИКА
-- Триггерные строки не переписываем И не используем как базу. Иначе строка
-- загрузчика получила бы базой триггерную строку, которой в истории карточки нет, —
-- и результат разошёлся бы с тем, что теперь пишет починенный код. Задача миграции
-- ровно в том, чтобы задним числом дать те же значения, что даёт код: prev — это
-- предыдущая запись priceHistory карточки. По source окно не сужаем: «предыдущая
-- цена» — это предыдущая запись листинга, а у листинга со смешанными источниками
-- фильтр по source подсунул бы не ту строку.
--
-- ФИЛЬТР ПО |x| > 100 НЕ БЕРЁМ
-- Он пропустил бы испорченные строки с мелким рублёвым diff (50 рублей выглядит
-- как правдоподобные 50%).
--
-- АСИММЕТРИЯ: СТАРЫЕ CIAN-СТРОКИ НЕ ЧИНИМ
-- Гейт validate_diff_percent общий для всех источников (|x| > 100 → NULL + warning),
-- а миграция — только про domklik. Уже лежащие в таблице cian-строки с |x| > 100
-- остаются как есть: их история приходит из cian_price_history.py со своим полем и
-- своей базой, отдельным замером не подтверждена, а править вслепую по чужому
-- источнику — это второй #3225, а не его починка. Отдельная задача.
--
-- САМАЯ РАННЯЯ ЗАПИСЬ ЛИСТИНГА → NULL, НЕ 0
-- Предыдущей цены нет — процента не существует. Ноль здесь читался бы как «цена не
-- менялась», то есть как измерение, которого не было. Так же ведёт себя и код.
--
-- ИДЕМПОТЕНТНОСТЬ
-- Пересчёт детерминирован (те же строки → те же значения), а UPDATE ограничен
-- `IS DISTINCT FROM` — повторный прогон трогает 0 строк. Новых объектов схемы нет.
BEGIN;
-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках
-- offer_price_history и без lock_timeout встанет в очередь за чужой сессией,
-- утащив за собой запросы приложения.
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
before_notnull bigint;
before_bad bigint;
after_notnull bigint;
after_bad bigint;
touched bigint;
BEGIN
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
count(*) FILTER (WHERE abs(diff_percent) > 100)
INTO before_notnull, before_bad
FROM offer_price_history
WHERE source = 'domklik'
AND change_time <> recorded_at;
RAISE NOTICE 'domklik (строки загрузчика) ДО: diff_percent непустых = %, из них |x| > 100 = %',
before_notnull, before_bad;
WITH neighbours AS (
-- Окно только по листингам, у которых есть domklik-строки: без этого сужения
-- lag() прогоняется по ВСЕЙ таблице, и под lock_timeout = 5s деплой падает.
-- Оба скана идут по индексам 023: oph_source_time_idx (source, change_time)
-- для подзапроса и oph_listing_time_idx (listing_id, change_time) для окна.
SELECT id,
source,
price_rub,
lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id)
AS prev_price
FROM offer_price_history
WHERE listing_id IN (
SELECT DISTINCT listing_id
FROM offer_price_history
WHERE source = 'domklik'
)
AND change_time <> recorded_at
),
recomputed AS (
SELECT id,
CASE WHEN prev_price > 0
THEN round((price_rub - prev_price) / prev_price * 100, 2)
END AS new_diff
FROM neighbours
WHERE source = 'domklik'
)
UPDATE offer_price_history oph
SET diff_percent = r.new_diff
FROM recomputed r
WHERE oph.id = r.id
AND oph.diff_percent IS DISTINCT FROM r.new_diff;
GET DIAGNOSTICS touched = ROW_COUNT;
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
count(*) FILTER (WHERE abs(diff_percent) > 100)
INTO after_notnull, after_bad
FROM offer_price_history
WHERE source = 'domklik'
AND change_time <> recorded_at;
RAISE NOTICE 'domklik (строки загрузчика) ПОСЛЕ: diff_percent непустых = % (было %), |x| > 100 = % (было %), переписано строк = %',
after_notnull, before_notnull, after_bad, before_bad, touched;
END $$;
COMMIT;

View file

@ -0,0 +1,318 @@
-- 286_offer_price_history_decimal_slips.sql
-- Удалить из offer_price_history точки с потерянным разрядом и починить diff_percent (#3376).
--
-- ЧТО НЕ ТАК
-- В priceHistory источников встречаются точки ровно ×10 / ÷10 к соседям:
-- 377 000 → 3 770 000 → 3 720 000; 19 064 000 → 190 150 000 → 19 150 000. Цена
-- возвращается к базе следующей же точкой — это потерянный разряд у источника,
-- а не рынок. Второй вид того же дефекта — в НАЧАЛЕ серии: 330 000 → 3 300 000
-- (текущая цена объявления 3 300 000), 420 000 → 4 200 000 → 4 500 000. Дефектная
-- точка первая, слева у неё базы нет, поэтому её ловит зеркальное правило.
--
-- Читатели колонки — медианный торг лендинга (#3223), админка, /scrapers,
-- публичный API. Оценщик (services/estimator.py) историю не читает, на оценку
-- эти точки не влияли.
--
-- Код починен в том же PR: гейт drop_decimal_slips в scraper_kit/offer_price_history.py
-- подключён к писателям НА ЖИВОМ ТРАКТЕ (domclick/detail.py, cian/detail.py). Эта
-- миграция отрабатывает задним числом по уже собранным строкам. Разовый
-- scripts/local-cian/playwright_history.py гейта не знает, но переиграть миграцию не
-- может: он выбирает только объявления вообще БЕЗ offer_price_history.
--
-- ВЫБОРКА КАНДИДАТОВ ЗЕРКАЛИТ ГЕЙТ 1:1, А НЕ «ТОТ ЖЕ КРИТЕРИЙ»
-- Первая редакция этого файла брала предыдущую точку через lag(), то есть СЫРУЮ
-- соседнюю строку — включая те, что удаляются этим же проходом. Гейт в коде берёт
-- предыдущую ОСТАВЛЕННУЮ. Расхождение видно на двух сериях (обе — от ревьюера):
-- 1M → 10M → 1M → 10M (текущая цена 1M): гейт оставляет [1M, 1M], lag-версия
-- оставляла [1M] — честная точка удалена;
-- 1M → 10M → 1.05M → 9.9M (текущая цена 1M): lag-версия удаляла на первом проходе
-- ещё и 1.05M (её сырая база — уже удалённая 10M), а на втором доедала 9.9M,
-- то есть НЕ была идемпотентной.
-- Поэтому кандидаты выбираются PL/pgSQL-циклом, который повторяет гейт пошагово:
-- строки по (change_time, id), переменная base — последняя ОСТАВЛЕННАЯ цена,
-- свидетель — следующая СЫРАЯ точка либо, у последней, listings.price_rub.
-- Правило первой точки — вторым, УЖЕ ПО ОСТАВШИМСЯ строкам (в гейте оно тоже
-- применяется к kept-серии: свидетели первой точки сами могут оказаться сдвигами).
--
-- КРИТЕРИЙ — ДВА СВИДЕТЕЛЯ, И У ПЕРВОЙ ТОЧКИ ТОЖЕ ДВА
-- Внутри серии: 1) скачок к предыдущей ОСТАВЛЕННОЙ точке, price/base ∈ [9.5, 10.5]
-- в любую сторону; 2) возврат к базе у следующей точки, next/base ∈ [0.9, 1.1].
-- Первая точка: 1) она ×10/÷10 ко второй оставшейся; 2) вторая подтверждена ТРЕТЬЕЙ
-- оставшейся, third/second ∈ [0.9, 1.1]. Третьей нет → кандидата нет.
-- Окно шире чистой десятки, потому что сдвиг разряда часто идёт вместе с настоящим
-- мелким изменением цены (377 000 → 3 720 000 это ×9.87). Одного скачка НЕ хватает:
-- ×10 без подтверждения — это возможное честное изменение цены (400 000 → 4 000 000 →
-- 8 000 000 — разгон, а не разряд), и такие точки остаются. Нет второго свидетеля
-- вообще (последняя точка серии, а listings.price_rub пуст) → точку не трогаем.
--
-- ПОЧЕМУ У ПЕРВОЙ ТОЧКИ НЕТ ОТКАТА НА listings.price_rub. Он был и снят сознательно:
-- у 17 из 21 кандидата первой точки (yandex 6, domklik 9, cian 2) listings.price_rub
-- ТОЧНО равна второй точке — у yandex это буквально одна переменная lot.price_rub,
-- записанная в двух местах. «Два свидетеля» там вырождаются в одного, а DELETE
-- необратим. Эти 17 строк остаются жить; их процент, если он не процент, занулит
-- финальный шаг |diff_percent| > 100 → NULL. Для yandex отлов переехал в #3385 —
-- сравнение со СЛЕДУЮЩИМ наблюдением. У ПОСЛЕДНЕЙ точки цена объявления свидетелем
-- остаётся: она там сравнивается с базой СЛЕВА, то есть с другим наблюдением.
--
-- СКОЛЬКО ЖДЁМ УДАЛЕНИЙ
-- Замер на проде 06.09.2026 ровно под ЭТИМ критерием: 34 строки внутри серий
-- (cian 12, domklik 22) + 4 первых точки по третьей точке (domklik 4) = 38.
-- По источникам: cian 12, domklik 26, yandex 0 — yandex целиком отпал вместе с
-- откатом на listings.price_rub (см. выше), его случай уехал в #3385.
-- Порог остановки 200 — примерно пятикратный запас: поймали больше — критерий
-- ловит не то, и миграция обязана упасть, а не молча вычистить историю.
--
-- ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
-- Признак происхождения тот же, что в 285 (там же его доказательство): триггер
-- record_listing_price_change пишет now() и в change_time, и в recorded_at одним
-- INSERT'ом → метки равны побайтово; загрузчик кладёт в change_time дату источника,
-- а recorded_at оставляет DEFAULT NOW(). Триггерные строки (миграция 131) — это
-- зафиксированные ЖИВЫЕ смены listings.price_rub, а не разбор чужого массива; их
-- не удаляем и в цепочку не берём. Поэтому единственная avito-строка из замера
-- остаётся: она триггерная, и если цена в карточке действительно менялась ×10, то
-- это наблюдение, а не дефект разбора.
--
-- ОТСЮДА ЖЕ ТРЕТИЙ СВИДЕТЕЛЬ, КОТОРЫЙ СПАСАЕТ ОТ УДАЛЕНИЯ (L-4): если ровно такая
-- же цена есть у ТРИГГЕРНОЙ строки того же объявления — значит listings.price_rub
-- когда-то реально равнялась этому значению, и подозреваемая точка загрузчика
-- подтверждена независимым писателем. Такую не удаляем и оставляем в цепочке базой.
-- ЧЕСТНО ПРО ЕГО ВЕС: на прод-данных 06.09.2026 он не спас НИ ОДНОЙ строки (0 из 21
-- кандидата правила первой точки). Механизм проверен только на синтетике — держим
-- как страховку на будущих прогонах, а не как замеренную защиту.
--
-- ПЕРЕСЧЁТ diff_percent У СЛЕДУЮЩЕЙ СТРОКИ
-- У строки, шедшей за удалённой, предыдущая цена сменилась — процент, посчитанный
-- от фантомной базы, надо пересчитать по формуле 285: (price prev)/prev*100,
-- prev отсутствует/нулевой → NULL (процента не существует, ноль читался бы как
-- «цена не менялась»). Цепочки подряд удалённых строк это покрывает: next_id
-- последней удалённой в цепочке и есть первая уцелевшая.
--
-- ФИНАЛЬНЫЙ ШАГ: |diff_percent| > 100 → NULL ПО ВСЕМ ИСТОЧНИКАМ
-- Ровно то, что делает validate_diff_percent на записи с #3225: такая величина —
-- уже не процент (у domklik туда клали рубли). 285 пересчитала только domklik и
-- только из соседних цен, поэтому у cian/yandex/avito-строк загрузчика мусор мог
-- остаться. Честные скачки ×2…×6 при этом СОХРАНЯЮТ строку и цену — обнуляется
-- только процент, посчитанный не от той величины.
--
-- ИДЕМПОТЕНТНОСТЬ
-- Свойство доказано НА ГЕЙТЕ, а не на этом файле: property-тест
-- test_drop_decimal_slips_is_idempotent перебирает серии длины 2-6 по алфавиту
-- {1M, 10M, 100M, 1.05M, 9.9M} × current_price ∈ {—, 1M, 10M} и требует
-- gate(gate(s)) == gate(s). Раз выборка кандидатов здесь повторяет гейт пошагово,
-- свойство переносится: второй прогон даёт 0 удалений. Пересчёт diff_percent
-- детерминирован и ограничен IS DISTINCT FROM, финальный NULL-шаг — тоже (после
-- него |x| > 100 не остаётся). Новых объектов схемы нет, временные таблицы уходят
-- по ON COMMIT DROP.
-- Прогон обеих фаз ДВАЖДЫ подряд с ROLLBACK вместо COMMIT — tradein-mvp/scripts/sql/
-- 286_dryrun.sql; второй проход обязан дать deleted=0 recomputed=0 nulled=0.
BEGIN;
-- Конвенция проекта (#2752): DELETE/UPDATE берут блокировки на строках
-- offer_price_history, а CREATE TEMP TABLE — DDL; без lock_timeout деплой встанет
-- в очередь за чужой сессией и утащит за собой запросы приложения.
SET LOCAL lock_timeout = '5s';
CREATE TEMP TABLE oph_decimal_slips (
id bigint PRIMARY KEY,
listing_id bigint NOT NULL,
next_id bigint, -- строка, шедшая следом: у неё сменится база diff_percent
source text NOT NULL
) ON COMMIT DROP;
-- ФАЗА 1 — правило внутри серии, пошагово как в гейте.
DO $$
DECLARE
r record;
cur_listing bigint := NULL;
base numeric := NULL; -- последняя ОСТАВЛЕННАЯ цена этого объявления
witness numeric;
confirmed boolean;
BEGIN
FOR r IN
SELECT oph.id,
oph.listing_id,
oph.source,
oph.price_rub,
lead(oph.price_rub) OVER w AS next_price,
lead(oph.id) OVER w AS next_id,
l.price_rub AS listing_price
FROM offer_price_history oph
LEFT JOIN listings l ON l.id = oph.listing_id
WHERE oph.change_time <> oph.recorded_at
-- Объявления с одной строкой загрузчика кандидатов дать не могут ни по
-- одному из правил (нет ни базы слева, ни второй точки справа).
AND oph.listing_id IN (
SELECT listing_id
FROM offer_price_history
WHERE change_time <> recorded_at
GROUP BY listing_id
HAVING count(*) >= 2
)
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
ORDER BY oph.listing_id, oph.change_time, oph.id
LOOP
IF cur_listing IS DISTINCT FROM r.listing_id THEN
cur_listing := r.listing_id;
base := NULL; -- новое объявление, базы слева нет
END IF;
-- Свидетель справа: следующая точка серии, а у последней — текущая цена
-- объявления. Нет ни того, ни другого → witness IS NULL, и сравнение ниже
-- даёт NULL, то есть «не сдвиг»: без второго свидетеля точку не трогаем.
witness := COALESCE(r.next_price, r.listing_price);
IF base IS NOT NULL
AND base > 0 AND r.price_rub > 0 AND witness > 0
AND (r.price_rub / base BETWEEN 9.5 AND 10.5
OR base / r.price_rub BETWEEN 9.5 AND 10.5)
AND witness / base BETWEEN 0.9 AND 1.1
THEN
-- L-4: цену подтверждает триггерная строка того же объявления → это
-- наблюдавшаяся listings.price_rub, а не дефект разбора.
SELECT EXISTS (
SELECT 1
FROM offer_price_history t
WHERE t.listing_id = r.listing_id
AND t.change_time = t.recorded_at
AND t.price_rub = r.price_rub
)
INTO confirmed;
IF NOT confirmed THEN
INSERT INTO oph_decimal_slips (id, listing_id, next_id, source)
VALUES (r.id, r.listing_id, r.next_id, r.source);
CONTINUE; -- база НЕ двигается: этой точки в серии больше нет
END IF;
END IF;
IF r.price_rub > 0 THEN
base := r.price_rub;
END IF;
END LOOP;
END $$;
-- ФАЗА 2 — правило первой точки, по ОСТАВШИМСЯ строкам (кандидаты фазы 1 исключены).
-- Набор множественный, состояния не требует — обычный INSERT ... SELECT.
INSERT INTO oph_decimal_slips (id, listing_id, next_id, source)
WITH kept AS (
SELECT oph.id,
oph.listing_id,
oph.source,
oph.price_rub,
row_number() OVER w AS rn,
lead(oph.id) OVER w AS second_id,
lead(oph.price_rub) OVER w AS second_price,
lead(oph.price_rub, 2) OVER w AS third_price
FROM offer_price_history oph
WHERE oph.change_time <> oph.recorded_at
AND NOT EXISTS (SELECT 1 FROM oph_decimal_slips s WHERE s.id = oph.id)
WINDOW w AS (PARTITION BY oph.listing_id ORDER BY oph.change_time, oph.id)
)
SELECT k.id, k.listing_id, k.second_id, k.source
FROM kept k
WHERE k.rn = 1
AND k.price_rub > 0
AND k.second_price > 0
AND (k.second_price / k.price_rub BETWEEN 9.5 AND 10.5
OR k.price_rub / k.second_price BETWEEN 9.5 AND 10.5)
-- Свидетель — только ТРЕТЬЯ точка истории. listings.price_rub здесь НЕ
-- подставляется: у 17 из 21 кандидата она в точности равна второй точке,
-- то есть это то же наблюдение, а не второй свидетель (см. шапку).
AND k.third_price > 0
AND k.third_price / k.second_price BETWEEN 0.9 AND 1.1
AND NOT EXISTS (
SELECT 1
FROM offer_price_history t
WHERE t.listing_id = k.listing_id
AND t.change_time = t.recorded_at
AND t.price_rub = k.price_rub
);
DO $$
DECLARE
to_delete bigint;
per_source text;
deleted_rows bigint;
updated_rows bigint;
nulled_rows bigint;
nulled_src text;
BEGIN
SELECT count(*) INTO to_delete FROM oph_decimal_slips;
SELECT string_agg(source || '=' || cnt, ', ' ORDER BY source)
INTO per_source
FROM (
SELECT source, count(*) AS cnt
FROM oph_decimal_slips
GROUP BY source
) s;
RAISE NOTICE 'offer_price_history: сдвигов разряда найдено = % (%)',
to_delete, COALESCE(per_source, 'ни одного');
IF to_delete > 200 THEN
RAISE EXCEPTION
'offer_price_history: к удалению % строк при замере 06.09.2026 = 38 '
'(34 внутри серий + 4 первых точки; cian 12, domklik 26, yandex 0) — '
'критерий ловит не то, миграция остановлена', to_delete;
END IF;
DELETE FROM offer_price_history oph
USING oph_decimal_slips s
WHERE oph.id = s.id;
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
-- Пересчёт СТРОГО у строк, шедших за удалёнными: только у них сменился prev.
WITH neighbours AS (
-- Окно сужено до затронутых листингов: без этого lag() гонится по всей
-- таблице, а под lock_timeout = 5s это лишний риск на деплое.
SELECT id,
price_rub,
lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id)
AS prev_price
FROM offer_price_history
WHERE change_time <> recorded_at
AND listing_id IN (SELECT DISTINCT listing_id FROM oph_decimal_slips)
),
recomputed AS (
SELECT n.id,
CASE
WHEN n.prev_price > 0
AND abs((n.price_rub - n.prev_price) / n.prev_price * 100) <= 100
THEN round((n.price_rub - n.prev_price) / n.prev_price * 100, 2)
END AS new_diff
FROM neighbours n
WHERE n.id IN (SELECT next_id FROM oph_decimal_slips WHERE next_id IS NOT NULL)
)
UPDATE offer_price_history oph
SET diff_percent = r.new_diff
FROM recomputed r
WHERE oph.id = r.id
AND oph.diff_percent IS DISTINCT FROM r.new_diff;
GET DIAGNOSTICS updated_rows = ROW_COUNT;
RAISE NOTICE 'offer_price_history: удалено строк = %, пересчитано diff_percent у соседей = %',
deleted_rows, updated_rows;
-- Финальный шаг: величина не того рода → NULL. Правило validate_diff_percent,
-- применённое ко ВСЕМ источникам и только к строкам загрузчика.
SELECT string_agg(source || '=' || cnt, ', ' ORDER BY source)
INTO nulled_src
FROM (
SELECT source, count(*) AS cnt
FROM offer_price_history
WHERE change_time <> recorded_at
AND abs(diff_percent) > 100
GROUP BY source
) s;
UPDATE offer_price_history
SET diff_percent = NULL
WHERE change_time <> recorded_at
AND abs(diff_percent) > 100;
GET DIAGNOSTICS nulled_rows = ROW_COUNT;
RAISE NOTICE 'offer_price_history: |diff_percent| > 100 обнулено = % (%)',
nulled_rows, COALESCE(nulled_src, 'ни одной');
END $$;
COMMIT;

View file

@ -0,0 +1,107 @@
-- 287_proxy_run_attribution.sql
-- scrape_runs.proxy_id — узел прогона (#3404 A) + soft-clear банов по источнику (#3404 B).
--
-- Dependencies: 015_scrape_runs.sql (scrape_runs), 157_scrape_proxies.sql (scrape_proxies),
-- 210_scrape_proxy_source_bans.sql (scrape_proxy_source_bans).
-- Apply after: 286_offer_price_history_decimal_slips.sql
--
-- ЧАСТЬ A — WHY:
-- До сих пор ни одна строка scrape_runs не знала, через какой узел пула шёл прогон:
-- ProxyProvider.acquire(provider) run_id не принимает, а leased_by у боевого пути —
-- NON_RUN_LEASE_MARKER. Разбор исхода прогона по узлу (кто плодит баны/провалы)
-- был возможен только вручную, по времени. Код-часть (app/services/proxy_pool.py:
-- attribute_run_proxy) пишет сюда после каждой выдачи лиза; здесь только схема.
--
-- ЧАСТЬ A — WHAT:
-- proxy_id — узел, через который шёл прогон. Если за прогон узел МЕНЯЛСЯ (ротация
-- при повторных провалах в browser_fetcher, либо curl-путь берёт лиз на каждый вызов
-- в providers/_proxy.py), здесь остаётся ПОСЛЕДНИЙ; полная цепочка узлов копится в
-- scrape_runs.counters->'proxy_ids' (jsonb-массив, пишет тот же attribute_run_proxy).
-- ON DELETE SET NULL, а не CASCADE — узел из пула может быть выведен/удалён оператором,
-- история прогонов (аналитика, отчёты) не должна пропадать вместе с ним.
-- Индекс (proxy_id, started_at DESC) — под разрез «исход прогона по узлу за период»
-- (WHERE proxy_id = ... ORDER BY started_at DESC); partial по proxy_id IS NOT NULL не
-- делаем, потому что колонка сортировки (started_at) в самом индексе — Postgres и так
-- не будет использовать индекс без него для прогонов без узла.
--
-- НИЧЕГО НЕ БЭКФИЛЛИТСЯ: связать уже прошедшие прогоны с конкретным узлом задним
-- числом нечем — leased_by исторически = NON_RUN_LEASE_MARKER, а лог выдачи лизов
-- не хранит run_id. Для всех строк scrape_runs, созданных ДО этой миграции,
-- proxy_id остаётся NULL навсегда — это не «прогон без прокси», а «прогон, для
-- которого атрибуция не собиралась». Врать восстановленным/угаданным значением
-- нельзя, поэтому backfill-UPDATE здесь сознательно отсутствует.
--
-- ЧАСТЬ B — WHY:
-- clear_source_bans() (proxy_pool.py) сейчас делает DELETE строки
-- scrape_proxy_source_bans. Это стирает историю эскалации (ban_count) и не оставляет
-- следа, что бан был снят ДОСРОЧНО (оператором/успешной ротацией exit-IP), в отличие
-- от бана, который просто истёк сам. Код-часть переводит функцию на UPDATE
-- (гашение: banned_until=now(), ban_count=0, cleared_at/cleared_reason проставляются),
-- строка доживает до штатного purge в run_proxy_healthcheck (SOURCE_BAN_PURGE_DAYS).
-- Эскалация при повторном бане той же пары сохраняется 1:1: формула в mark_banned
-- берёт СТАРЫЙ ban_count как показатель степени (base * 2^ban_count), при
-- ban_count=0 после гашения это ровно SOURCE_BAN_BASE_HOURS=6ч — байт-в-байт как
-- свежий INSERT после DELETE.
--
-- ЧАСТЬ B — WHAT:
-- cleared_at — момент досрочного снятия бана (не путать с истечением banned_until
-- само по себе: NULL значит «бан снят не был / истёк сам», не-NULL — снят
-- оператором или ротацией exit-IP до истечения срока или сразу после).
-- cleared_reason — свободный текст причины снятия (тот же 'reason', что передаётся
-- в clear_source_bans).
--
-- ИДЕМПОТЕНТНОСТЬ:
-- ADD COLUMN IF NOT EXISTS × 3, CREATE INDEX IF NOT EXISTS, COMMENT ON COLUMN
-- (безусловны, но идемпотентны сами по себе — просто перезаписывают тот же текст).
-- Backfill-DML в файле нет вовсе, поэтому повторный прогон — чистый no-op.
BEGIN;
SET LOCAL lock_timeout = '5s';
-- ── Часть A: scrape_runs.proxy_id ────────────────────────────────────────────
ALTER TABLE scrape_runs
ADD COLUMN IF NOT EXISTS proxy_id bigint REFERENCES scrape_proxies(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_scrape_runs_proxy_id_started_at
ON scrape_runs (proxy_id, started_at DESC);
COMMENT ON COLUMN scrape_runs.proxy_id IS
'Узел пула (scrape_proxies.id), через который шёл прогон. NULL = прогон без '
'прокси (эстиматор, admin-инициированные вызовы с NON_RUN_LEASE_MARKER) либо '
'прогон ДО применения миграции 287 (backfill не делался — связать нечем). '
'Если узел менялся mid-run (ротация после серии провалов в browser_fetcher, '
'либо curl-путь берёт лиз заново на каждый вызов) — здесь ПОСЛЕДНИЙ выданный '
'узел, полная цепочка — counters->''proxy_ids'' (jsonb-массив id, в порядке '
'первой выдачи). ON DELETE SET NULL: удаление узла из пула не должно уносить '
'историю прогонов.';
-- ── Часть B: soft-clear в scrape_proxy_source_bans ───────────────────────────
ALTER TABLE scrape_proxy_source_bans
ADD COLUMN IF NOT EXISTS cleared_at timestamptz,
ADD COLUMN IF NOT EXISTS cleared_reason text;
COMMENT ON COLUMN scrape_proxy_source_bans.cleared_at IS
'Момент досрочного снятия бана (proxy_pool.clear_source_bans, #3404) — '
'оператором или успешной ротацией exit-IP. NULL = бан не снимался вручную '
'(либо ещё активен, либо истёк сам по banned_until). Строка при гашении НЕ '
'удаляется — доживает до штатного purge (SOURCE_BAN_PURGE_DAYS), таймер '
'которого для погашенных строк отсчитывается от banned_until = момент гашения.';
COMMENT ON COLUMN scrape_proxy_source_bans.cleared_reason IS
'Причина досрочного снятия бана (тот же текст, что передан в '
'clear_source_bans(reason=...)). NULL, если строка не гасилась вручную.';
COMMENT ON COLUMN scrape_proxy_source_bans.ban_count IS
'Сколько раз эта пара банилась. Срок ТЕКУЩЕГО бана (banned_until - banned_at) = '
'base * 2^(ban_count-1), потолок SOURCE_BAN_MAX_HOURS: ban_count=1 → 6ч, 2 → 12ч, '
'3 → 24ч и т.д. Сбрасывается либо purge''ем через SOURCE_BAN_PURGE_DAYS после '
'истечения, либо proxy_pool.clear_source_bans (#3404: досрочное ГАШЕНИЕ строки —'
' banned_until=now(), ban_count=0, cleared_at/cleared_reason проставляются; '
'строка НЕ удаляется, живёт до purge). Оба пути одинаково обнуляют ban_count, '
'поэтому следующий бан той же пары в обоих случаях стартует заново с '
'SOURCE_BAN_BASE_HOURS.';
COMMIT;

View file

@ -0,0 +1,67 @@
-- 288_deals_doc_type.sql
-- deals.doc_type — тип документа сделки Росреестра (#3051 п.3, п.6).
--
-- Dependencies: 002_core_tables.sql (deals), 015_scrape_runs.sql + 072_scrape_schedules_seed_cian_rosreestr.sql
-- (scrape_schedules, строка source='rosreestr_dkp_import')
-- Apply after: 287_proxy_run_attribution.sql
--
-- ЧАСТЬ A — WHY:
-- Импорт Росреестра до сих пор ронял тип документа на пол: фильтр doc_type = 'ДКП'
-- стоял литералом в WHERE, а в deals не приезжало ничего. Пока скоуп был один
-- (Свердловская обл., только вторичка) это было безобидно — все строки источника
-- 'rosreestr' по построению ДКП. С расширением на Москву безобидность кончается:
-- в источнике за 2024 по region_code=77 лежит 30 627 ДДУ с медианой 112 743 ₽/м²
-- против 107 005 ДКП с медианой 256 250 ₽/м² — это цены котлована, и смешать их
-- в одной таблице без различимого признака значит развалить любую оценку.
-- Колонка нужна ДО того, как импорт начнёт тянуть больше одного типа.
--
-- ЧАСТЬ A — WHAT:
-- doc_type text NULLable — источник (foreign table gendesign_rosreestr_deals.doc_type)
-- тоже text и NULL допускает; строгий NOT NULL сломал бы не-росреестровые источники
-- (etazhi / domklik_history), у которых понятия «тип документа» нет вовсе.
-- Индекс НЕ добавляем: селективность низкая (2-3 значения), а все живые выборки
-- по deals идут по region_code/deal_date/geom — doc_type там в лучшем случае
-- довесок к уже отобранному диапазону. Появится запрос, который реально режет
-- по doc_type на большом наборе — заведём частичный индекс тогда, по EXPLAIN.
--
-- ЧАСТЬ B — бэкфилл:
-- Всё, что лежит в deals с source='rosreestr', прошло через WHERE doc_type = 'ДКП'
-- (и в scheduler.import_rosreestr_dkp, и в deploy/import-rosreestr.sh, и в
-- backfill-миграции 077) — других типов там физически быть не может. Поэтому
-- проставить 'ДКП' задним числом корректно, а не эвристика.
-- Остальные источники остаются NULL осознанно.
--
-- ЧАСТЬ C — расписание:
-- scrape_schedules.default_params для 'rosreestr_dkp_import' получает явный
-- region_code=66. Раньше регион был неявным дефолтом в коде; после параметризации
-- (params.get('region_code', 66)) неявность становится ловушкой — прод-строка должна
-- сама говорить, какой регион она тянет. doc_types в default_params НЕ пишем:
-- дефолт ['ДКП'] в коде и есть текущее поведение, а запись его в расписание
-- создала бы второе место, где надо не забыть поменять.
--
-- Идемпотентна, безопасна к повторному запуску.
BEGIN;
-- #2752: блокирующий DDL не должен вставать в очередь за чужой сессией и уводить
-- за собой запросы приложения — лучше упасть по таймауту и повторить деплой.
SET LOCAL lock_timeout = '5s';
-- A: колонка
ALTER TABLE deals ADD COLUMN IF NOT EXISTS doc_type text;
COMMENT ON COLUMN deals.doc_type IS
'Тип документа сделки Росреестра (ДКП / ДДУ). NULL для источников без этого понятия.';
-- B: бэкфилл — все rosreestr-строки прошли фильтр ДКП на импорте
UPDATE deals
SET doc_type = 'ДКП'
WHERE source = 'rosreestr'
AND doc_type IS NULL;
-- C: явный регион в расписании импорта вместо неявного дефолта в коде
UPDATE scrape_schedules
SET default_params = default_params || '{"region_code": 66}'::jsonb
WHERE source = 'rosreestr_dkp_import';
COMMIT;

View file

@ -0,0 +1,60 @@
-- 289_rosreestr_fdw_msk_columns_seed77.sql
-- gendesign_rosreestr_deals: колонки okato/quarter_cad_number/district + disabled
-- seed-строка scrape_schedules для региона 77 (Москва) — #3051 п.3.
--
-- Dependencies: 072_scrape_schedules_seed_cian_rosreestr.sql (gendesign_rosreestr_deals,
-- scrape_schedules).
-- Apply after: 288_deals_doc_type.sql
--
-- WHY:
-- Трек 2 подготовки Mera к Москве — импорт сделок Росреестра параметризуется по
-- региону (66 Свердловская обл. / 77 Москва, code-часть в scheduler.py). Для
-- региона С canonical_city (Москва) исходные city/okato/quarter_cad_number/district
-- уходят в deals.raw_payload (jsonb), т.к. city источника там — муниципальный
-- округ/поселение, не город, и обычный city/address его не покрывают. Foreign
-- table расширена тремя колонками источника; существование их в
-- public.rosreestr_deals на gendesign-стороне проверено live (2026-09-08, prod psql).
--
-- Seed-строка rosreestr_dkp_import_77 — ВЫКЛЮЧЕНА (enabled=false): миграция
-- только заводит расписание, включение и первый прогон по Москве — отдельное
-- решение main-сессии после ревью кода-части.
--
-- ИДЕМПОТЕНТНОСТЬ: ADD COLUMN IF NOT EXISTS × 3, ON CONFLICT (source) DO NOTHING
-- для seed — повторный прогон no-op.
BEGIN;
SET LOCAL lock_timeout = '5s';
-- ── gendesign_rosreestr_deals: колонки под региональный raw_payload (#3051) ────
-- ALTER FOREIGN TABLE ADD COLUMN — только локальные метаданные (не трогает
-- реальную remote-таблицу), безопасно как ALTER TABLE ADD COLUMN без DEFAULT.
ALTER FOREIGN TABLE gendesign_rosreestr_deals
ADD COLUMN IF NOT EXISTS okato text;
ALTER FOREIGN TABLE gendesign_rosreestr_deals
ADD COLUMN IF NOT EXISTS quarter_cad_number text;
ALTER FOREIGN TABLE gendesign_rosreestr_deals
ADD COLUMN IF NOT EXISTS district text;
-- ── Seed: rosreestr_dkp_import_77 (выключено) ──────────────────────────────────
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
default_params
)
VALUES (
'rosreestr_dkp_import_77',
false,
4,
6,
'{"region_code": 77, "since": "2024-01-01", "batch_size": 2000}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,100 @@
-- 290_frt_mkd_staging.sql
-- АИС ППК «ФРТ» (бывш. Реформа ЖКХ) — реестр МКД, обогащение houses (issue #frt-mkd).
--
-- КОНТЕКСТ: houses.area_land / foundation_type отсутствуют вовсе; elevators_count в
-- источнике — ОБЩЕЕ число лифтов, а в houses раздельно passenger/cargo — отдельная
-- колонка elevators_total, НЕ маппинг на passenger_elevators (иначе задвоение/потеря
-- типа). year_built/material_walls/total_floors/entrances/is_emergency/flat_count/
-- heat_supply_type/gas_supply_type/hot_water уже существуют — этот источник их
-- ДОБИРАЕТ (COALESCE, только NULL), не переопределяет.
--
-- ИСТОЧНИК: https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/110 (node 110 =
-- реестр МКД региона 66), zip → CSV `export-reestrmkd-66-YYYYMMDD.csv`, UTF-8 BOM,
-- разделитель ';', 60 колонок, ~41.8k строк по СО. Проверено живьём 08.09.2026.
--
-- ЧТО НЕ ДЕЛАЕМ (осознанно, см. промпт задачи):
-- * project_type НЕ мапим в houses.series_name — свободный текст, дубль материала
-- стен (топ: пусто, «кирпичный», «нет данных», «панельный», разнобой регистра).
-- * energy_efficiency НЕ добавляем в houses — миграция 284 уже решила НЕ заводить
-- это поле; у ~61% домов значение «Не присвоен».
-- * playground/sportsground — id справочника (498/499/500), НЕ булев флаг; в staging
-- как есть, в houses.has_playground не пишем.
--
-- Идемпотентно: CREATE TABLE/ADD COLUMN IF NOT EXISTS, повторный прогон — no-op.
-- lock_timeout (#2752): ALTER houses берёт ACCESS EXCLUSIVE — SET LOCAL только внутри BEGIN.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS frt_mkd (
houseguid text PRIMARY KEY, -- ФИАС GUID дома (АИС ФРТ houseguid)
region_code smallint NOT NULL, -- код региона выгрузки (66 = Свердловская обл.)
address text,
built_year smallint,
exploitation_start_year smallint,
project_type text, -- сырьё; НЕ мапится в houses (см. шапку)
house_type text,
is_alarm boolean, -- «Да»/«Нет» из источника
floor_count_max smallint,
floor_count_min smallint,
entrance_count smallint,
elevators_count smallint, -- ОБЩЕЕ число лифтов (не passenger/cargo)
energy_efficiency text, -- сырьё; НЕ мапится в houses (мигр. 284)
quarters_count integer,
living_quarters_count integer,
unliving_quarters_count integer,
area_total numeric(12, 2),
area_residential numeric(12, 2),
area_non_residential numeric(12, 2),
area_common_property numeric(12, 2),
area_land numeric(12, 2),
parking_square numeric(12, 2),
playground integer, -- id справочника благоустройства, НЕ булев флаг
sportsground integer, -- id справочника благоустройства, НЕ булев флаг
other_beautification text,
foundation_type text,
floor_type text,
wall_material text,
basement_area numeric(12, 2),
chute_type text,
chute_count smallint,
heating_type text,
hot_water_type text,
cold_water_type text,
sewerage_type text,
gas_type text,
ventilation_type text,
firefighting_type text,
drainage_type text,
management_organization_id bigint,
method_of_forming_overhaul_fund text,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE frt_mkd IS
'АИС ППК ФРТ (бывш. Реформа ЖКХ) — реестр МКД, open data export/110, region 66. '
'Ключ houseguid (ФИАС GUID). Заполняется app/services/frt_mkd_loader.py, '
'мигр. 290. Матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
'zhkh_house_guid).';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS area_land numeric(12, 2);
COMMENT ON COLUMN houses.area_land IS
'мигр. 290: площадь земельного участка, м2, источник АИС ППК ФРТ (frt_mkd.area_land).';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS foundation_type text;
COMMENT ON COLUMN houses.foundation_type IS
'мигр. 290: тип фундамента, источник АИС ППК ФРТ (frt_mkd.foundation_type).';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS elevators_total integer;
COMMENT ON COLUMN houses.elevators_total IS
'мигр. 290: ОБЩЕЕ число лифтов (без разбивки passenger/cargo), источник АИС ППК ФРТ '
'(frt_mkd.elevators_count). НЕ путать с passenger_elevators/cargo_elevators — те '
'раздельные и из других источников.';
ALTER TABLE houses ADD COLUMN IF NOT EXISTS frt_matched_at timestamptz;
COMMENT ON COLUMN houses.frt_matched_at IS
'мигр. 290: момент, когда backfill_houses() из frt_mkd_loader.py нашёл матч по '
'houseguid для этого дома (проставляется один раз, даже если полей для дозаполнения '
'уже не осталось).';
COMMIT;

View file

@ -0,0 +1,37 @@
-- 291_scrape_schedules_seed_frt_mkd_load.sql
-- Расписание для frt_mkd_load (АИС ППК ФРТ, реестр МКД region 66, issue #frt-mkd).
--
-- Источник обновляется нерегулярно (реестр МКД, не суточный поток) — interval_days=30
-- по аналогии с ДОМ.РФ капремонт (мигр. 176/... каденция месячная). node_id/region_code
-- в default_params — параметры handler'а (app/services/product_handlers.py
-- _job_frt_mkd_load), сам loader их же принимает как аргументы load_frt_mkd().
--
-- ENABLED=false — как у domrf_kapremont_load и domclick_detail_backfill: включение
-- отдельным осознанным шагом после деплоя и дымовой пробы (ZIP 3.2 МБ / CSV 41 МБ —
-- смотрим таймауты и нагрузку на первом ручном прогоне, не в фоне).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290_frt_mkd_staging.sql.
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'frt_mkd_load',
false,
2,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"node_id": 110, "region_code": 66, "interval_days": 30}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,87 @@
-- 292_cbr_macro_series.sql
-- Макро-ряды ЦБ РФ: ипотека по субъектам + ключевая ставка.
--
-- Context:
-- ЦБ РФ публикует месячную статистику по ипотечным жилищным кредитам (объём выдач,
-- ставки, задолженность) в разрезе субъектов РФ — три XLSX-дашборда на cbr.ru
-- (см. app/services/cbr_macro.py::CBR_MORTGAGE_SERIES) — и дневную ключевую ставку
-- через SOAP-сервис DailyInfoWebServ. Обе серии нужны как макро-контекст для
-- продукта (региональная динамика ипотечного рынка); загрузку в estimator этот
-- PR не делает — только данные и их сбор (app/tasks/cbr_macro_pull.py).
--
-- Schema notes:
-- cbr_mortgage_series
-- * region — территория как в источнике (РФ целиком / федеральный округ /
-- субъект), без нормализации кодов ОКАТО.
-- * period_month — первое число отчётного месяца (шапка XLSX «Месяц Год»).
-- * series — slug серии (rate_rub / new_loans_rub / debt_rub, конфиг —
-- CBR_MORTGAGE_SERIES в коде).
-- * PRIMARY KEY (region, period_month, series) — одно значение на территорию×месяц×серию.
-- * fetched_at — «когда мы ВПЕРВЫЕ увидели этот период» (не время последней
-- загрузки — источник даёт всю серию целиком, отсечки по
-- периоду нет). Урок #2846 у sber_price_index: НЕ обновлять
-- fetched_at в ON CONFLICT DO UPDATE, иначе один прогон
-- перезаписывает метку у всех периодов, включая 2019-01.
-- cbr_key_rate
-- * rate_date — дата действия ключевой ставки (PRIMARY KEY).
-- * rate — значение ставки, % годовых.
-- * fetched_at — по той же логике, что и выше: не трогаем в DO UPDATE.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS — безопасно переприменять. Только ADDITIVE.
--
-- Dependencies: нет (самостоятельные таблицы).
-- Deploy order: применить до app/services/cbr_macro.py + app/tasks/cbr_macro_pull.py
-- + миграции 291 (сид scrape_schedules).
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS cbr_mortgage_series (
region text NOT NULL,
period_month date NOT NULL,
series text NOT NULL,
value double precision NOT NULL,
source text NOT NULL DEFAULT 'cbr',
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (region, period_month, series)
);
COMMENT ON TABLE cbr_mortgage_series IS
'Месячные ряды ипотечной статистики ЦБ РФ по территориям (РФ / ФО / субъекты): '
'объём выдач, ставки, задолженность. Источник — XLSX-дашборды cbr.ru/vfs/statistics/'
'BankSector/Mortgage/. Загружается app/tasks/cbr_macro_pull.py через in-app '
'планировщик (scrape_schedules.source=''cbr_macro_pull'').';
COMMENT ON COLUMN cbr_mortgage_series.region IS
'Территория как в источнике (например «РОССИЙСКАЯ ФЕДЕРАЦИЯ», «ЦЕНТРАЛЬНЫЙ '
'ФЕДЕРАЛЬНЫЙ ОКРУГ», «Свердловская область»). Без нормализации ОКАТО/ISO.';
COMMENT ON COLUMN cbr_mortgage_series.period_month IS
'Первое число отчётного месяца (нормализовано из шапки «Месяц Год» XLSX).';
COMMENT ON COLUMN cbr_mortgage_series.series IS
'Slug серии, см. CBR_MORTGAGE_SERIES в app/services/cbr_macro.py '
'(rate_rub, new_loans_rub, debt_rub).';
COMMENT ON COLUMN cbr_mortgage_series.value IS
'Значение серии в единицах источника (% годовых для ставки, млн руб для объёмов).';
COMMENT ON COLUMN cbr_mortgage_series.fetched_at IS
'Когда мы ВПЕРВЫЕ увидели этот период (не время последней загрузки). '
'НЕ обновляется в ON CONFLICT DO UPDATE — см. урок #2846 у sber_price_index.';
CREATE INDEX IF NOT EXISTS idx_cbr_mortgage_series_region_series_period
ON cbr_mortgage_series (region, series, period_month DESC);
CREATE TABLE IF NOT EXISTS cbr_key_rate (
rate_date date PRIMARY KEY,
rate double precision NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE cbr_key_rate IS
'Ключевая ставка ЦБ РФ по датам действия. Источник — SOAP-метод KeyRate '
'сервиса https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx. Загружается '
'app/tasks/cbr_macro_pull.py.';
COMMENT ON COLUMN cbr_key_rate.rate_date IS 'Дата, с которой действует значение ставки.';
COMMENT ON COLUMN cbr_key_rate.rate IS 'Ключевая ставка, % годовых.';
COMMENT ON COLUMN cbr_key_rate.fetched_at IS
'Когда мы ВПЕРВЫЕ увидели эту дату. НЕ обновляется в ON CONFLICT DO UPDATE.';
COMMIT;

View file

@ -0,0 +1,41 @@
-- 293_scrape_schedules_seed_cbr_macro_pull.sql
-- Сид расписания для нового продуктового источника cbr_macro_pull (ипотека по
-- субъектам + ключевая ставка ЦБ РФ, см. 292_cbr_macro_series.sql).
--
-- Такт: interval_days=7 — источник обновляет XLSX-дашборды ипотеки раз в месяц,
-- ключевую ставку — по решениям Совета директоров (раз в 6 недель), недельный
-- такт достаточен и не создаёт нагрузки (три XLSX ~800 КБ суммарно + один SOAP-запрос).
--
-- Окно 07:0008:00 UTC — свободно на момент миграции (проверены соседние сиды:
-- 180 sber_freshness_monitor 09-10, 275 landing_stats_refresh 05-06,
-- 289 rosreestr_dkp_import_77 04-06); до утра по Екатеринбургу (UTC+5 → 12:00).
--
-- ENABLED=false — как и у прочих новых источников (домрф/sber): включение —
-- отдельный осознанный шаг после деплоя и дымовой пробы.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290 (таблицы данных).
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
BEGIN;
SET LOCAL lock_timeout = '5s';
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'cbr_macro_pull',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"interval_days": 7}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,79 @@
-- 294_dtp_incidents.sql
-- ДТП (dtp-stat.ru, первоисточник — открытые данные ГИБДД), Свердловская обл. (#3410).
--
-- WHY: слой ДТП нужен как радиусный "фактор безопасности" района (по образцу
-- osm_poi_ekb_local / 169_osm_poi_ekb_local.sql — та же архитектура: пустая таблица +
-- индексы на деплое, наполнение отдельным рефреш-джобом, никакого чтения внешнего
-- источника при прогоне миграций).
--
-- ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — открытый
-- агрегатор данных ГИБДД, без auth. Дамп заморожен (Last-Modified 26.02.2026 на момент
-- проверки 08.09.2026) — см. докстринг app/services/dtp_stat_loader.py, пустая дельта на
-- рефреше это НЕ баг, а факт источника.
--
-- ПДн (важно): properties.vehicles[].participants[] источника содержит пол/роль/нарушения
-- физлиц-участников ДТП. Эта таблица их СОЗНАТЕЛЬНО НЕ хранит — ни колонкой, ни в jsonb.
-- Загрузчик (dtp_stat_loader.py) участников/транспорт из парсинга просто не извлекает.
-- Берём только агрегаты (dead/injured count) и не-персональные атрибуты происшествия.
--
-- ИНДЕКСЫ (урок 270_houses_geog_gist_idx.sql / 134_listings_geom_geography_gist.sql): весь
-- код МЕРЫ ходит радиусными запросами через ST_DWithin(geom::geography, …) — обычный GIST
-- по geometry планировщик под такой предикат не берёт (прод-замер houses: 70.7 мс на
-- bitmap-фильтр всех строк; после функционального индекса по geography — 38 мс на listings).
-- Поэтому сразу оба индекса: geometry (на будущее/для прочих операторов) и функциональный
-- по (geom::geography) — под фактический предикат dtp_index.py.
--
-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. Безопасно перезапускать.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS dtp_incidents (
id bigserial PRIMARY KEY,
source_id text UNIQUE,
dtp_at timestamptz,
category text,
severity text,
dead integer,
injured integer,
address text,
light text,
weather text[],
nearby text[],
lat double precision,
lon double precision,
geom geometry(Point, 4326),
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE dtp_incidents IS
'ДТП по Свердловской обл. (dtp-stat.ru, первоисточник — открытые данные ГИБДД, #3410). '
'Пишет app/tasks/dtp_stat_refresh.py (TRUNCATE+INSERT, scheduler source=dtp_stat_refresh, '
'seed 293, enabled=false — включение вручную). Читает app/services/dtp_index.py — '
'радиусные агрегаты (число ДТП/тяжёлых/погибших/раненых) для карточки района. '
'ПДн участников (vehicles/participants: пол, роль, нарушения физлиц) осознанно НЕ '
'хранятся — ни колонкой, ни в jsonb; источник даёт их построчно на транспорт/участника, '
'загрузчик их не извлекает вовсе.';
COMMENT ON COLUMN dtp_incidents.source_id IS 'properties.id из dtp-stat GeoJSON — ключ идемпотентности (UNIQUE).';
COMMENT ON COLUMN dtp_incidents.dtp_at IS 'properties.datetime, распарсено в timestamptz.';
COMMENT ON COLUMN dtp_incidents.category IS 'properties.category — тип ДТП ("Столкновение" и т.п.).';
COMMENT ON COLUMN dtp_incidents.severity IS 'properties.severity — тяжесть ("Тяжёлый"/"Лёгкий"/…).';
COMMENT ON COLUMN dtp_incidents.dead IS 'properties.dead_count — погибшие, агрегат, без ФИО/пола участников.';
COMMENT ON COLUMN dtp_incidents.injured IS 'properties.injured_count — раненые, агрегат.';
COMMENT ON COLUMN dtp_incidents.weather IS 'properties.weather — список погодных условий на момент ДТП.';
COMMENT ON COLUMN dtp_incidents.nearby IS 'properties.nearby — список объектов УДС/притяжения рядом с местом ДТП.';
COMMENT ON COLUMN dtp_incidents.geom IS 'ST_SetSRID(ST_MakePoint(lon, lat), 4326) — строится явно в загрузчике, без триггера (как osm_poi_ekb_local).';
-- GIST по geometry — на будущее / прочие geometry-операторы.
CREATE INDEX IF NOT EXISTS dtp_incidents_geom_idx
ON dtp_incidents USING GIST (geom);
-- Функциональный GIST по (geom::geography) — под фактический предикат
-- ST_DWithin(geom::geography, …) в app/services/dtp_index.py (см. WHY выше).
CREATE INDEX IF NOT EXISTS dtp_incidents_geog_idx
ON dtp_incidents USING GIST ((geom::geography));
CREATE INDEX IF NOT EXISTS dtp_incidents_dtp_at_idx
ON dtp_incidents (dtp_at);
COMMIT;

View file

@ -0,0 +1,52 @@
-- 295_scrape_schedules_seed_dtp_stat_refresh.sql
-- Scheduler seed for the ДТП local-mirror refresh job (#3410).
--
-- WHAT (source='dtp_stat_refresh'):
-- trigger_dtp_stat_refresh_run (scheduler.py) → run_dtp_stat_refresh
-- (app/tasks/dtp_stat_refresh.py) → app/services/dtp_stat_loader.py: download ZIP
-- (dtp-stat.ru), потоковый парс GeoJSON, TRUNCATE dtp_incidents; INSERT — построчно
-- строим geom из lon/lat явно (ST_SetSRID(ST_MakePoint(...))).
--
-- ПОЧЕМУ enabled=false (по образцу house_dedup_merge, seed 135): источник — открытый
-- годовой дамп, а не живой поток; на момент проверки 08.09.2026 Last-Modified
-- 26.02.2026 — дельта между рефрешами пустая по построению, пустая дельта НЕ баг
-- (см. докстринг app/services/dtp_stat_loader.py). Первый прогон — вручную/через
-- orchestrator, включение регулярного расписания — отдельное решение продукта.
--
-- interval_days=30 (default_params, НЕ отдельная колонка — compute_next_run_at читает
-- именно default_params.interval_days, ровно как в 135/212): месячный дамп двигается
-- редко, ежемесячная проверка — с запасом.
--
-- Window 11:00-12:00 UTC (14:00-15:00 МСК): после osm_poi_ekb_refresh (10:00-11:00
-- UTC, 170), не пересекает другие DB-only/сетевые джобы. Внешний HTTP на dtp-stat.ru,
-- но разовый один ZIP-запрос ~5 МБ — не конкурирует за прокси-пул (обычные площадки
-- не трогает).
--
-- next_run_at = завтра 11:00 UTC (NOT NULL — конвенция сидов; безвредно при
-- enabled=false, т.к. get_due_schedules сначала фильтрует enabled=true).
--
-- DEPENDENCIES: 052_scrape_schedules.sql (table + UNIQUE(source)), 294_dtp_incidents.sql.
-- Idempotent: ON CONFLICT (source) DO NOTHING.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'dtp_stat_refresh',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"interval_days": 30}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,71 @@
-- 296_fns_legal_entity_facts.sql
-- ФНС open data по юрлицам: доходы/расходы, среднесписочная численность, недоимка,
-- спецрежимы (issue: bulk-дампы ФНС → lookup по ИНН, PR "feat/fns-legal-entities").
--
-- ПОЧЕМУ ЭТОТ ИСТОЧНИК
-- Открытых данных ЕГРН по правообладателям-физлицам не существует (218-ФЗ ст. 62).
-- По ЮРЛИЦАМ данные открыты, лицензия ФНС (nalog.gov.ru/opendata) разрешает
-- переработку и перераспределение. Наборы (все по маске 7707329152-<slug>):
-- revexp (доходы и расходы), sshr2019 (среднесписочная численность), debtam
-- (недоимка и задолженность), snr (спецрежимы). ЕГРЮЛ (адрес/ОКВЭД/учредители) в
-- этих наборах НЕТ — только ИНН + наименование + показатели набора.
--
-- ЭТОТ PR — ТОЛЬКО ЗАГРУЗКА И LOOKUP ПО ИНН. Потребителя (скоринг застройщика/УК в
-- оценке) здесь нет и не планируется в рамках этого PR — подключение отдельной
-- задачей, если решение об этом будет принято отдельно.
--
-- ФОРМА ХРАНЕНИЯ: одна узкая long-format таблица на все 4 набора, а не таблица на
-- набор. Наборы различаются только НАБОРОМ показателей (атрибутов XML), сама форма
-- «ИНН + показатель + период + значение» у всех одинаковая — long-format не плодит
-- 4 почти идентичные таблицы и не требует миграции при добавлении 5-го набора в
-- будущем (не в этом PR).
--
-- PRIMARY KEY (inn, dataset, series, period), А НЕ (inn, series, period) — так
-- предлагал скелет задачи, но series здесь = имя XML-атрибута источника, и без
-- dataset в ключе одноимённые атрибуты в двух разных наборах молча схлопнутся в
-- одну строку (мы НЕ проверяли по XSD, что имена атрибутов между revexp/sshr2019/
-- debtam/snr не пересекаются, поэтому закладываемся на худший случай).
--
-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. lock_timeout — на случай, если
-- таблицы уже существуют от прогона на другой ветке (ALTER не планируется, но
-- дисциплина сохраняется).
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS fns_legal_entity_facts (
inn text NOT NULL,
dataset text NOT NULL, -- 'revexp' | 'sshr2019' | 'debtam' | 'snr'
series text NOT NULL, -- имя показателя (атрибут XML источника)
period date NOT NULL, -- период показателя; см. fns_opendata_loader.py
value numeric NOT NULL,
org_name text, -- наименование юрлица на момент загрузки (не ЕГРЮЛ, provenance)
loaded_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (inn, dataset, series, period)
);
COMMENT ON TABLE fns_legal_entity_facts IS
'ФНС opendata (nalog.gov.ru/opendata/7707329152-*) по юрлицам: показатели наборов '
'revexp/sshr2019/debtam/snr. Long-format: одна строка = один показатель одного ИНН '
'за один период. ЕГРЮЛ-данных (адрес/ОКВЭД/учредители) здесь нет. Без потребителя '
'на момент создания — см. app/services/fns_lookup.py.';
COMMENT ON COLUMN fns_legal_entity_facts.inn IS 'ИНН юрлица (10 цифр), как в XML источника';
COMMENT ON COLUMN fns_legal_entity_facts.dataset IS 'slug набора ФНС opendata: revexp/sshr2019/debtam/snr';
COMMENT ON COLUMN fns_legal_entity_facts.series IS 'имя показателя = имя XML-атрибута источника (не переименовываем)';
COMMENT ON COLUMN fns_legal_entity_facts.period IS 'период показателя: дата публикации набора (см. fns_dataset_versions.published_on)';
COMMENT ON COLUMN fns_legal_entity_facts.value IS 'значение показателя как есть из XML (numeric — источник не различает валюту/единицы в атрибуте)';
COMMENT ON COLUMN fns_legal_entity_facts.org_name IS 'наименование юрлица на момент загрузки, provenance only — НЕ авторитетный источник (используй ЕГРЮЛ)';
CREATE TABLE IF NOT EXISTS fns_dataset_versions (
dataset text PRIMARY KEY, -- 'revexp' | 'sshr2019' | 'debtam' | 'snr'
file_url text NOT NULL, -- прямая ссылка на .zip, снятая со страницы каталога
published_on date, -- дата публикации, распарсенная из имени файла (data-YYYYMMDD-...)
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE fns_dataset_versions IS
'Последняя загруженная версия каждого набора ФНС opendata — гейт от перекачки '
'уже загруженной версии (URL меняется с каждой публикацией, дата в имени файла).';
COMMENT ON COLUMN fns_dataset_versions.file_url IS 'резолвится динамически со страницы каталога набора — НЕ хардкодить, протухает';
COMMENT ON COLUMN fns_dataset_versions.published_on IS 'дата публикации из имени файла (data-YYYYMMDD-structure-...)';
COMMIT;

View file

@ -0,0 +1,36 @@
-- 297_scrape_schedules_seed_fns_opendata_load.sql
-- Расписание для fns_opendata_load (см. 296_fns_legal_entity_facts.sql — контекст источника).
--
-- enabled=false: как domclick_detail_backfill (мигр. 175) — включение отдельным
-- осознанным шагом после деплоя, дымовой пробы и ручного --dry-run прогона. Наборы
-- ФНС весят десятки-сотни МБ каждый, первый прогон надо смотреть глазами.
--
-- interval_days=30: наборы ФНС публикуются раз в квартал/год (revexp — по итогам
-- отчётного периода), суточный/недельный опрос каталога бессмысленно частый.
-- Реализовано через default_params (interval_minutes читает post_claim
-- reschedule_after_minutes, здесь используем ту же схему в днях: 30 * 24 * 60).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 294 (таблицы факта).
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'fns_opendata_load',
false,
2,
5,
((CURRENT_DATE + INTERVAL '1 day')) AT TIME ZONE 'UTC',
'{"interval_minutes": 43200, "datasets": ["revexp", "sshr2019", "debtam", "snr"]}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,170 @@
-- 298_deal_city_price_bands_region.sql
-- deal_city_price_bands: ключ (region_code, city) вместо (city) — #3051, sub-PR B.
--
-- Dependencies: 178_deal_city_price_bands.sql (таблица, PK(city)), 194_deal_city_price_bands_tiers.sql
-- (tier-схема, derivation), 288_deals_doc_type.sql (deals.doc_type).
-- Apply after: 289_rosreestr_fdw_msk_columns_seed77.sql
--
-- WHY:
-- В deals уже 212 937 строк region_code=77 city='Москва' рядом со 108 623
-- строками region_code=66 (Свердловская обл.) — Москва в той же таблице
-- `deals`, что и область. Старая derivation (178/194) ключует бэнды ТОЛЬКО
-- по city и считает region_stats (пул для tier='region_fallback') ПО ВСЕЙ
-- таблице deals без фильтра региона. С двумя регионами в одной таблице это
-- ломает две вещи одновременно:
-- 1. city='Москва' коллизирует c одноимёнными city в других регионах
-- (маловероятно для этой пары, но ключ (city) в принципе не region-safe).
-- 2. region_stats-пул для 'region_fallback' смешивает 212k строк Москвы в
-- p1-перцентиль floor для тонких городов Свердловской обл. — Москва на
-- порядок дороже, пул сдвигается вверх, floor малых городов области
-- задирается выше их реального рынка.
--
-- WHAT:
-- - deal_city_price_bands.region_code int NOT NULL DEFAULT 66 — бэкфилл
-- существующих 383 строк на 66 (таблица до этой миграции была исключительно
-- Свердловская обл., см. 178/194 WHERE-фильтры без region_code).
-- - PK (city) → PK (region_code, city) — через DO-guard по pg_get_constraintdef,
-- идемпотентно (см. ниже).
-- - Re-seed той же derivation, что 194/refresh-таск, НО region_stats и
-- city_stats группируются по (region_code[, city]) — Москва больше не
-- утекает в пул малых городов области, и наоборот.
--
-- БАЙТ-ИДЕНТИЧНОСТЬ ДЛЯ region_code=66:
-- - Фильтр `NOT (region_code = 66 AND city = 'Екатеринбург')` — тот же
-- инвариант, что `city <> 'Екатеринбург'` в 178/194, ограниченный явно на
-- регион 66 (ЕКБ существует только там).
-- - `doc_type = 'ДКП'` — НЕ сужает выборку region_code=66: backfill 288
-- проставил doc_type='ДКП' на 100% строк source='rosreestr' (на момент
-- миграции 288 других doc_type для source='rosreestr' не было и не могло
-- быть — импорт всегда фильтровал ДКП на входе).
-- - `region_stats` теперь GROUP BY region_code — для region_code=66 пул p1
-- считается ИСКЛЮЧИТЕЛЬНО по строкам региона 66 (та же популяция, что была
-- у 194 ДО появления в deals региона 77), т.е. Москва (region_code=77)
-- физически не участвует в агрегате region_stats-строки region_code=66.
-- - `city_stats` теперь GROUP BY region_code, city — для региона 66 разбивка
-- по городам не меняется (раньше GROUP BY city неявно уже был per-region,
-- т.к. в deals была только область).
-- - Клампы (8000/800000/700000), tier-пороги (30/10) и p1/p99-перцентили —
-- формулы дословно как в 194/refresh, только с добавленным region_code в
-- SELECT/GROUP BY/JOIN.
-- - `tiered` JOIN region_stats теперь по region_code (вместо CROSS JOIN на
-- единственную строку) — для региона 66 эквивалентно прежнему CROSS JOIN,
-- т.к. region_stats содержит ровно одну строку на регион.
--
-- ИДЕМПОТЕНТНОСТЬ:
-- ADD COLUMN IF NOT EXISTS; PK-guard проверяет ТЕКУЩЕЕ определение PK через
-- pg_get_constraintdef и меняет его только если оно ещё (city) — повторный
-- прогон видит PK(region_code, city) и не трогает его. INSERT ... ON CONFLICT
-- (region_code, city) DO UPDATE — повторный прогон рефрешит бэнды под свежие
-- сделки (та же семантика 178/194).
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE deal_city_price_bands
ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66;
COMMENT ON COLUMN deal_city_price_bands.region_code IS
'Регион ключа бэнда (deals.region_code). 66 = Свердловская обл. (Екатеринбург '
'намеренно исключён из таблицы для ЭТОГО региона — estimator fallback на '
'глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2). Часть составного PK (region_code, city).';
-- PK (city) → PK (region_code, city), идемпотентно: смотрим ТЕКУЩЕЕ определение
-- PK и меняем его только если это ещё старый PK(city).
DO $$
DECLARE
v_pk_def text;
BEGIN
SELECT pg_get_constraintdef(oid) INTO v_pk_def
FROM pg_constraint
WHERE conrelid = 'deal_city_price_bands'::regclass
AND contype = 'p';
IF v_pk_def = 'PRIMARY KEY (city)' THEN
ALTER TABLE deal_city_price_bands DROP CONSTRAINT deal_city_price_bands_pkey;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'deal_city_price_bands'::regclass
AND contype = 'p'
) THEN
ALTER TABLE deal_city_price_bands
ADD CONSTRAINT deal_city_price_bands_pkey PRIMARY KEY (region_code, city);
END IF;
END $$;
-- Страховка: если region_code=66 город='Москва' когда-либо просочился (не
-- должен — таблица была исключительно Свердловской обл.), убираем перед re-seed,
-- чтобы ON CONFLICT ниже не унаследовал мусорную строку под чужим regionʼом.
DELETE FROM deal_city_price_bands WHERE region_code = 66 AND city = 'Москва';
-- Re-seed — та же derivation, что 194/refresh-таск, региональная (см. WHY/WHAT).
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
FROM deals
WHERE source = 'rosreestr'
AND doc_type = 'ДКП'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND region_code IS NOT NULL
AND NOT (region_code = 66 AND city = 'Екатеринбург')
GROUP BY region_code
),
city_stats AS (
SELECT
region_code,
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)
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
AND region_code IS NOT NULL
AND NOT (region_code = 66 AND city = 'Екатеринбург')
GROUP BY region_code, city
),
tiered AS (
SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
'full'::text AS tier
FROM city_stats
WHERE n_deals >= 30
AND 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
UNION ALL
SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 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
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands (region_code, city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT region_code, city, ppm2_min, ppm2_max, n_deals, tier, now()
FROM tiered
ON CONFLICT (region_code, city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
tier = EXCLUDED.tier,
refreshed_at = EXCLUDED.refreshed_at;
COMMIT;

View file

@ -31,6 +31,9 @@ dependencies = [
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
"openpyxl>=3.1.0", # ЦБ РФ XLSX-дашборды ипотеки (app/services/cbr_macro.py)
"ijson>=3.3.0", # потоковый парс ~71 МБ GeoJSON dtp-stat.ru —
# без json.load() целиком в память
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
# Docker-context = tradein-mvp root, uv sync ставит editable.

View file

@ -1233,14 +1233,19 @@ _CANDIDATES_SQL = text(
)
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
# never issues this query.
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
# _resolve_city_ppm2_band); the default (city=None) path never issues this
# query. region_code defaults to 66 (Свердловская обл.) — this script has no
# region CLI flag yet, so every call is scoped to the oblast, matching every
# existing --city invocation (byte-identical to the pre-298 unscoped lookup,
# which only ever saw region-66 rows since the table was oblast-only before).
_CITY_PPM2_BAND_SQL = text(
"""
SELECT ppm2_min, ppm2_max
FROM deal_city_price_bands
WHERE city = CAST(:city AS text)
WHERE region_code = CAST(:region_code AS int)
AND city = CAST(:city AS text)
"""
)
@ -1294,22 +1299,32 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
)
def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]:
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
def _resolve_city_ppm2_band(
db: Session, city: str | None, region_code: int = 66
) -> tuple[float, float]:
"""Per-(region, city) PPM2 sanity band (oblast D, #3051) — falls back to module globals.
``city is None`` ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a
materially different sane / range than the EKB-tuned 30k..600k globals
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
Екатеринбург intentionally excluded from the table, mirrors
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
the globals; read-only best-effort, never raises.
(region_code, city)'s own ``[ppm2_min, ppm2_max]`` — a region-66 town can
have a materially different sane / range than the EKB-tuned 30k..600k
globals (e.g. Нижний Тагил: 16 955..108 175). No row for the (region, city)
(e.g. Екатеринбург region_code=66 intentionally excluded from the table,
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
error the globals; read-only best-effort, never raises.
``region_code`` defaults to 66 (Свердловская обл.) this script has no
region CLI flag yet (out of scope, #3051 sub-PR B); every existing
--city caller keeps its byte-identical lookup.
"""
if city is None:
return float(PPM2_MIN), float(PPM2_MAX)
try:
row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first()
row = (
db.execute(_CITY_PPM2_BAND_SQL, {"city": city, "region_code": region_code})
.mappings()
.first()
)
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
return float(PPM2_MIN), float(PPM2_MAX)

View file

@ -80,6 +80,12 @@ Flags:
plus the geocode hit/miss split. No DB writes.
--batch LABEL log label (default `deals_nominatim_YYYY-MM-DD`).
--stale-days N retry addresses last tried more than N days ago (default 30).
--region-code N which `deals.region_code` to geocode (default 66, Sverdlovsk
oblast). #3051: 77 (Москва) — every deal there has
address='Москва, <street>' with no city already recognised by
`known_city_hint`'s region-66 default, so this flag threads
through both the SQL filter (`AND region_code = N`) and
`geocoder.{geocode,known_city_hint}(..., region_code=N)`.
"""
from __future__ import annotations
@ -182,7 +188,9 @@ class Stats:
# ---------------------------------------------------------------------------
def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> list[AddressGroup]:
def _select_pending_addresses(
db: Session, *, limit: int, stale_days: int, region_code: int = 66
) -> list[AddressGroup]:
"""Distinct (address, city) pairs still needing coords — resume-safe set.
Combines the `deals_geocode_pending_idx` partial index predicate
@ -215,6 +223,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
" WHERE lat IS NULL "
" AND address IS NOT NULL "
" AND length(trim(address)) >= 3 "
" AND region_code = CAST(:region_code AS int) "
" AND (geocode_tried_at IS NULL "
" OR geocode_tried_at < NOW() "
" - make_interval(days => CAST(:stale_days AS int))) "
@ -223,7 +232,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
"WHERE running_rows - deals_count < CAST(:limit AS int) "
"ORDER BY deals_count DESC, address ASC, city ASC NULLS FIRST"
),
{"limit": limit, "stale_days": stale_days},
{"limit": limit, "stale_days": stale_days, "region_code": region_code},
)
.mappings()
.all()
@ -238,7 +247,7 @@ def _select_pending_addresses(db: Session, *, limit: int, stale_days: int) -> li
]
def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
def _count_pending_total(db: Session, *, stale_days: int, region_code: int = 66) -> tuple[int, int]:
"""Full backlog: (distinct (address, city) pairs, total rows) this pass.
Denominators for the dry-run projection counts every lat-IS-NULL deal
@ -257,11 +266,12 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
"WHERE lat IS NULL "
" AND address IS NOT NULL "
" AND length(trim(address)) >= 3 "
" AND region_code = CAST(:region_code AS int) "
" AND (geocode_tried_at IS NULL "
" OR geocode_tried_at < NOW() "
" - make_interval(days => CAST(:stale_days AS int)))"
),
{"stale_days": stale_days},
{"stale_days": stale_days, "region_code": region_code},
).first()
if row is None:
return (0, 0)
@ -274,9 +284,15 @@ def _count_pending_total(db: Session, *, stale_days: int) -> tuple[int, int]:
def _update_deals_geocoded(
db: Session, *, address: str, city: str | None, lat: float, lon: float
db: Session,
*,
address: str,
city: str | None,
lat: float,
lon: float,
region_code: int = 66,
) -> int:
"""UPDATE every lat-IS-NULL deal on (address, city); geom via trigger.
"""UPDATE every lat-IS-NULL deal on (address, city, region_code); geom via trigger.
The `deals_set_geom_trg` BEFORE UPDATE OF lat, lon trigger
(002_core_tables.sql, reuses listings_set_geom()) populates geom from the
@ -290,6 +306,12 @@ def _update_deals_geocoded(
FROM` treats NULL=NULL as a match while staying strict for a real city, so
the same street text in another city keeps its own coords (#2603).
`region_code` (#3051) — an extra guard on top of (address, city): the same
street text can legitimately exist in two different regions (e.g. a
"Ленина" in both oblast 66 and Moscow's region-77 corpus), so a run scoped
to one region must never touch the other's rows even if address/city
happen to collide.
Returns the number of deal rows updated.
"""
result = db.execute(
@ -300,14 +322,15 @@ def _update_deals_geocoded(
" geocode_tried_at = NOW() "
" WHERE address = CAST(:addr AS text) "
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
" AND region_code = CAST(:region_code AS int) "
" AND lat IS NULL"
),
{"addr": address, "city": city, "lat": lat, "lon": lon},
{"addr": address, "city": city, "lat": lat, "lon": lon, "region_code": region_code},
)
return result.rowcount
def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
def _mark_deals_tried(db: Session, *, address: str, city: str | None, region_code: int = 66) -> int:
"""Stamp `geocode_tried_at = NOW()` WITHOUT touching lat/lon (geocode miss).
Critical for resume: an address the geocoder can't resolve must still drop
@ -316,8 +339,9 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
it for `stale_days`. The `AND lat IS NULL` guard means a concurrent success
can't be downgraded.
Scoped to the (address, city) pair for the same reason as the coords write
a miss in one city must not defer the other city's retry (#2603).
Scoped to the (address, city, region_code) pair for the same reason as the
coords write a miss in one city/region must not defer another
region's retry (#2603, #3051).
Returns the number of deal rows stamped.
"""
@ -327,9 +351,10 @@ def _mark_deals_tried(db: Session, *, address: str, city: str | None) -> int:
" SET geocode_tried_at = NOW() "
" WHERE address = CAST(:addr AS text) "
" AND city IS NOT DISTINCT FROM CAST(:city AS text) "
" AND region_code = CAST(:region_code AS int) "
" AND lat IS NULL"
),
{"addr": address, "city": city},
{"addr": address, "city": city, "region_code": region_code},
)
return result.rowcount
@ -345,6 +370,7 @@ async def _run_backfill(
*,
batch: str,
dry_run: bool,
region_code: int = 66,
) -> Stats:
"""For each (address, city) pair: geocode once, then UPDATE all its deals.
@ -357,17 +383,21 @@ async def _run_backfill(
retry/backoff), so we don't add our own sleep here — that would double the
walltime. A geocode that raises is treated as a failure for THIS run but is
NOT stamped (left for the next pass to retry sooner than a clean miss).
`region_code` (#3051, default 66) threads into `known_city_hint`/`geocode`
(region's own city dictionary + bbox/viewbox) and into the two DB writers
(extra WHERE guard, matches `_select_pending_addresses`'s filter).
"""
stats = Stats()
for i, group in enumerate(groups, start=1):
address = group.address
city = group.city
# Only a recognised oblast-66 city is fed to the geocoder; junk Rosreestr
# values degrade to None (geocoder.known_city_hint — shared with the
# other DB-column callers). The raw `city` is still used for the UPDATE
# scope — it identifies the group either way.
hint = known_city_hint(city)
# Only a recognised city of `region_code` is fed to the geocoder; junk
# Rosreestr values degrade to None (geocoder.known_city_hint — shared
# with the other DB-column callers). The raw `city` is still used for
# the UPDATE scope — it identifies the group either way.
hint = known_city_hint(city, region_code)
# The geocoder itself rejects <3 chars, but skip here too so the dry-run
# report and counters stay honest (no phantom "processed" address).
@ -377,7 +407,7 @@ async def _run_backfill(
result: GeocodeResult | None = None
try:
result = await geocode(address, db, city_hint=hint)
result = await geocode(address, db, city_hint=hint, region_code=region_code)
except Exception as exc: # defensive — one geocode error must not kill batch
stats.geocode_failed += 1
stats.processed += 1
@ -407,7 +437,7 @@ async def _run_backfill(
else:
try:
with db.begin_nested():
_mark_deals_tried(db, address=address, city=city)
_mark_deals_tried(db, address=address, city=city, region_code=region_code)
db.commit()
except Exception as exc: # defensive — isolate one bad UPDATE
db.rollback()
@ -441,7 +471,12 @@ async def _run_backfill(
try:
with db.begin_nested():
n = _update_deals_geocoded(
db, address=address, city=city, lat=result.lat, lon=result.lon
db,
address=address,
city=city,
lat=result.lat,
lon=result.lon,
region_code=region_code,
)
# Per-address commit so resume picks up exactly where we crashed.
db.commit()
@ -562,6 +597,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
f"{_DEFAULT_STALE_DAYS}). Bounds retries on un-geocodable addresses."
),
)
p.add_argument(
"--region-code",
type=int,
default=66,
help=(
"deals.region_code to geocode (default 66, Sverdlovsk oblast). #3051: "
"77 (Moscow) threads through the SQL filter, geocoder.geocode/"
"known_city_hint, and the DB writers' WHERE guard."
),
)
return p.parse_args(argv)
@ -575,34 +620,44 @@ async def main(argv: list[str] | None = None) -> int:
"""
args = _parse_args(argv)
logger.info(
"starting batch=%s limit=%s stale_days=%s dry_run=%s",
"starting batch=%s limit=%s stale_days=%s region_code=%s dry_run=%s",
args.batch,
args.limit,
args.stale_days,
args.region_code,
args.dry_run,
)
db = SessionLocal()
try:
groups = _select_pending_addresses(db, limit=args.limit, stale_days=args.stale_days)
groups = _select_pending_addresses(
db, limit=args.limit, stale_days=args.stale_days, region_code=args.region_code
)
total_rows = sum(g.deals_count for g in groups)
logger.info(
"loaded %d distinct (address, city) groups (%d deal rows) needing coords",
"loaded %d distinct (address, city) groups (%d deal rows) needing coords "
"region_code=%s",
len(groups),
total_rows,
args.region_code,
)
if not groups:
logger.info(
"nothing to do — no deals with lat IS NULL eligible (all tried "
"within the last %d days, or no addressable rows)",
"within the last %d days, or no addressable rows) region_code=%s",
args.stale_days,
args.region_code,
)
return 0
stats = await _run_backfill(db, groups, batch=args.batch, dry_run=args.dry_run)
stats = await _run_backfill(
db, groups, batch=args.batch, dry_run=args.dry_run, region_code=args.region_code
)
if args.dry_run:
total_streets, backlog_rows = _count_pending_total(db, stale_days=args.stale_days)
total_streets, backlog_rows = _count_pending_total(
db, stale_days=args.stale_days, region_code=args.region_code
)
_report_dry_run(
stats,
total_streets=total_streets,
@ -611,9 +666,10 @@ async def main(argv: list[str] | None = None) -> int:
)
logger.info(
"done: batch=%s processed=%d geocoded=%d geocode_failed=%d "
"done: batch=%s region_code=%s processed=%d geocoded=%d geocode_failed=%d "
"skipped=%d deals_updated=%d cache=(hit=%d miss=%d)",
args.batch,
args.region_code,
stats.processed,
stats.geocoded,
stats.geocode_failed,

View file

@ -32,7 +32,9 @@ importers) — `scraper_kit.domclick_exceptions` остаётся единств
from __future__ import annotations
import itertools
import json
import logging
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
@ -40,7 +42,11 @@ import httpx
import pytest
from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.offer_price_history import (
drop_decimal_slips,
recompute_diff_percent,
validate_diff_percent,
)
from scraper_kit.providers.domclick.detail import (
DomClickDetailEnrichment,
_extract_ssr_state,
@ -86,6 +92,7 @@ _SSR_LITERAL = """{
"priceInfo": {
"priceHistory": [
{"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"},
{"date": "2026-03-01T09:00:00+03:00", "price": 13800000, "diff": -200000, "state": "less"},
{"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"},
{"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"}
]
@ -219,15 +226,34 @@ def test_parse_detail_html_full() -> None:
def test_parse_detail_html_price_changes() -> None:
e = parse_detail_html(_HTML, _CARD_URL)
# entry1 ок; entry2 bad-date skip; entry3 price=null skip → 1 запись
assert len(e.price_changes) == 1
change = e.price_changes[0]
assert change["price_rub"] == 13400000
assert change["diff_percent"] == -400000
assert isinstance(change["change_time"], datetime)
# 2 валидные записи; bad-date skip; price=null skip. Порядок — по change_time.
assert len(e.price_changes) == 2
first, second = e.price_changes
assert [c["price_rub"] for c in e.price_changes] == [13800000, 13400000]
# Процент считается из соседних price_rub, поле diff площадки (рубли) игнорируется
# (#3225): 13 400 000 / 13 800 000 1 = 2.9%, а НЕ 400000.
assert first["diff_percent"] is None # самая ранняя запись: предыдущей цены нет
assert second["diff_percent"] == -2.9
assert isinstance(second["change_time"], datetime)
# ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC.
assert change["change_time"].tzinfo is not None
assert change["change_time"].utcoffset() == timedelta(hours=3)
assert second["change_time"].tzinfo is not None
assert second["change_time"].utcoffset() == timedelta(hours=3)
def test_parse_detail_html_price_changes_single_entry_has_no_percent() -> None:
html = _wrap(
{
"productCard": {
"priceInfo": {
"priceHistory": [
{"date": "2026-04-15T09:00:00+03:00", "price": 9000000, "diff": -300000}
]
}
}
}
)
e = parse_detail_html(html, _CARD_URL)
assert [c["diff_percent"] for c in e.price_changes] == [None]
def test_parse_detail_html_raw_extra() -> None:
@ -463,13 +489,18 @@ def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPage
@pytest.mark.asyncio
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None:
"""Sidecar-бан рапортует сам фетчер (`report_platform_ban`, #3288) — здесь уже нет.
Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы
свежий узел. Ветка `parse_detail_html` ниже другой случай: тот детект наш,
фетчер его не видит, и там report_ban остаётся (см. тест следом за этим блоком).
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _CARD_URL in bf.report_ban.call_args.args[0]
bf.report_ban.assert_not_called()
@pytest.mark.asyncio
@ -552,6 +583,37 @@ def test_save_detail_enrichment_writes_columns() -> None:
db.commit.assert_called_once()
def test_save_detail_enrichment_leaves_recorded_at_to_default() -> None:
"""Строка загрузчика обязана иметь change_time <> recorded_at.
По этому признаку миграция 285 отличает строки загрузчика от строк триггера
record_listing_price_change (тот пишет обе метки одним now(), то есть равными) и
пересчитывает diff_percent только у первых. У триггера ДРУГАЯ база отсчёта
listings.OLD.price_rub, поэтому пересчёт его строк ломает честные значения.
Признак держится на двух вещах: change_time = дата источника (глубоко в прошлом),
а recorded_at загрузчик не указывает вовсе DEFAULT NOW() на вставке.
Пропишут recorded_at в этот INSERT признак сломается и тест покраснеет.
"""
db = MagicMock()
db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC)
e = DomClickDetailEnrichment(
item_id="2075729321",
source_url=_CARD_URL,
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}],
)
assert save_detail_enrichment(db, 999, e) is True
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
assert len(insert_calls) == 1
insert_sql = str(insert_calls[0][0][0])
assert "recorded_at" not in insert_sql
# change_time — историческая дата источника, а не момент вставки.
assert insert_calls[0][0][1]["ct"] == ct
assert ct < datetime.now(UTC)
def test_save_detail_enrichment_listing_not_found() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 0
@ -583,20 +645,23 @@ def _insert_diff_param(db: MagicMock) -> float | None:
return insert_calls[0][0][1]["diff"]
def test_save_detail_enrichment_clamps_extreme_positive_diff() -> None:
def test_save_detail_enrichment_rejects_rubles_as_diff(caplog: pytest.LogCaptureFixture) -> None:
"""Величина не того рода (рубли) → NULL + warning, НЕ 999999.99 (#3225)."""
db = MagicMock()
db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC)
e = DomClickDetailEnrichment(
item_id="x",
source_url=_CARD_URL,
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 5000000}],
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 250000}],
)
save_detail_enrichment(db, 1, e)
assert _insert_diff_param(db) == 999999.99
with caplog.at_level(logging.WARNING):
save_detail_enrichment(db, 777, e)
assert _insert_diff_param(db) is None
assert any("250000" in r.getMessage() and "777" in r.getMessage() for r in caplog.records)
def test_save_detail_enrichment_clamps_extreme_negative_diff() -> None:
def test_save_detail_enrichment_rejects_extreme_negative_diff() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC)
@ -606,7 +671,7 @@ def test_save_detail_enrichment_clamps_extreme_negative_diff() -> None:
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -5000000}],
)
save_detail_enrichment(db, 1, e)
assert _insert_diff_param(db) == -999999.99
assert _insert_diff_param(db) is None
def test_save_detail_enrichment_normal_diff_unchanged() -> None:
@ -635,31 +700,288 @@ def test_save_detail_enrichment_none_diff_unchanged() -> None:
assert _insert_diff_param(db) is None
# ── clamp_diff_percent (scraper_kit.offer_price_history) — standalone unit ───
# ── validate_diff_percent (scraper_kit.offer_price_history) — standalone unit ─
# Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) —
# прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами,
# которые его же и используют.
def test_clamp_diff_percent_extreme_positive() -> None:
assert clamp_diff_percent(5000000) == 999999.99
def test_validate_diff_percent_rejects_rubles(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
assert validate_diff_percent(250000, listing_id=406163) is None
assert any("250000" in r.getMessage() and "406163" in r.getMessage() for r in caplog.records)
def test_clamp_diff_percent_extreme_negative() -> None:
assert clamp_diff_percent(-5000000) == -999999.99
def test_validate_diff_percent_rejects_extreme_negative() -> None:
assert validate_diff_percent(-5000000) is None
def test_clamp_diff_percent_in_range_unchanged() -> None:
assert clamp_diff_percent(-2.5) == -2.5
def test_validate_diff_percent_boundary_kept() -> None:
assert validate_diff_percent(100) == 100.0
assert validate_diff_percent(-100) == -100.0
assert validate_diff_percent(100.01) is None
def test_clamp_diff_percent_none() -> None:
assert clamp_diff_percent(None) is None
def test_validate_diff_percent_in_range_unchanged() -> None:
assert validate_diff_percent(-2.5) == -2.5
assert validate_diff_percent(-0.82) == -0.82
def test_clamp_diff_percent_bool_treated_as_none() -> None:
assert clamp_diff_percent(True) is None
assert clamp_diff_percent(False) is None
def test_validate_diff_percent_none() -> None:
assert validate_diff_percent(None) is None
def test_validate_diff_percent_bool_treated_as_none() -> None:
assert validate_diff_percent(True) is None
assert validate_diff_percent(False) is None
# ── drop_decimal_slips (#3376) — сдвиг разряда в priceHistory источника ───────
# Прод-замер 06.09.2026 под ЭТИМ критерием: 38 точек (cian 12, domklik 26,
# yandex 0). Вид: 377 000 → 3 770 000 → 3 720 000 — цена возвращается к базе
# следующей же точкой.
def test_drop_decimal_slips_drops_spike_up(caplog: pytest.LogCaptureFixture) -> None:
"""Точка ×10 с возвратом к базе выброшена; соседи по бокам целы."""
series = [19064000, 190150000, 19150000]
with caplog.at_level(logging.WARNING):
kept, dropped = drop_decimal_slips(series, lambda p: p, listing_id=406163)
assert kept == [19064000, 19150000]
assert dropped == 1
assert any("406163" in r.getMessage() and "19064000" in r.getMessage() for r in caplog.records)
def test_drop_decimal_slips_drops_spike_down() -> None:
"""Тот же критерий в другую сторону: ÷10 и возврат к базе (377 000 при базе 3.7 млн)."""
kept, dropped = drop_decimal_slips([3720000, 377000, 3770000], lambda p: p)
assert kept == [3720000, 3770000]
assert dropped == 1
def test_drop_decimal_slips_keeps_honest_doubling() -> None:
"""Рост ×2 — не сдвиг разряда, серия не тронута."""
series = [5000000, 10000000, 9900000]
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
def test_drop_decimal_slips_keeps_tenfold_without_return_to_base() -> None:
"""×10 БЕЗ возврата к базе — возможное честное изменение цены, точку не трогаем.
Хвост 1 020 000 10 200 000 10 150 000 намеренно стоит НЕ первым: ровно та
же тройка в начале серии это уже сдвиг по правилу первой точки (см.
test_drop_decimal_slips_drops_first_point_witnessed_by_third), и правила здесь
не спорят, а смотрят на разные концы серии. Внутри серии у 10 200 000 есть
база слева, и она НЕ подтверждена следующей точкой (9.95 1) точка честная.
"""
series = [1000000, 1020000, 10200000, 10150000]
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
def test_drop_decimal_slips_last_point_checked_against_current_price() -> None:
"""У последней точки следующей нет — свидетель это текущая цена объявления."""
kept, dropped = drop_decimal_slips([1020000, 10200000], lambda p: p, current_price=1020000)
assert kept == [1020000]
assert dropped == 1
def test_drop_decimal_slips_last_point_kept_without_current_price() -> None:
"""Второго свидетеля нет вообще → точка остаётся (могла быть честной сменой цены)."""
series = [1020000, 10200000]
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
# ── правило ПЕРВОЙ точки (#3376, прод-разбор 06.09.2026) ─────────────────────
# Серии, у которых дефектная точка первая: 420 000 → 4 200 000 → 4 500 000. Слева
# базы нет, основное правило такую точку не видит. Свидетелей по-прежнему двое, и
# оба — из самой истории: вторая точка и ТРЕТЬЯ. Кандидатов первой точки на проде
# было 21, свидетеля из истории имеют 4 (все domklik).
def test_drop_decimal_slips_drops_first_point_witnessed_by_third() -> None:
"""420 000 → 4 200 000 → 4 500 000: вторую точку подтверждает третья."""
kept, dropped = drop_decimal_slips([420000, 4200000, 4500000], lambda p: p)
assert (kept, dropped) == ([4200000, 4500000], 1)
def test_drop_decimal_slips_keeps_first_point_when_series_runs_away() -> None:
"""Первая ×10 ко второй, но третья ушла ЕЩЁ дальше — это разгон цены, не разряд."""
series = [400000, 4000000, 8000000]
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
def test_drop_decimal_slips_keeps_first_point_without_third_point() -> None:
"""330 000 → 3 300 000: третьей точки нет — свидетеля нет, ДАЖЕ с ценой лота.
Цена объявления в правиле первой точки не участвует сознательно: у 17 из 21
прод-кандидата (yandex 6, domklik 9, cian 2) listings.price_rub в точности
равнялась второй точке у yandex это буквально одна переменная lot.price_rub,
записанная в двух местах. Такой «второй свидетель» то же наблюдение, а
удаление точки необратимо. Верни COALESCE на current_price в правило первой
точки первый assert покраснеет.
"""
series = [330000, 3300000]
assert drop_decimal_slips(series, lambda p: p, current_price=3300000) == (series, 0)
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
def test_drop_decimal_slips_first_point_decided_on_kept_series() -> None:
"""Свидетели первой точки — ОСТАВШИЕСЯ соседи, а не сырые.
1M 10M 100M 10M: 100M выбрасывает основное правило, и только после этого
видно, что первая точка ÷10 к оставшейся серии [10M, 10M]. Считай правило по
сырым соседям первый проход оставил бы 1M, а второй выбросил, то есть гейт
перестал бы быть идемпотентным (перебор ниже ловит 1116 таких прогонов, и эта
серия первый из них).
"""
kept, dropped = drop_decimal_slips(
[1_000_000, 10_000_000, 100_000_000, 10_000_000], lambda p: p
)
assert (kept, dropped) == ([10_000_000, 10_000_000], 2)
# ── база = предыдущая ОСТАВЛЕННАЯ точка (фальсификаторы ревьюера) ────────────
def test_drop_decimal_slips_alternating_series_keeps_honest_points() -> None:
"""1M → 10M → 1M → 10M при цене 1M: обе честные точки на месте.
Первая редакция миграции 286 брала базой СЫРУЮ предыдущую строку (lag) и на
этой серии оставляла [1M] вместо [1M, 1M] удаляла честную точку.
"""
kept, dropped = drop_decimal_slips(
[1_000_000, 10_000_000, 1_000_000, 10_000_000], lambda p: p, current_price=1_000_000
)
assert (kept, dropped) == ([1_000_000, 1_000_000], 2)
def test_drop_decimal_slips_base_is_previous_kept_not_raw() -> None:
"""1M → 10M → 1.05M → 9.9M при цене 1M: после выброса 10M база для 1.05M — это 1M.
По сырой базе 1.05M тоже стала бы кандидатом (10M/1.05M = 9.52), а следующий
прогон доел бы 9.9M ровно та неидемпотентность, которую нашёл ревьюер.
"""
kept, dropped = drop_decimal_slips(
[1_000_000, 10_000_000, 1_050_000, 9_900_000], lambda p: p, current_price=1_000_000
)
assert (kept, dropped) == ([1_000_000, 1_050_000, 9_900_000], 1)
# ── свойство: gate(gate(s)) == gate(s) ───────────────────────────────────────
_SLIP_ALPHABET = (1_000_000, 10_000_000, 100_000_000, 1_050_000, 9_900_000)
_SLIP_CURRENTS = (None, 1_000_000, 10_000_000)
def test_drop_decimal_slips_is_idempotent(caplog: pytest.LogCaptureFixture) -> None:
"""Повторный прогон гейта не трогает ничего — на всех сериях длины 2-6.
Свойство несущее, а не декоративное: миграция 286 зеркалит эту функцию
пошагово, а доказать идемпотентность на самом SQL-файле нечем прод-прогон
один. Раз выборка кандидатов повторяет гейт, свойство переносится на неё.
ФАЛЬСИФИЦИРУЕМОСТЬ (перемерено 06.09.2026 на этом же переборе 19 525 серий
× 3 цены = 58 575 прогонов):
база = сырая предыдущая точка вместо оставленной 136 неидемпотентных
прогонов, первый же (1M, 10M, 1.05M, 9.9M) при цене 1M: [1M, 9.9M] [1M];
решение по первой точке на сырых соседях вместо оставшихся 1116 прогонов,
первый (1M, 10M, 100M, 10M) без текущей цены: [1M, 10M, 10M] [10M, 10M].
"""
# Гейт логирует каждый выброс; на таком переборе это десятки тысяч записей.
caplog.set_level(logging.CRITICAL, logger="scraper_kit.offer_price_history")
for length in range(2, 7):
for series in itertools.product(_SLIP_ALPHABET, repeat=length):
for current in _SLIP_CURRENTS:
once, _ = drop_decimal_slips(list(series), lambda p: p, current_price=current)
twice, dropped_again = drop_decimal_slips(once, lambda p: p, current_price=current)
assert (twice, dropped_again) == (once, 0), (
f"серия {series} при текущей цене {current}: {once}{twice}"
)
# ── recompute_diff_percent — общий пересчёт после выброса ────────────────────
def test_recompute_diff_percent_from_surviving_neighbours() -> None:
"""Процент считается от оставшейся базы; у первой точки базы нет → NULL."""
changes: list[dict[str, object]] = [
{"price_rub": 3720000, "diff_percent": -89.87},
{"price_rub": 3770000, "diff_percent": 900.0},
]
recompute_diff_percent(changes)
assert [c["diff_percent"] for c in changes] == [None, 1.34]
def test_recompute_diff_percent_survives_none_price() -> None:
"""price_rub=None не роняет пересчёт: ручной ingest кладёт price_changes сырьём.
scripts/ingest_domclick_jsonl.py:108 берёт rec["price_changes"] из JSONL без
валидации арифметика по None здесь дала бы TypeError на всю запись.
"""
changes: list[dict[str, object]] = [
{"price_rub": 5_000_000, "diff_percent": 12.0},
{"price_rub": None, "diff_percent": 900.0},
{"price_rub": 5_100_000, "diff_percent": -90.0},
]
recompute_diff_percent(changes)
assert [c["diff_percent"] for c in changes] == [None, None, 2.0]
def test_save_detail_enrichment_drops_decimal_slip_before_insert() -> None:
"""Писатель не отправляет спайк в INSERT, а соседу пересчитывает diff_percent."""
db = MagicMock()
db.execute.return_value.rowcount = 1
# SELECT price_rub FROM listings — текущая цена объявления (свидетель).
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=3770000)
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2, 3)]
e = DomClickDetailEnrichment(
item_id="2075729321",
source_url=_CARD_URL,
price_changes=[
{"change_time": times[0], "price_rub": 3720000, "diff_percent": None},
# Сдвиг разряда: ÷10 к предыдущей, следующая возвращается к базе.
{"change_time": times[1], "price_rub": 377000, "diff_percent": -89.87},
{"change_time": times[2], "price_rub": 3770000, "diff_percent": 900.0},
],
)
assert save_detail_enrichment(db, 999, e) is True
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
assert [c[0][1]["price"] for c in insert_calls] == [3720000, 3770000]
# У точки-соседа diff_percent парсер считал от исчезнувшей базы 377 000 (+900%,
# такое validate_diff_percent отвергает). От настоящей базы 3 720 000 это +1.34%.
assert insert_calls[1][0][1]["diff"] == 1.34
def test_save_detail_enrichment_witness_comes_only_from_listing_price() -> None:
"""Серия из ДВУХ точек: второго свидетеля даёт исключительно цена листинга.
Здесь чтение цены не декорация, а единственный вход гейта: у последней точки
следующей нет. Сломай его (RETURNING убран / fetchone None) свидетеля не
станет, спайк 10 200 000 уедет в offer_price_history, и тест покраснеет на
втором INSERT'е. Проверено подменой fetchone → None.
"""
db = MagicMock()
db.execute.return_value.rowcount = 1
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=1020000)
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2)]
e = DomClickDetailEnrichment(
item_id="x",
source_url=_CARD_URL,
price_changes=[
{"change_time": times[0], "price_rub": 1020000, "diff_percent": None},
{"change_time": times[1], "price_rub": 10200000, "diff_percent": 900.0},
],
)
assert save_detail_enrichment(db, 999, e) is True
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
assert [c[0][1]["price"] for c in insert_calls] == [1020000]
# Цену отдаёт тот же UPDATE — отдельного SELECT по PK больше нет.
assert "RETURNING price_rub" in str(db.execute.call_args_list[0][0][0])
assert not [c for c in db.execute.call_args_list if "SELECT price_rub" in str(c[0][0])]
# ── canon_sale_type (#2674) ───────────────────────────────────────────────────

View file

@ -337,6 +337,7 @@ def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom():
"city": "Екатеринбург",
"lat": 56.1,
"lon": 60.2,
"region_code": 66,
}
assert n == 7
@ -492,7 +493,7 @@ async def test_run_backfill_passes_known_city_as_hint():
) as mock_geo:
await _run_backfill(db, groups, batch="b", dry_run=False)
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил")
mock_geo.assert_called_once_with("Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
assert coord_updates[0]["city"] == "Нижний Тагил"
@ -507,7 +508,7 @@ async def test_run_backfill_junk_city_geocodes_without_hint_but_scopes_update():
) as mock_geo:
await _run_backfill(db, groups, batch="b", dry_run=False)
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None)
mock_geo.assert_called_once_with("Бессонова, 11", db, city_hint=None, region_code=66)
assert coord_updates[0]["city"] == "Бессонова"
@ -547,7 +548,7 @@ async def test_run_backfill_miss_marks_only_its_own_city():
await _run_backfill(db, groups, batch="b", dry_run=False)
assert coord_updates == []
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил"}]
assert tried_updates == [{"addr": "Победы, 30", "city": "Нижний Тагил", "region_code": 66}]
# ---------------------------------------------------------------------------

View file

@ -438,21 +438,38 @@ class FakeSession:
[{"ban_count": ban["ban_count"], "banned_until": ban["banned_until"]}]
)
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2.
# Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры
# в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и
# тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном.
if "UPDATE scrape_proxy_source_bans" in sql and "cleared_reason" in sql:
# clear_source_bans (#3404): гасим строку (banned_until=now(), ban_count=0,
# cleared_at/cleared_reason) вместо DELETE — трассируемость снятия бана, строка
# доживает до штатного purge. Фильтр по reason (#2800) гейтим по подстроке
# боевого SQL — как ban-фильтры в acquire-ветке: иначе мок «чинил» бы код,
# который фильтра не содержит, и тест на «успешная проба не гасит чужой бан»
# остался бы зелёным на сломанном.
filters_reason = "reason = CAST(:only_reason AS text)" in sql
only_reason = p.get("only_reason") if filters_reason else None
cleared = [
b
for b in self.bans
if b["proxy_id"] == p["proxy_id"]
and (p["source"] is None or b["source"] == p["source"])
and (only_reason is None or b.get("reason") == only_reason)
]
self.bans = [b for b in self.bans if b not in cleared]
now = datetime.now(UTC)
cleared: list[dict[str, Any]] = []
for b in self.bans:
if b["proxy_id"] != p["proxy_id"]:
continue
if p["source"] is not None and b["source"] != p["source"]:
continue
if only_reason is not None and b.get("reason") != only_reason:
continue
# Гейт покоя (реальный WHERE): уже погашенную строку повторно не трогаем —
# без него повторный вызов сдвигал бы banned_until вперёд.
if (
b.get("cleared_at") is not None
and b["ban_count"] == 0
and b["banned_until"] <= now
):
continue
b["banned_until"] = now
b["ban_count"] = 0
b["cleared_at"] = now
b["cleared_reason"] = p["reason"]
b["updated_at"] = now
cleared.append(b)
return _FakeResult([{"source": b["source"]} for b in cleared])
if "DELETE FROM scrape_proxy_source_bans" in sql: # purge истёкших (#2600 п.2)
@ -1419,6 +1436,10 @@ async def test_healthcheck_purges_long_expired_bans_only(
# До п.2 ложный бан лечился PATCH enabled=true (обнулял disabled_reason). Теперь бан
# в отдельной таблице и истекает только по таймеру (до 72ч при эскалации) — без этой
# ручки ложное срабатывание детектора капчи (#2642) снималось бы только руками в SQL.
#
# #3404: снятие гасит строку (banned_until=now(), ban_count=0, cleared_at/cleared_reason),
# а не удаляет её — строка живёт для трассируемости до штатного purge, но для выдачи и
# для эскалации следующего бана неотличима от прежнего DELETE.
def _active_ban(pid: int, source: str, ban_count: int = 1) -> dict[str, Any]:
@ -1428,17 +1449,37 @@ def _active_ban(pid: int, source: str, ban_count: int = 1) -> dict[str, Any]:
"ban_count": ban_count,
"banned_until": datetime.now(UTC) + timedelta(hours=SOURCE_BAN_MAX_HOURS),
"reason": f"banned:{source}",
"cleared_at": None,
"cleared_reason": None,
}
def test_clear_source_bans_removes_all_bans_of_node() -> None:
def test_clear_source_bans_gates_all_bans_of_node() -> None:
db = FakeSession(
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],
bans=[_active_ban(1, "avito"), _active_ban(1, "cian"), _active_ban(2, "avito")],
)
cleared = proxy_pool.clear_source_bans(db, 1, reason="manual enable") # type: ignore[arg-type]
assert cleared == 2
assert [(b["proxy_id"], b["source"]) for b in db.bans] == [(2, "avito")] # чужой цел
# #3404: строки НЕ удаляются — все три остаются в таблице (трассируемость).
assert {(b["proxy_id"], b["source"]) for b in db.bans} == {
(1, "avito"),
(1, "cian"),
(2, "avito"),
}
now = datetime.now(UTC)
node1_bans = [b for b in db.bans if b["proxy_id"] == 1]
assert len(node1_bans) == 2
for b in node1_bans:
assert b["banned_until"] <= now
assert b["ban_count"] == 0
assert b["cleared_at"] is not None
assert b["cleared_reason"] == "manual enable"
# чужой бан (proxy_id=2) не тронут — остаётся активным
other_ban = next(b for b in db.bans if b["proxy_id"] == 2)
assert other_ban["banned_until"] > now
assert other_ban["ban_count"] == 1
assert other_ban["cleared_at"] is None
# узел снова выдаётся источнику, который его банил
lease = acquire(db, "avito", run_id=1) # type: ignore[arg-type]
assert lease is not None and lease.id == 1
@ -1453,7 +1494,16 @@ def test_clear_source_bans_single_source_keeps_others() -> None:
db, 1, source="avito", reason="ip rotated"
)
assert cleared == 1
assert [b["source"] for b in db.bans] == ["cian"]
# обе строки остаются (#3404), но только "avito" погашена
avito_ban = db._ban(1, "avito")
cian_ban = db._ban(1, "cian")
now = datetime.now(UTC)
assert avito_ban["banned_until"] <= now
assert avito_ban["ban_count"] == 0
assert avito_ban["cleared_reason"] == "ip rotated"
assert cian_ban["banned_until"] > now
assert cian_ban["ban_count"] == 1
assert cian_ban["cleared_at"] is None
def test_clear_source_bans_noop_when_nothing_to_clear() -> None:
@ -1462,8 +1512,8 @@ def test_clear_source_bans_noop_when_nothing_to_clear() -> None:
def test_clear_source_bans_resets_escalation() -> None:
"""DELETE, а не banned_until=now(): снятие обнуляет и ban_count — следующий бан
начинается с базовых SOURCE_BAN_BASE_HOURS, а не продолжает эскалацию."""
"""Гашение (banned_until=now(), ban_count=0), а не удаление строки: следующий бан той
же пары начинается с базовых SOURCE_BAN_BASE_HOURS, а не продолжает эскалацию (#3404)."""
assert mark_banned is not None
db = FakeSession(
[_proxy(1, affinity="any"), _proxy(2, affinity="any")],

View file

@ -105,9 +105,19 @@ class FakeSession:
)
return _FakeResult([])
if "DELETE FROM scrape_proxy_source_bans" in sql: # clear_source_bans (#2600 п.2)
cleared = [b for b in self.source_bans if b["proxy_id"] == p["proxy_id"]]
self.source_bans = [b for b in self.source_bans if b not in cleared]
if "UPDATE scrape_proxy_source_bans" in sql and "cleared_reason" in sql:
# clear_source_bans (#3404): гасит строку (cleared_reason=reason), а НЕ
# удаляет — строка остаётся для трассируемости. Фейк мутирует найденные
# записи в месте, не убирая их из self.source_bans.
target_source = p.get("source")
cleared = [
b
for b in self.source_bans
if b["proxy_id"] == p["proxy_id"]
and (target_source is None or b["source"] == target_source)
]
for b in cleared:
b["cleared_reason"] = p["reason"]
return _FakeResult([{"source": b["source"]} for b in cleared])
if "UPDATE scrape_proxies" in sql and "exit_ip" in sql:
@ -404,7 +414,13 @@ async def test_successful_rotation_clears_source_bans(monkeypatch: pytest.Monkey
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert db.source_bans == [{"proxy_id": 2, "source": "avito"}]
# #3404: строки не удаляются — гасятся (cleared_reason проставлен), остаются для
# трассируемости. Чужой узел (proxy_id=2) не тронут вообще.
proxy1_bans = [b for b in db.source_bans if b["proxy_id"] == 1]
assert len(proxy1_bans) == 2
assert all(b.get("cleared_reason") == "exit ip rotated (status=200)" for b in proxy1_bans)
other_ban = next(b for b in db.source_bans if b["proxy_id"] == 2)
assert other_ban.get("cleared_reason") is None
async def test_failed_rotation_keeps_source_bans(monkeypatch: pytest.MonkeyPatch) -> None:
@ -471,10 +487,13 @@ async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyP
fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
db = FakeSession(_proxy_row(), source_bans=[{"proxy_id": 1, "source": "avito"}])
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert SECRET_TOKEN not in (result.reason or "")
assert SECRET_TOKEN not in (result.new_ip or "")
# #3404: успешная ротация гасит бан и пишет cleared_reason — секрет не должен
# попасть и туда (та же гигиена, что для reason/note/логов).
assert SECRET_TOKEN not in (db.source_bans[0].get("cleared_reason") or "")
async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
@ -574,7 +593,10 @@ async def test_token_never_appears_in_log_messages_or_sentry_text(
else:
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url=rotate_url))
db = FakeSession(
_proxy_row(rotate_url=rotate_url),
source_bans=[{"proxy_id": 1, "source": "avito"}],
)
with caplog.at_level(logging.DEBUG):
caplog.clear()
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
@ -583,6 +605,12 @@ async def test_token_never_appears_in_log_messages_or_sentry_text(
assert SECRET_TOKEN not in record.getMessage(), (
f"scenario={name}: token leaked into log message args"
)
if name == "success":
# #3404: успешная ротация гасит бан и пишет cleared_reason — секрет не
# должен попасть и туда.
assert SECRET_TOKEN not in (db.source_bans[0].get("cleared_reason") or ""), (
f"scenario={name}: token leaked into cleared_reason"
)
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
assert all(SECRET_TOKEN not in text for text in sentry_texts)
@ -646,7 +674,9 @@ async def test_mobileproxy_success_clears_source_bans(monkeypatch: pytest.Monkey
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert db.source_bans == []
# #3404: гашение, не удаление — строка остаётся, cleared_reason помечает снятие.
assert len(db.source_bans) == 1
assert db.source_bans[0]["cleared_reason"] == "exit ip rotated (mobileproxy status=200)"
async def test_mobileproxy_non_ok_status_is_failure_and_consumes_quota(
@ -737,7 +767,10 @@ async def test_mobileproxy_key_never_leaks_in_reason_or_db_or_logs(
fake_client, _ = _fake_async_client_get(response=response, exception=exception)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
db = FakeSession(
_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL),
source_bans=[{"proxy_id": 1, "source": "avito"}],
)
with caplog.at_level(logging.DEBUG):
caplog.clear()
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
@ -749,6 +782,10 @@ async def test_mobileproxy_key_never_leaks_in_reason_or_db_or_logs(
assert MOBILEPROXY_KEY not in record.getMessage(), (
f"scenario={name}: proxy_key leaked into log message args"
)
if name == "success":
# #3404: успешная ротация гасит бан и пишет cleared_reason — ключ не
# должен попасть и туда.
assert MOBILEPROXY_KEY not in (db.source_bans[0].get("cleared_reason") or ""), name
async def test_mobileproxy_unknown_query_shape_still_masks_url_in_refusal(

View file

@ -806,7 +806,7 @@ async def test_admin_geocode_missing_passes_city_hint(target: str) -> None:
target=target, # type: ignore[arg-type]
)
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил")
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил", region_code=66)
assert result["geocoded"] == 1
assert result["skipped"] == 0
# SELECT адресован именно запрошенной таблице (обе несут колонку city).
@ -854,7 +854,7 @@ async def test_admin_geocode_missing_drops_junk_city_hint(target: str) -> None:
target=target, # type: ignore[arg-type]
)
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None)
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None, region_code=66)
@pytest.mark.asyncio

View file

@ -78,7 +78,14 @@ def _mock_db(
return db
def _make_resp(status: int = 200, text: str = "<html>ok</html>") -> MagicMock:
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруженная страница (без
# блока контактов / меньше settings.yandex_detail_min_html_bytes) теперь отсеивается
# ДО parse и считается отказом, поэтому заглушка «<html>ok</html>» больше не годится
# для кейсов, где ожидается обогащение.
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
def _make_resp(status: int = 200, text: str = _FULL_PAGE_HTML) -> MagicMock:
"""Fake curl_cffi response."""
resp = MagicMock()
resp.status_code = status

View file

@ -83,6 +83,26 @@ PROD_STALE = [
]
def _run_row(row: Any) -> Any:
"""Та же правда в кодировке #3172: строка запроса — ПРОГОН, а не готовая свёртка.
Свежесть теперь меряется последним прогоном, ПРИНЁСШИМ ДАННЫЕ (`freshness_rows`),
поэтому запрос отдаёт сырые поля прогона; порог, который проверяет этот файл, от
правки не зависит он считает те же `since`/`never_ok`, просто свёрнутые в Python.
"""
return SimpleNamespace(
source=row.source,
interval_days=row.interval_days,
created_at=NOW - timedelta(days=400),
finished_at=row.since,
status="done",
counters={"lots_fetched": 1},
)
PROD_RUN_ROWS = [_run_row(r) for r in PROD_ROWS]
@pytest.fixture(autouse=True)
def _reset_digest_clock() -> Any:
"""Выпуск сводки помнится в памяти модуля — сбрасываем между тестами."""
@ -131,7 +151,7 @@ def test_never_successful_source_is_reported_with_a_flag() -> None:
def test_digest_emits_one_event_listing_all_stale_sources() -> None:
sentry = MagicMock()
with patch.object(sched, "sentry_sdk", sentry):
stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
stale = sched.emit_stale_digest(_db(PROD_RUN_ROWS), now=NOW)
assert [s.source for s in stale] == PROD_STALE
sentry.capture_message.assert_called_once()
msg = sentry.capture_message.call_args[0][0]
@ -145,7 +165,7 @@ def test_digest_covers_the_two_sources_the_ladder_cannot_reach() -> None:
"""Главное свойство: стрик 0 не мешает сводке — она меряет календарь, а не серию."""
sentry = MagicMock()
with patch.object(sched, "sentry_sdk", sentry):
stale = sched.emit_stale_digest(_db(PROD_ROWS), now=NOW)
stale = sched.emit_stale_digest(_db(PROD_RUN_ROWS), now=NOW)
zero_streak = {"avito_full_load_exhaustive", "cian_history_backfill"}
assert zero_streak <= {s.source for s in stale}
@ -154,14 +174,14 @@ def test_digest_is_quiet_when_everything_is_fresh() -> None:
sentry = MagicMock()
fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)]
with patch.object(sched, "sentry_sdk", sentry):
assert sched.emit_stale_digest(_db(fresh), now=NOW) == []
assert sched.emit_stale_digest(_db([_run_row(r) for r in fresh]), now=NOW) == []
sentry.capture_message.assert_not_called()
def test_digest_is_daily_not_per_tick() -> None:
"""Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки."""
sentry = MagicMock()
db = _db(PROD_ROWS)
db = _db(PROD_RUN_ROWS)
with patch.object(sched, "sentry_sdk", sentry):
sched.emit_stale_digest(db, now=NOW)
sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1))

View file

@ -194,7 +194,7 @@ def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
"""
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
assert "скорректирована по оценочной модели площадки" not in (est.confidence_explanation or "")
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:

View file

@ -302,7 +302,14 @@ async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) ->
avito = db._ban(1, "avito")
assert avito is not None, "чужой бан проба снимать не имеет права"
assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать"
assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять"
cian = db._ban(1, "cian")
# #3404: снятие теперь ГАСИТ строку, а не удаляет — вердикт пробы перестаёт
# блокировать выдачу (banned_until в прошлом, ban_count обнулён), но остаётся
# виден в истории как снятый досрочно.
assert cian is not None, "погашенная строка живёт до purge (#3404)"
assert cian["ban_count"] == 0, "свой вердикт проба обязана снять"
assert cian["banned_until"] <= datetime.now(UTC), "и снять немедленно"
assert cian["cleared_at"] is not None, "снятие должно быть отмечено"
assert counters["pair_cleared"] == 1

View file

@ -14,9 +14,12 @@
kwarg'а в вызове. Красные на старом коде:
* (1) `fetch_detail` вызывался без `proxy_provider` lease не брался, 403 никому не
сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»:
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`,
где флага нет один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`).
сообщался: `mark_banned_calls == []`. Ловушка «правка без эффекта» была в том, что
`USE_PROXY_POOL_CURL` задавался только контейнеру `scraper`, а ручка живёт в
`backend`, где флага не было один `proxy_provider=` пул бы не включил. Отсюда взялся
подкласс конфига с зашитым `use_proxy_pool_curl = True`; #3387 задал переменную и
сервису `backend`, костыль снят (#3386 хвост), флаг снова управляет путём в обе
стороны см. `test_price_history_honours_flag_off`.
* (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел
параметра `db` вызов из теста падал бы на сигнатуре, а исход «пул исчерпан»
выражения не имел вообще.
@ -123,7 +126,10 @@ def _price_history_db(n_listings: int) -> MagicMock:
return db
async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any:
async def _run_price_history(
pool: _SpyPool, *, status_code: int, n: int = 1, use_pool: bool = True
) -> Any:
from app.core.config import settings
from app.services import scraper_adapters
from app.services.cian_price_history import backfill_cian_price_history
@ -132,6 +138,7 @@ async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) ->
cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code)
),
patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0),
patch.object(settings, "use_proxy_pool_curl", use_pool),
patch.object(scraper_adapters, "_proxy_pool", pool),
patch.object(scraper_adapters, "_SessionLocal", MagicMock()),
):
@ -139,23 +146,33 @@ async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) ->
@pytest.mark.asyncio
async def test_price_history_takes_pool_node_despite_flag_off() -> None:
"""Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend).
async def test_price_history_takes_pool_node() -> None:
"""Узел берётся из пула, вердикт возвращается, lease не течёт.
Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного
pool-режима `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал.
Красный на коде до #2830: не было `proxy_provider=` — `curl_proxy_url` уходил на
статичный env-узел и `acquire` не звал.
"""
from app.core.config import settings
assert settings.use_proxy_pool_curl is False, (
"тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
)
pool = _SpyPool()
await _run_price_history(pool, status_code=200)
assert pool.acquire_calls == ["cian"]
assert pool.release_calls == [9] # lease не течёт
@pytest.mark.asyncio
async def test_price_history_honours_flag_off() -> None:
"""USE_PROXY_POOL_CURL=false → честный env-путь, а не пул через зашитую константу.
#3386 (хвост): пока в сервисе жил подкласс `RealScraperConfig` с
`use_proxy_pool_curl = True`, рубильник на этом пути был неотключаем тест красный
на main (`acquire_calls == ["cian"]`). После #3387 переменная задана контейнеру
`backend` в compose, костыль лишний, и флаг снова управляет обеими сторонами.
"""
pool = _SpyPool()
result = await _run_price_history(pool, status_code=200, use_pool=False)
assert pool.acquire_calls == [], "при выключенном флаге пул не трогаем"
assert result.checked == 1, "запрос всё равно идёт — просто env-прокси, как раньше"
@pytest.mark.asyncio
async def test_price_history_403_bans_the_node_for_cian() -> None:
"""403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = []."""

View file

@ -27,18 +27,21 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services.product_handlers import _job_yandex_newbuilding_sweep
from app.tasks.yandex_newbuilding_sweep import YandexNewbuildingSweepResult
def _ctx() -> SimpleNamespace:
return SimpleNamespace(runs=MagicMock())
def _result(processed: int, succeeded: int) -> MagicMock:
r = MagicMock()
r.to_dict = MagicMock(
return_value={"processed": processed, "succeeded": succeeded, "total": 425}
)
return r
def _result(processed: int, succeeded: int) -> YandexNewbuildingSweepResult:
"""Настоящий результат задачи, а не MagicMock.
У MagicMock любой не заданный атрибут (`no_proxy_stop` из #3197) — truthy, то есть
добавление хендлеру новой ветки по полю результата уводило бы сюда ВСЕ три случая,
и тест краснел бы по причине, не имеющей отношения к #2860.
"""
return YandexNewbuildingSweepResult(processed=processed, succeeded=succeeded, total=425)
@pytest.mark.asyncio

View file

@ -0,0 +1,98 @@
"""#3051 PR-A — региональный скоуп ДКП-коридора (`_fetch_dkp_corridor` / потребители).
region_code ОБЯЗАТЕЛЬНЫЙ фильтр (не advisory, в отличие от city) в обоих ДКП-запросах
(street и city-wide widen): без него street ILIKE ловил бы одноимённую улицу ЧУЖОГО
региона. Мок-паттерн повторяет tests/test_dkp_corridor_as_of_2846.py db.execute
диспатчится по подстрокам SQL-текста, без живого Postgres.
"""
from __future__ import annotations
import os
from datetime import date
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.estimator import _fetch_dkp_corridor
_ADDRESS = "Москва, ул. Тверская, 1"
def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]:
return {"price_per_m2": ppm2, "deal_date": deal_date}
def _db_capturing(
street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None
) -> tuple[Any, list[tuple[str, dict[str, Any]]]]:
"""Session-двойник, попутно копящий (sql, params) КАЖДОГО execute на deal_city_price_bands."""
calls: list[tuple[str, dict[str, Any]]] = []
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
sql = str(query)
result = MagicMock()
if "sber" in sql.lower():
result.mappings.return_value.all.return_value = []
elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql:
calls.append((sql, params or {}))
result.mappings.return_value.all.return_value = street_rows
elif "deal_city_price_bands" in sql:
calls.append((sql, params or {}))
result.mappings.return_value.all.return_value = city_rows or []
else:
result.mappings.return_value.all.return_value = []
return result
db = MagicMock()
db.execute.side_effect = _execute
return db, calls
def test_corridor_binds_region_in_both_queries() -> None:
"""street n=1 (< DKP_CORRIDOR_CITY_WIDE_MIN_N=3) → срабатывает city-wide widen —
ОБА execute на deal_city_price_bands несут `d.region_code = CAST(:region_code AS int)`
и bind region_code=77 при явном kwarg; без kwarg дефолт 66 (обратная совместимость,
сегодня все сделки в БД region_code=66).
"""
street_rows = [_deal(300_000, date(2026, 1, 1))]
city_rows = [_deal(290_000, date(2025, 10, 1)), _deal(295_000, date(2025, 11, 1))]
db77, calls77 = _db_capturing(street_rows, city_rows)
raw = _fetch_dkp_corridor(
db77, address=_ADDRESS, rooms=2, area=50.0, city="нижний тагил", region_code=77
)
assert raw is not None
assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)"
assert len(calls77) == 2, "ожидались обе ветки: street + city-wide widen"
for sql, params in calls77:
assert "d.region_code = CAST(:region_code AS int)" in sql
assert params["region_code"] == 77
db66, calls66 = _db_capturing(street_rows, city_rows)
_fetch_dkp_corridor(db66, address=_ADDRESS, rooms=2, area=50.0, city="нижний тагил")
assert len(calls66) == 2
for _sql, params in calls66:
assert params["region_code"] == 66
def test_estimate_paths_never_pass_none_region() -> None:
"""POST /estimate (estimate_quality) и GET rehydrate (load_estimate) обязаны
резолвить region_code через regions_mod.DEFAULT_REGION_CODE fallback, а не оставить
None: NULL в bind-параметре `= NULL` не матчит ничего и молча обнулил бы фильтр
региона. Дешёвая source-проверка вместо тяжёлых end-to-end моков обоих путей.
"""
import inspect
from app.api.v1.trade_in import load_estimate
from app.services.estimator import estimate_quality
load_src = inspect.getsource(load_estimate)
estimate_src = inspect.getsource(estimate_quality)
for src in (load_src, estimate_src):
assert "regions_mod.DEFAULT_REGION_CODE" in src
assert "region_code=target_region_code" in src

View file

@ -0,0 +1,93 @@
"""#3051 п.3 — параметризация import_rosreestr_dkp по региону (Москва, region_code=77).
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через
inspect.getsource), тем же механизмом, что и test_rosreestr_dedup_key.py без живого
FDW / DB-фикстуры. Многострочные клозы матчатся regex с `\\s+`, а не подстрокой: SQL
внутри text(...) переносится по строкам, точный пробельный рисунок хрупок.
"""
from __future__ import annotations
import inspect
import os
import re
from pathlib import Path
from unittest.mock import MagicMock
# Импорт app.services.scheduler тянет app.core.config.Settings → требует DATABASE_URL.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.services import scheduler
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_289 = _SQL_DIR / "289_rosreestr_fdw_msk_columns_seed77.sql"
_IMPORT_SRC = inspect.getsource(scheduler.import_rosreestr_dkp)
def test_case_on_canonical_city_bind() -> None:
"""canonical_city — bind-параметр CASE-выражения (address/city/raw_payload),
не Python if/else на конкретный код региона (#3051: одна ветка SQL решает и
для 66 (None), и для 77 ('Москва'), и для любого будущего региона).
"""
assert _IMPORT_SRC.count("CAST(:canonical_city AS text) IS NOT NULL") >= 3
assert '"canonical_city": region.canonical_city' in _IMPORT_SRC
def test_city_filter_bypassed_only_with_canonical_city() -> None:
"""WHERE-клоз: у региона С canonical_city city IS NOT NULL НЕ применяется —
иначе для Москвы теряется ~10% строк с пустым city источника (округ/поселение
не всегда заполнен).
"""
pattern = re.compile(
r"\(\s*CAST\(:canonical_city AS text\) IS NOT NULL\s*"
r"OR \(city IS NOT NULL AND trim\(city\) <> ''\)\s*\)"
)
assert pattern.search(_IMPORT_SRC), "не найден WHERE-клоз city IS NOT NULL bypass"
def test_raw_payload_jsonb_bind() -> None:
"""raw_payload (jsonb) — исходные city/okato/quarter_cad_number/district для
регионов с canonical_city; bind через CAST(... AS jsonb), не литерал.
"""
assert "CAST(:raw_payload AS jsonb)" in _IMPORT_SRC
assert "raw_payload = EXCLUDED.raw_payload" in _IMPORT_SRC
assert "IS DISTINCT FROM EXCLUDED.raw_payload" in _IMPORT_SRC
assert "json.dumps" in _IMPORT_SRC
def test_doc_types_param_default_dkp() -> None:
"""doc_types — параметр (было: литерал 'ДКП'), дефолт ['ДКП'] сохраняет прежнее
поведение (только вторичка).
"""
assert "ANY(CAST(:doc_types AS text[]))" in _IMPORT_SRC
assert 'params.get("doc_types") or ["ДКП"]' in _IMPORT_SRC
def test_unknown_region_raises() -> None:
"""region_code без записи в REGIONS → ValueError ДО любого db.execute — прогон
не должен успеть тронуть БД с неизвестным региональным контекстом.
"""
db = MagicMock()
with pytest.raises(ValueError, match="99"):
scheduler.import_rosreestr_dkp(db, run_id=1, params={"region_code": 99})
db.execute.assert_not_called()
def test_dkp_source_for_region() -> None:
"""66 → байт-в-байт прежнее имя _DKP_SOURCE; 77 → суффиксное имя, совпадающее
с source seed-строки миграции 289 (изоляция чекпоинтов между регионами).
"""
assert scheduler._dkp_source_for_region(66) == scheduler._DKP_SOURCE
assert scheduler._dkp_source_for_region(77) == "rosreestr_dkp_import_77"
sql = _MIGRATION_289.read_text("utf-8")
assert "'rosreestr_dkp_import_77'" in sql, "seed-source миграции 289 разъехался с кодом"

View file

@ -0,0 +1,91 @@
"""#3051 п.3 — миграция 289 (FDW-колонки + disabled seed 77) и bash-путь import-rosreestr.sh.
(a) Статические ассерты содержимого миграции 289 seed выключен, region_code=77 в
default_params, идемпотентность (ON CONFLICT DO NOTHING + 3× ADD COLUMN IF NOT EXISTS).
(b) Bash-скрипт НЕ region-generic Python-путь (см. докстринг скрипта): валидация
REGION_CODE/DOC_TYPE обязана отбить инъекцию ДО первого docker-вызова. Проверяем через
subprocess с урезанным PATH (без `docker`) падение должно случиться на валидации
(`[[ ... =~ ... ]]`/bash builtins), а не на "docker: command not found", иначе тест
ничего не защищает.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
_BACKEND_DIR = Path(__file__).resolve().parents[1]
_TRADEIN_DIR = _BACKEND_DIR.parent
_MIGRATION_289 = _BACKEND_DIR / "data" / "sql" / "289_rosreestr_fdw_msk_columns_seed77.sql"
_DEPLOY_SCRIPT = _TRADEIN_DIR / "deploy" / "import-rosreestr.sh"
# ── (a) миграция 289 — статика ───────────────────────────────────────────────────
def test_migration_289_seed_disabled_with_region_code_77() -> None:
sql = _MIGRATION_289.read_text("utf-8")
assert "'rosreestr_dkp_import_77',\n false," in sql, (
"seed-строка обязана быть enabled=false (включение — отдельное решение)"
)
assert '"region_code": 77' in sql
assert "ON CONFLICT (source) DO NOTHING" in sql
# Реальные DDL-строки (отступ 4 пробела) — не считая упоминание "× 3" в шапке-комментарии.
assert sql.count("\n ADD COLUMN IF NOT EXISTS") == 3
# ── (b) деплой-скрипт — валидация раньше docker ──────────────────────────────────
_BASH = shutil.which("bash")
@pytest.mark.skipif(_BASH is None, reason="bash недоступен в PATH (нет git-bash/WSL)")
def test_invalid_region_code_rejected_before_any_docker_call(tmp_path: Path) -> None:
"""REGION_CODE с инъекцией → rc=1, сообщение валидации, PATH без docker — до
первой docker-команды (line 50) скрипт не доходит вовсе.
"""
empty_bin = tmp_path / "empty_bin"
empty_bin.mkdir()
env = dict(os.environ)
env["PATH"] = str(empty_bin)
env["REGION_CODE"] = "77;drop table x"
result = subprocess.run(
[_BASH, str(_DEPLOY_SCRIPT)],
env=env,
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 1
assert "REGION_CODE" in result.stderr
assert result.stdout == "", "stdout пуст — значит ни одна docker-команда не выполнилась"
@pytest.mark.skipif(_BASH is None, reason="bash недоступен в PATH (нет git-bash/WSL)")
def test_invalid_doc_type_rejected_before_any_docker_call(tmp_path: Path) -> None:
"""DOC_TYPE с кавычкой → rc=1, сообщение валидации, до docker не доходит."""
empty_bin = tmp_path / "empty_bin"
empty_bin.mkdir()
env = dict(os.environ)
env["PATH"] = str(empty_bin)
env["DOC_TYPE"] = "x'y"
result = subprocess.run(
[_BASH, str(_DEPLOY_SCRIPT)],
env=env,
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 1
assert "DOC_TYPE" in result.stderr
assert result.stdout == ""

View file

@ -127,6 +127,24 @@ def test_region_by_city_folds_and_resolves() -> None:
assert regions.region_by_city(None) is None
def test_canonical_city_and_region_for_point() -> None:
"""canonical_city: 66 None (byte-for-byte прежнее) / 77 'Москва' (Росреестр отдаёт
округ/поселение, не город). region_for_point резолвит регион по bbox_region
точка в Москве 77, в ЕКБ 66, вне обоих (Пермь) None.
"""
assert regions.REGIONS[66].canonical_city is None
assert regions.REGIONS[77].canonical_city == "Москва"
moscow = regions.region_for_point(55.75, 37.62)
assert moscow is not None and moscow.code == 77
ekb = regions.region_for_point(56.8300, 60.6000)
assert ekb is not None and ekb.code == 66
assert regions.region_for_point(58.01, 56.25) is None # Пермь — вне охвата обоих
def test_unsupported_tier_degrades_loudly_not_silently() -> None:
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
формулировкой, а не молчаливое «посчитаем без источника»."""

View file

@ -0,0 +1,135 @@
"""Секрет из query-строки не попадает в лог процесса (#3154).
Прод-факт: uvicorn access-log печатал полный путь вместе с `?secret=<64 hex>`
(секрет вебхука GlitchTip = `TRADEIN_INTERNAL_AUTH_SECRET`), лог уезжал в Loki и
лежал там открытым. Скруббер Alloy (#3115) ловит только форму `user:pass@host`.
Проверка ПО ЗНАЧЕНИЮ: строка гоняется через настоящий handler с фильтром, в
выводе должно быть `secret=***` и НЕ должно быть самого секрета.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import io
import logging
import pytest
from app.core.log_scrub import QuerySecretFilter, install_query_secret_filter, scrub_query_secrets
_SECRET = "5fc281ce5d1bcf612417dffcae51d82fe5c3db2d7c4b4eac9a1b2c3d4e5f60718"
# Настоящая форма access-строки uvicorn (msg + args, путь лежит в args).
_ACCESS_MSG = '%s - "%s %s HTTP/%s" %d'
def _emit(logger_name: str, msg: str, *args: object) -> str:
"""Прогоняет запись через настоящий handler и возвращает текст строки."""
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s"))
logger = logging.getLogger(logger_name)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
previous_propagate = logger.propagate
logger.propagate = False
try:
logger.info(msg, *args)
finally:
logger.propagate = previous_propagate
logger.removeHandler(handler)
return stream.getvalue()
def test_uvicorn_access_line_masks_secret_query_param() -> None:
install_query_secret_filter("uvicorn.access")
line = _emit(
"uvicorn.access",
_ACCESS_MSG,
"172.18.0.3:60322",
"POST",
f"/api/v1/trade-in/ops/glitchtip-webhook?secret={_SECRET}",
"1.1",
200,
)
assert "secret=***" in line, line
assert _SECRET not in line, line
# Остальная строка цела — иначе access-лог перестал бы годиться для разбора.
assert "POST /api/v1/trade-in/ops/glitchtip-webhook?secret=*** HTTP/1.1" in line
assert "172.18.0.3:60322" in line and "200" in line
def test_app_logger_via_root_handler_masks_secret() -> None:
"""Фильтр стоит и на handler'ах корня — прикладные логгеры тоже закрыты."""
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s"))
handler.addFilter(QuerySecretFilter())
root = logging.getLogger()
root.addHandler(handler)
try:
logging.getLogger("app.some.module").warning(
"retry callback https://gendsgn.ru/hook?token=%s", _SECRET
)
finally:
root.removeHandler(handler)
out = stream.getvalue()
assert "token=***" in out, out
assert _SECRET not in out, out
@pytest.mark.parametrize(
"raw",
[
f"/hook?secret={_SECRET}",
f"/hook?SECRET={_SECRET}",
f"/hook?a=1&token={_SECRET}&b=2",
f"/hook?api_key={_SECRET}",
f"/hook?apiKey={_SECRET}",
f'"GET /hook?access_token={_SECRET} HTTP/1.1"',
# Имя с префиксом: чувствительное слово в КОНЦЕ имени параметра.
f"/hook?client_secret={_SECRET}",
f"/hook?webhook_secret={_SECRET}",
f"/hook?refresh_token={_SECRET}",
f"/hook?auth_token={_SECRET}",
],
)
def test_sensitive_param_names_are_masked(raw: str) -> None:
scrubbed = scrub_query_secrets(raw)
assert _SECRET not in scrubbed, scrubbed
assert "***" in scrubbed
@pytest.mark.parametrize(
"raw",
[
"GET /api/v1/trade-in/offers?limit=50&city=Екатеринбург",
"https://metrics.gendsgn.ru/ingest/loki/api/v1/push",
# Имя НЕ оканчивается на чувствительное слово — маскировать нечего.
"/hook?secretary=anna",
"/hook?tokens_page=2",
# `token-info` в ПУТИ, а не в query: значения там нет вовсе.
"GET /api/v1/token-info?limit=5",
],
)
def test_innocent_lines_untouched(raw: str) -> None:
assert scrub_query_secrets(raw) == raw
def test_filter_installed_by_app_main() -> None:
"""Проводка: импорт приложения ставит фильтр на access-лог uvicorn."""
import app.main # noqa: F401 (импорт ради побочного эффекта установки фильтра)
filters = logging.getLogger("uvicorn.access").filters
assert any(isinstance(f, QuerySecretFilter) for f in filters), filters
def test_install_is_idempotent() -> None:
install_query_secret_filter("uvicorn.access")
install_query_secret_filter("uvicorn.access")
filters = logging.getLogger("uvicorn.access").filters
assert sum(isinstance(f, QuerySecretFilter) for f in filters) == 1, filters

View file

@ -23,10 +23,10 @@ import_rosreestr_dkp (source='rosreestr_dkp_import'), шестой backfill, н
штатным поведением).
- Потолок возраста чекпоинта 24ч (_DKP_CHECKPOINT_STALE_HOURS): старше курсор
считается негодным, прогон стартует с last_id=0, причина 'checkpoint_stale'.
- Вердикт пишется в scrape_runs.counters через kit_runs.update_heartbeat
(`counters || :counters` merge), а не локальный runs_mod.update_heartbeat
(`CAST(:counters AS jsonb)` полная замена): иначе первый же per-batch heartbeat
после старта стирает resume-вердикт.
- Вердикт пишется в scrape_runs.counters через update_heartbeat, а тот counters
МЕРЖИТ (`counters || :counters`) иначе первый же per-batch heartbeat после старта
стёр бы resume-вердикт. На момент #3168 мерж был только у kit-копии, поэтому запись
шла именно kit-именем; с #3390 копия одна и семантика мержа — единственная.
Обратимость (см. PR summary): временный откат last_id на литерал 0 красит
test_resume_continues_from_saved_last_id (last_id == 123456 не совпадает с 0).
@ -140,6 +140,52 @@ def test_no_previous_run_starts_from_zero() -> None:
assert verdict["resume_from"] is None
# ── 1b. Курсор — ПЕР РЕГИОН (#3051 п.3): source изолирует чекпоинты 66/77 ───────────
class _SourceCapturingDb:
"""Двойник: запоминает `source`-bind ПОСЛЕДНЕГО SELECT-запроса (_DKP_RESUME_CANDIDATE_SQL).
Отличает SELECT (несёт 'source' в params) от heartbeat-UPDATE (несёт 'counters')
тем же приёмом, что _FakeDb выше.
"""
def __init__(self, row: Any) -> None:
self.row = row
self.select_params: dict[str, Any] | None = None
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
if params is not None and "source" in params:
self.select_params = params
return MagicMock(fetchone=lambda: self.row)
return MagicMock()
def commit(self) -> None:
pass
def test_resume_uses_region_source() -> None:
"""source= (#3051 п.3) — курсор региона 77 изолирован от курсора региона 66:
явный source попадает bind-параметром в SELECT-кандидата; вызов без аргумента
сохраняет прежнее имя _DKP_SOURCE (регион 66, обратная совместимость). Сам
call site (import_rosreestr_dkp) обязан передавать именно региональный source,
не полагаться на дефолт молча.
"""
db_77 = _SourceCapturingDb(None)
sched._resume_dkp_cursor(db_77, run_id=1, source="rosreestr_dkp_import_77")
assert db_77.select_params is not None
assert db_77.select_params["source"] == "rosreestr_dkp_import_77"
db_default = _SourceCapturingDb(None)
sched._resume_dkp_cursor(db_default, run_id=2)
assert db_default.select_params is not None
assert db_default.select_params["source"] == sched._DKP_SOURCE
import_src = inspect.getsource(sched.import_rosreestr_dkp)
assert "source=dkp_source" in import_src, "call site не передаёт региональный source"
# ── 2. Курсор старше потолка не подхватывается — прогон стартует заново ─────────────
@ -163,20 +209,15 @@ def test_checkpoint_just_under_ceiling_is_still_accepted() -> None:
# ── 3. Запись курсора не затирает посторонние ключи в counters (мерж, не замена) ────
#
# Текстового гейта на имя `kit_runs.update_heartbeat` здесь больше нет (#3390): пока копий
# было две, имя выбирало семантику, и проверять его по тексту имело смысл. Теперь
# `app.services.scrape_runs` — алиас kit'а, оба имени дают ОДИН объект, и гейт краснел бы
# на переименовании, ничего при этом не защищая. Мерж проверяется по значению — тестом
# ниже и test_3390_single_runs_module.py (оба пути импорта, heartbeat + финализатор).
def test_cursor_write_uses_merge_not_replace_heartbeat() -> None:
"""import_rosreestr_dkp обязан писать чекпоинт через kit_runs.update_heartbeat
(merge: `counters || :counters`), а не локальный runs_mod.update_heartbeat (замена:
`CAST(:counters AS jsonb)`) иначе resume-вердикт, записанный ДО цикла, стирается
первым же per-batch heartbeat'ом того же прогона.
"""
src = inspect.getsource(sched.import_rosreestr_dkp)
assert "kit_runs.update_heartbeat" in src
assert "runs_mod.update_heartbeat" not in src
def test_kit_runs_update_heartbeat_merges_into_existing_counters() -> None:
def test_update_heartbeat_merges_into_existing_counters() -> None:
"""Сама примитива слияния: второй write добавляет rows_fetched/last_id, НЕ стирая
resume_from/resume_reason, записанные первым write'ом.

View file

@ -0,0 +1,158 @@
"""Недогруженная карточка Яндекса — отказ, а не успех (#3191).
Наблюдение 2026-08-28 (три карточки, прод-сайдкар, аноним):
offer 52275656 3 920 119 б encryptedPhones 65
offer 62983459 1 805 556 б encryptedPhones 0 <- недогруз
offer 12108408 4 732 884 б encryptedPhones 65
Средняя вернулась вдвое меньше нормы и БЕЗ блока контактов. Не капча и не ошибка:
HTTP 200, HTML валиден, `window.INITIAL_STATE` на месте, парсер отрабатывает.
Единственная проверка размера в провайдере (`newbuilding.py`, `len(html) < 500`)
отвечает на вопрос «пришло ли хоть что-то» 1,8 МБ проходит её в 3600 раз, и
частичная карточка уезжала в БД с `detail_enriched_at`, выбывая из очереди навсегда.
Проверка ПО ЗНАЧЕНИЮ: сравниваются счётчики и факт вызова записи, а не «не бросило
исключение». Первый кейс недогруз (карточка НЕ должна сохраниться), второй
регресс на полной странице (сохраняется как раньше). Тождество attempted (#3338)
проверяется в обоих: incomplete failed, как blocked.
"""
from __future__ import annotations
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.tasks.yandex_detail_backfill import ( # noqa: E402
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
# Размеры взяты с наблюдения: недогруз 1,8 МБ (порог в 1 МБ проходит — ловит его
# именно отсутствие блока контактов), полная карточка 3,9 МБ. Маркер написан здесь
# литералом НАРОЧНО: тест не должен падать на импорте, когда правку снимают —
# фальсификация обязана краснеть по значению («карточка успешна»), а не по ImportError.
CONTACTS_MARKER = "encryptedPhones"
UNDERRENDERED_HTML = "<html>" + "x" * 1_805_556 + "</html>"
FULL_HTML = "<html>" + "x" * 3_920_119 + f'"{CONTACTS_MARKER}":["a","b"]</html>'
def _mock_db(n: int) -> MagicMock:
snapshot = [
{"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0)
db.execute.return_value = sel
return db
def _resp(html: str) -> MagicMock:
resp = MagicMock()
resp.status_code = 200
resp.text = html
return resp
async def _run(html: str) -> tuple[YandexDetailBackfillResult, MagicMock]:
"""Один прогон на одну карточку. parse НАРОЧНО отдаёт разобранную модель:
именно так и было на проде частичная страница парсится успешно."""
save = MagicMock(return_value=True)
db = _mock_db(1)
session = AsyncMock()
session.get = AsyncMock(side_effect=[_resp(html)])
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
with (
patch(_ASYNC_SESSION, MagicMock(return_value=ctx)),
patch(_PARSE, return_value=MagicMock()),
patch(_SAVE, save),
patch(_RUNS, MagicMock()),
patch(_SLEEP, new_callable=AsyncMock),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
counters = await run_yandex_detail_backfill(
db, run_id=3191, params={"batch_size": 1, "budget_sec": 3600}
)
return counters, save
@pytest.mark.asyncio
async def test_underrendered_page_is_refusal_not_success() -> None:
"""1,8 МБ без блока контактов → incomplete, detail_enriched_at не проставлен."""
counters, save = await _run(UNDERRENDERED_HTML)
# save_detail_enrichment — единственный писатель detail_enriched_at (UPDATE ...
# detail_enriched_at = NOW()). Не вызван = карточка осталась в очереди, её
# возьмёт следующий снапшот (WHERE detail_enriched_at IS NULL).
save.assert_not_called()
assert (counters.enriched, counters.incomplete, counters.failed) == (0, 1, 1), (
f"enriched={counters.enriched} incomplete={counters.incomplete} "
f"failed={counters.failed}: недогруз обязан быть отказом, а не успехом"
)
assert counters.blocked == 0, (
f"blocked={counters.blocked}: площадка ответила HTTP 200 — это не блок"
)
# Тождество #3338: incomplete ⊆ failed, в сумму исходов входит только failed.
assert counters.attempted == counters.enriched + counters.failed == 1
@pytest.mark.asyncio
async def test_full_page_still_enriches() -> None:
"""Регресс: полная карточка сохраняется как раньше."""
counters, save = await _run(FULL_HTML)
save.assert_called_once()
assert (counters.enriched, counters.incomplete, counters.failed) == (1, 0, 0), (
f"enriched={counters.enriched} incomplete={counters.incomplete} "
f"failed={counters.failed}: полная страница должна обогащать как раньше"
)
assert counters.attempted == counters.enriched + counters.failed == 1
def test_reason_names_the_two_signals() -> None:
"""Признака два, и любой из них — отказ."""
from scraper_kit.providers.yandex.detail import detail_incomplete_reason
assert detail_incomplete_reason(FULL_HTML) is None
# Структурный: 1,8 МБ проходит размерный порог, но контактов нет.
assert CONTACTS_MARKER in (detail_incomplete_reason(UNDERRENDERED_HTML) or "")
# Размерный: контакты есть, а страница обрезана.
reason = detail_incomplete_reason(f'"{CONTACTS_MARKER}":[]', min_html_bytes=1_000_000)
assert reason is not None and "недогружена" in reason
def test_redirect_phones_alone_counts_as_complete() -> None:
"""Карточка «без телефона / только чат»: redirectPhones есть, encryptedPhones нет.
Класс в замер #3192 не попадал (там у всех 12 были оба ключа). Требуя именно
encryptedPhones, мы браковали бы такие карточки вечно они возвращаются в очередь,
а ключ у них не появится. Маркеры литералами: правку снимут тест обязан краснеть
по значению («карточка отбракована»), а не по ImportError.
"""
from scraper_kit.providers.yandex.detail import detail_incomplete_reason
body = "<html>" + "x" * 3_920_119
assert detail_incomplete_reason(body + '"redirectPhones":["a"]</html>') is None
# Фальсификация: без ОБОИХ ключей та же страница полного размера — недогруз.
assert detail_incomplete_reason(body + "</html>") is not None

View file

@ -87,10 +87,16 @@ def _mock_db(n: int) -> MagicMock:
return db
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруз теперь отсеивается до
# parse, и страница-заглушка превратила бы эти кейсы в тесты про полноту, а не про
# природу блока. Маркер контактов + размер выше порога — оба признака полноты.
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = "<html>отказ</html>"
resp.text = _FULL_PAGE_HTML if status == 200 else "<html>отказ</html>"
return resp

View file

@ -0,0 +1,248 @@
"""#3197 (хвост) — две служебные ручки admin.py и пул прокси: одна чинится, вторая НЕТ.
`POST /admin/scrape/domclick/debug/detail-fetch` **настоящий фикс**. Он ходит через
`BrowserFetcher.fetch`, а `fetch`/`fetch_json` единственные методы, которые кладут
`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает
(`_resolve_proxy_override` `_ensure_browser(provider, proxy_override=...)`). Прямая
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
`proxy_provider`/`use_pool`/`environment` без них сайдкар брал свой env-узел
(`SCRAPER_PROXY_URL`, на проде это выключенный узел 9: 407 camoufox `InvalidIP`), а
прод-отказ «пул пуст не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
`POST /admin/scrape/cian/auto-login` **намеренно без пула**, и это проверяется обратными
по значению утверждениями. Логин идёт не через `fetch`, а через `login` `_post_login`,
который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler`
(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и
`_ensure_browser(provider)` без override то есть **сайдкар на `/login` proxy-override не
принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы
холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а
при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST
и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во
время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся
ради endpoint/environment из одного места); пул для логина отдельная задача сайдкара.
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
сейчас такого имени в модуле нет). Дубль нужен, чтобы файл был запускаем и против прежних
состояний исходников: красное тогда читается как «значение не то», а не «мок не сработал».
Стаб cian ПОДКЛАСС настоящего `BrowserFetcher`: `__aenter__`/`_acquire_lease` исполняются
по-настоящему (иначе тест на «нет ложного 502» проверял бы заглушку), застаблен только
сетевой `login`.
Сеть/БД/камуфокс замоканы; в сеть тест не ходит.
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from types import SimpleNamespace
from typing import Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import HTTPException
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.contracts import ProxyLease
from app.api.v1 import admin
from app.services import cian_session as cian_session_svc
_TARGETS = ("scraper_kit.providers._base.BrowserFetcher", "app.api.v1.admin.BrowserFetcher")
class _CapturingFetcher:
"""Собирает kwargs каждой конструкции; login() отдаёт валидный набор куки."""
captured: ClassVar[list[dict[str, Any]]] = []
def __init__(self, **kwargs: Any) -> None:
_CapturingFetcher.captured.append(kwargs)
async def __aenter__(self) -> _CapturingFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def login(self, **_kwargs: Any) -> dict[str, str]:
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
class _RealLeaseFetcher(BrowserFetcher):
"""Настоящий `BrowserFetcher` (реальные `__aenter__`/`_acquire_lease`) без сети.
Наследование, а не заглушка: утверждение «пустой пул в production не роняет ручку»
обязано пройти через тот самый guard `_acquire_lease`, который поднимает
`NoProxyAvailableError`. Подменён только сетевой `login`.
"""
captured: ClassVar[list[dict[str, Any]]] = []
def __init__(self, **kwargs: Any) -> None:
_RealLeaseFetcher.captured.append(kwargs)
super().__init__(**kwargs)
async def login(self, **_kwargs: Any) -> dict[str, str]:
return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
class _SpyProvider:
"""Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None.
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
и «ни разу не позвали» должно покрывать в том числе «даже не создали».
"""
acquired: ClassVar[list[str]] = []
released: ClassVar[list[int]] = []
def acquire(self, provider: str) -> ProxyLease | None:
_SpyProvider.acquired.append(provider)
return None
def release(self, lease: ProxyLease) -> None:
_SpyProvider.released.append(lease.id)
def mark_health(self, lease: ProxyLease, *, ok: bool, error: str | None = None) -> None:
return None
@pytest.fixture
def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None:
"""Curl-флаг включён всегда: это он открывает `_kit_proxy_provider()` (гейт #2163).
Провайдер обязан доезжать до конструктора при ЛЮБОМ значении browser-флага
`use_pool=False` фетчер его просто игнорирует, но call-site у dev и прода один.
"""
from app.core.config import settings
monkeypatch.setattr(settings, "use_proxy_pool_curl", True)
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
_CapturingFetcher.captured = []
_RealLeaseFetcher.captured = []
_SpyProvider.acquired = []
_SpyProvider.released = []
def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None:
from app.core.config import settings
monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool)
monkeypatch.setattr(settings, "environment", env)
monkeypatch.setattr(settings, "cookie_encryption_key", "k" * 32)
monkeypatch.setattr(settings, "cian_login_email", "a@b.c")
monkeypatch.setattr(settings, "cian_login_password", "pw")
def _patched_cian_login(stack: ExitStack, fetcher_cls: type) -> None:
for target in _TARGETS:
stack.enter_context(patch(target, fetcher_cls, create=True))
stack.enter_context(
patch.object(
cian_session_svc, "verify_session", AsyncMock(return_value={"user": {"userId": 7}})
)
)
stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock()))
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
@pytest.mark.usefixtures("_pool_on")
async def test_cian_auto_login_does_not_lease_from_pool(
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
) -> None:
"""Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг)."""
_cian_login_settings(monkeypatch, use_pool=use_pool, env=environment)
with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher)
result = await admin.cian_auto_login(db=MagicMock(), body=None)
assert result["ok"] is True and result["userId"] == 7
assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
kwargs = _RealLeaseFetcher.captured[0]
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен"
assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)"
# endpoint из фабрики не должен потеряться (#2322: без него TypeError).
from app.core.config import settings
assert kwargs.get("endpoint") == settings.browser_http_endpoint
@pytest.mark.usefixtures("_pool_on")
async def test_cian_auto_login_survives_empty_pool_in_production(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502).
Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при
`use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`,
`cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` то
есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию,
хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает).
"""
_cian_login_settings(monkeypatch, use_pool=True, env="production")
with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher)
try:
result = await admin.cian_auto_login(db=MagicMock(), body=None)
except HTTPException as exc:
# pytest.fail, а не re-raise: красное должно называть статус и detail.
pytest.fail(f"пустой пул уронил ручку восстановления: {exc.status_code} {exc.detail}")
assert result["ok"] is True and result["userId"] == 7
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
@pytest.mark.usefixtures("_pool_on")
async def test_domclick_debug_detail_wires_proxy_pool(
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
) -> None:
"""Debug-карточка DomClick берёт узел с provider_affinity='domclick', а не env."""
from scraper_kit.providers.domclick import detail as domclick_detail
from app.core.config import settings
monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool)
monkeypatch.setattr(settings, "environment", environment)
enrichment = SimpleNamespace(
item_id="1",
repair_state=None,
living_area_m2=None,
year_built=None,
price_changes=[],
raw_extra={},
)
body = admin.DomClickDebugDetailFetchRequest(
card_url="https://ekaterinburg.domclick.ru/card/sale__flat__1"
)
with ExitStack() as stack:
for target in _TARGETS:
stack.enter_context(patch(target, _CapturingFetcher, create=True))
stack.enter_context(
patch.object(domclick_detail, "fetch_detail", AsyncMock(return_value=enrichment))
)
stack.enter_context(
patch("app.services.domclick_session.load_session", MagicMock(return_value=None))
)
result = await admin.debug_domclick_detail_fetch(body=body, db=MagicMock())
assert result.ok is True
assert len(_CapturingFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
kwargs = _CapturingFetcher.captured[0]
assert kwargs["source"] == "domclick"
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
assert kwargs.get("proxy_provider") is not None, "без провайдера пул не подключится"
assert kwargs.get("use_pool") is use_pool, "флаг пула должен доезжать из конфига"
# #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв.
assert kwargs.get("environment") == environment
# endpoint не должен потеряться при переезде на фабрику (#2322: без него TypeError).
from app.core.config import settings
assert kwargs.get("endpoint") == settings.browser_http_endpoint

View file

@ -0,0 +1,228 @@
"""#3197 (часть 1, Циан) — суточный бэкфилл ходил в сайдкар мимо прокси-пула.
`BrowserFetcher(source="cian", endpoint=...)` конструировался БЕЗ
`proxy_provider`/`use_pool`/`environment` единственных трёх аргументов, которые
кладут "proxy" в тело POST /fetch (см. `scraper_kit.browser_fetcher`). Без них сайдкар
брал свой env-прокси (`SCRAPER_PROXY_URL`): ни выбора узла из пула, ни
`scrape_proxy_source_bans`, ни ротации, а прод-отказ «пул пуст не ходить на
env/direct» (#2616) на этом пути был мёртв, потому что смотрит на `environment`, который
до конструктора не доезжал. Соседи уже починены: domclick (#3197 ч.1, см.
test_3197_domclick_proxy_pool_wiring.py) и house_imv/avito (#2698).
Остальные тесты про то, чем оживший отказ оборачивается в прогоне: пустой пул
поднимается ДО запроса, поэтому следующее объявление упрётся ровно в то же самое, и
батч обязан оборваться на первом, а не крутить весь список. Подделка у них на уровне
`BrowserFetcher.fetch()`, НАСТОЯЩИЙ `fetch_detail`: его браузерная ветка глотала любое
исключение и возвращала None, поэтому стоп в задаче не срабатывал ни разу, а прогон,
не ходивший к площадке, финализировался как 'done' (#3197, ревью PR #3382).
Сеть/БД/камуфокс замоканы; в сеть тест не ходит.
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from types import SimpleNamespace
from typing import Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import pytest
from scraper_kit.providers.cian.detail import fetch_detail
from scraper_kit.proxy_errors import NoProxyAvailableError
from app.tasks import cian_history_backfill as chb
_FETCH = "app.tasks.cian_history_backfill.fetch_detail"
_SAVE = "app.tasks.cian_history_backfill.save_detail_enrichment"
_SLEEP = "app.tasks.cian_history_backfill.asyncio.sleep"
_DELAY = "app.tasks.cian_history_backfill.get_scraper_delay"
class _CapturingFetcher:
"""Зеркалит _CapturingFetcher из test_3197_domclick_proxy_pool_wiring.py."""
captured: ClassVar[dict[str, Any]] = {}
def __init__(self, **kwargs: Any) -> None:
_CapturingFetcher.captured = kwargs
self.last_response_status: int | None = None
async def __aenter__(self) -> _CapturingFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
class _EmptyPoolFetcher:
"""BrowserFetcher, у которого пул опустел: `fetch()` поднимает `NoProxyAvailableError`.
Ровно как в проде (`browser_fetcher.py:682`): отказ поднимается ДО POST в сайдкар,
запроса к площадке не было. Подделка кончается здесь `fetch_detail` настоящий.
"""
calls: ClassVar[list[str]] = []
def __init__(self, **_kwargs: Any) -> None:
self.last_response_status: int | None = None
async def __aenter__(self) -> _EmptyPoolFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def fetch(self, url: str, **_kwargs: Any) -> str:
_EmptyPoolFetcher.calls.append(url)
raise NoProxyAvailableError("cian")
class _RaisingFetcher:
"""Фетчер с заданным исключением — для прямых тестов контракта `fetch_detail`."""
def __init__(self, exc: BaseException) -> None:
self.exc = exc
self.last_response_status: int | None = None
async def fetch(self, _url: str, **_kwargs: Any) -> str:
raise self.exc
def _mock_db(n_rows: int) -> MagicMock:
rows = [
{"id": i + 1, "source_url": f"https://ekb.cian.ru/sale/flat/{i + 1}/"}
for i in range(n_rows)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = rows
db.execute.return_value = sel
return db
async def _run_listings(
db: MagicMock, fetch: Any = None, fetcher: type = _CapturingFetcher
) -> chb.CianBackfillResult:
"""Прогон listings-блока. `fetch=None` ⇒ `fetch_detail` НЕ подменяется (рабочий тракт)."""
patches = [
patch.object(chb, "BrowserFetcher", fetcher),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
patch(_DELAY, return_value=0.0),
]
if fetch is not None:
patches.append(patch(_FETCH, fetch))
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return await chb.backfill_cian_history(
db, batch_size=10, do_listings=True, do_houses=False, do_valuations=False
)
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
async def test_browser_fetcher_gets_proxy_pool_wiring(
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
) -> None:
"""use_pool/proxy_provider/environment доезжают до BrowserFetcher ИЗ КОНФИГА.
Оба значения флага проверяются одним телом: `use_pool` обязан следовать конфигу, а не
быть зашитой константой, а `proxy_provider` передаётся в любом случае при
`use_pool=False` он игнорируется фетчером, но call-site у dev и прода один и тот же.
"""
_CapturingFetcher.captured = {}
monkeypatch.setattr(chb.settings, "use_proxy_pool_browser", use_pool)
monkeypatch.setattr(chb.settings, "environment", environment)
fetch = AsyncMock(return_value=SimpleNamespace(price_changes=[]))
await _run_listings(_mock_db(1), fetch)
captured = _CapturingFetcher.captured
assert captured["source"] == "cian"
assert captured["endpoint"] == chb.settings.browser_http_endpoint
# .get(), а не [] — красное должно читаться как «значение не то», а не как KeyError.
assert captured.get("proxy_provider") is not None, "без провайдера пул не подключится"
assert captured.get("use_pool") is use_pool, "флаг пула должен доезжать до фетчера из конфига"
# #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв.
assert captured.get("environment") == environment
async def test_empty_pool_stops_the_batch_on_first_listing() -> None:
"""«Пул пуст» на первом объявлении обрывает батч, а не крутит весь список.
Замокано: БД (`_mock_db`), `save_detail_enrichment`, `asyncio.sleep`, задержка и
сам `BrowserFetcher` подделка кончается на `fetch()`, который поднимает
`NoProxyAvailableError` вместо HTTP-запроса в сайдкар.
НЕ замокан `fetch_detail` прогон идёт через настоящую браузерную ветку
`providers/cian/detail.py`. Прежняя редакция подменяла `fetch_detail` моком,
который сам поднимал исключение, и была зелёной по построению: живая функция
исключение ГЛОТАЛА и возвращала None, поэтому стоп не срабатывал ни разу
(ревью PR #3382 — прогон, не ходивший к площадке, уходил в 'done').
"""
_EmptyPoolFetcher.calls = []
result = await _run_listings(_mock_db(3), fetcher=_EmptyPoolFetcher)
# Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате
# должно означать «прошли 3 строки вместо 1», а не «поля нет».
assert _EmptyPoolFetcher.calls == ["https://ekb.cian.ru/sale/flat/1/"], (
"к сайдкару ходили ровно один раз — пул пуст с первого объявления"
)
assert result.listings_processed == 1, "батч обязан оборваться, а не пройти все 3 строки"
assert getattr(result, "no_proxy_stop", False) is True
# Отказ НАШЕЙ стороны не должен маскироваться под бан площадки (иначе прогон уйдёт
# в 'banned' и запись соврёт про Циан).
assert result.ban_kinds == {}
assert result.listings_blocked == 0
async def test_fetch_detail_propagates_empty_pool() -> None:
"""`fetch_detail` пробрасывает `NoProxyAvailableError`, а не гасит его в None.
Прямой контракт общей функции: на None вызывающий отвечает «не разобрали» и идёт
к следующему объявлению то есть глотание здесь отменяет стоп у ВСЕХ вызывающих
браузерного пути. Соседний `app/services/cian_price_history.py:143` того же типа
ждёт отдельным `except`.
"""
fetcher = _RaisingFetcher(NoProxyAvailableError("cian"))
with pytest.raises(NoProxyAvailableError):
await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type]
async def test_fetch_detail_still_returns_none_on_transport_error() -> None:
"""Прежний контракт цел: обычный сбой фетча по-прежнему None, а не исключение.
Иначе «пробрасываем пустой пул» превратилось бы в «пробрасываем всё», и каждый
таймаут сайдкара рвал бы батч вместо одной пропущенной карточки.
"""
fetcher = _RaisingFetcher(httpx.ConnectTimeout("sidecar timeout"))
got = await fetch_detail("https://ekb.cian.ru/sale/flat/1/", browser_fetcher=fetcher) # type: ignore[arg-type]
assert got is None
async def test_run_marked_failed_with_no_proxy_stop_counter() -> None:
"""Прогон с пустым пулом финализируется как failed + counters.no_proxy_stop=1."""
from app.services import scheduler as sched
runs = MagicMock()
result = chb.CianBackfillResult(
listings_total=3, listings_processed=1, listings_failed_fetch=1, no_proxy_stop=True
)
with (
patch.object(sched, "runs_mod", runs),
patch.object(chb, "backfill_cian_history", AsyncMock(return_value=result)),
):
await sched._execute_cian_backfill(MagicMock(), run_id=3197, params={"batch_size": 3})
runs.mark_done.assert_not_called()
runs.mark_banned.assert_not_called()
runs.mark_failed.assert_called_once()
_db, run_id, reason, counters = runs.mark_failed.call_args.args
assert run_id == 3197
assert "пул" in reason
assert counters["no_proxy_stop"] == 1

View file

@ -0,0 +1,179 @@
"""#3197 (часть 1, Яндекс-новостройки) — sweep ходил в сайдкар мимо прокси-пула.
Обе точки провайдера (`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug`)
конструировали `BrowserFetcher(source="yandex", endpoint=...)` БЕЗ
`proxy_provider`/`use_pool`/`environment` единственных трёх аргументов, которые кладут
"proxy" в тело POST /fetch. Без них сайдкар брал свой env-прокси (`SCRAPER_PROXY_URL`),
на проде это выключенный узел (407 camoufox `InvalidIP` `/fetch` 503, факт #3386),
а прод-отказ «пул пуст не ходить на env/direct» (#2616) на этом пути был мёртв: он
смотрит на `environment`, который до конструктора не доезжал. Соседи уже починены:
cian (#3197, test_3197_cian_history_proxy_pool_wiring.py) и domclick/avito.
Второй тест про то, чем оживший отказ оборачивается в прогоне: пустой пул поднимается
ДО запроса, поэтому следующий ЖК упрётся ровно в то же самое, и прогон обязан оборваться
на первом, а не перебирать весь список. Подделка на уровне `BrowserFetcher.fetch()`,
НАСТОЯЩИЕ `resolve_yandex_jk_slug`/`fetch_jk`: их `except Exception` глотал любое
исключение и возвращал None, поэтому стоп в задаче не сработал бы ни разу (образец
дефекта #3382, `cian/detail.py`).
Сеть/БД/камуфокс замоканы; в сеть тест не ходит.
"""
from __future__ import annotations
import os
from typing import Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from scraper_kit.proxy_errors import NoProxyAvailableError
from app.tasks import yandex_newbuilding_sweep as sweep
_BASE_FETCHER = "scraper_kit.providers._base.BrowserFetcher"
class _CapturingFetcher:
"""Собирает kwargs КАЖДОЙ конструкции — обе точки (resolve и fetch_jk) видны разом."""
captured: ClassVar[list[dict[str, Any]]] = []
def __init__(self, **kwargs: Any) -> None:
_CapturingFetcher.captured.append(kwargs)
async def __aenter__(self) -> _CapturingFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def fetch(self, _url: str, **_kwargs: Any) -> str:
# Короткий HTML: resolve не найдёт slug, fetch_jk отбракует как tiny — обе
# ветки идут дальше по циклу, а нам нужны только kwargs конструктора.
return "<html></html>"
class _EmptyPoolFetcher:
"""Фетчер с пустым пулом: `fetch()` поднимает `NoProxyAvailableError` ДО запроса."""
calls: ClassVar[list[str]] = []
def __init__(self, **_kwargs: Any) -> None:
pass
async def __aenter__(self) -> _EmptyPoolFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def fetch(self, url: str, **_kwargs: Any) -> str:
_EmptyPoolFetcher.calls.append(url)
raise NoProxyAvailableError("yandex")
def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
db = MagicMock()
res = MagicMock()
res.scalar_one.return_value = 0 # counts
res.mappings.return_value.all.return_value = rows
res.fetchone.return_value = None # idempotency-проба: ещё не обогащён
db.execute.return_value = res
return db
def _row(house_id: int, *, slug: str | None, ext_id: str | None) -> dict[str, Any]:
return {"house_id": house_id, "yandex_jk_slug": slug, "ext_id": ext_id}
@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])
async def test_browser_fetcher_gets_proxy_pool_wiring(
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str
) -> None:
"""use_pool/proxy_provider/environment доезжают до BrowserFetcher ИЗ КОНФИГА.
Оба значения флага одним телом: `use_pool` обязан следовать конфигу, а не быть
зашитой константой, `proxy_provider` передаётся в любом случае (при use_pool=False
фетчер его игнорирует, но call-site у dev и прода один).
Строки подобраны так, чтобы прогон прошёл ОБЕ точки: дом без slug идёт в
`resolve_yandex_jk_slug`, дом со slug в `fetch_jk`.
"""
from app.core.config import settings
_CapturingFetcher.captured = []
monkeypatch.setattr(settings, "use_proxy_pool_browser", use_pool)
monkeypatch.setattr(settings, "environment", environment)
rows = [_row(1, slug=None, ext_id="111"), _row(2, slug="tatlin", ext_id="222")]
with patch(_BASE_FETCHER, _CapturingFetcher):
await sweep.enrich_yandex_newbuilding_sweep(_mock_db(rows), limit=2, request_delay_sec=0.0)
captured = _CapturingFetcher.captured
assert len(captured) == 2, "обе точки (resolve и fetch_jk) обязаны строить фетчер"
for kwargs in captured:
assert kwargs["source"] == "yandex"
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
assert kwargs.get("proxy_provider") is not None, "без провайдера пул не подключится"
assert kwargs.get("use_pool") is use_pool, "флаг пула должен доезжать из конфига"
# #2616 шаг 1: без environment отказ «пул пуст» на этом пути мёртв.
assert kwargs.get("environment") == environment
@pytest.mark.parametrize(
("rows", "url_part"),
[
([_row(i, slug=None, ext_id=str(i)) for i in (1, 2, 3)], "-1/"),
([_row(i, slug=f"jk{i}", ext_id=str(i)) for i in (1, 2, 3)], "jk1-1/"),
],
ids=["resolve", "fetch_jk"],
)
async def test_empty_pool_stops_the_sweep_on_first_house(
rows: list[dict[str, Any]], url_part: str
) -> None:
"""«Пул пуст» на первом доме обрывает прогон, а не перебирает все три.
Замокано: БД и сам `BrowserFetcher` подделка кончается на `fetch()`. НЕ замоканы
`resolve_yandex_jk_slug`/`fetch_jk`: прогон идёт через настоящие функции провайдера,
иначе тест был бы зелёным по построению (живые функции исключение ГЛОТАЛИ, и стоп
не срабатывал ровно дефект #3382).
"""
_EmptyPoolFetcher.calls = []
with patch(_BASE_FETCHER, _EmptyPoolFetcher):
result = await sweep.enrich_yandex_newbuilding_sweep(
_mock_db(rows), limit=3, request_delay_sec=0.0
)
# Сначала измеримое поведение (сколько раз пошли), потом флаг: красное на откате
# должно означать «прошли 3 дома вместо 1», а не «поля нет».
assert len(_EmptyPoolFetcher.calls) == 1, "к сайдкару ходили ровно один раз"
assert _EmptyPoolFetcher.calls[0].endswith(url_part)
assert result.processed == 1, "прогон обязан оборваться, а не пройти все 3 дома"
assert getattr(result, "no_proxy_stop", False) is True
async def test_scheduler_marks_run_failed_on_empty_pool() -> None:
"""Прогон с пустым пулом финализируется как failed, а не 'done' с честным нулём."""
from app.services import product_handlers as ph
ctx = MagicMock()
result = sweep.YandexNewbuildingSweepResult(processed=1, failed_fetch=1, no_proxy_stop=True)
with patch(
"app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep",
AsyncMock(return_value=result),
):
await ph._job_yandex_newbuilding_sweep(MagicMock(), 3197, {"limit": 3}, ctx)
ctx.runs.mark_done.assert_not_called()
ctx.runs.mark_failed.assert_called_once()
_db, run_id, reason, counters = ctx.runs.mark_failed.call_args.args
assert run_id == 3197
assert "пул" in reason
# Ровно 1, а не JSON true: SQL-монитор соседей ищет counters->>'no_proxy_stop' = '1'
# (scheduler.py:168 cian, avito_detail_backfill.py:1043, domclick_detail_backfill.py:610).
# bool — подкласс int, поэтому `== 1` один сам по себе зелёный и на True: тип проверяем
# отдельно, иначе красное на откате не наступит.
assert counters["no_proxy_stop"] == 1 and not isinstance(counters["no_proxy_stop"], bool)

View file

@ -0,0 +1,180 @@
"""Дом-поля карточки Домклика доезжают до houses (#3253).
Парсер читал houseInfo.info (тип стен/перекрытий, число квартир) и складывал всё
в listings.raw_payload, а в houses не переносил: на проде 400 домов-domklik против
3061 объявления, а houses.total_units = 0 у ВСЕХ источников при том, что
quarters_count уже лежал в собранных payload'ах.
(а) Значение: карточка с houseInfo второй оператор получает total_units /
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
(б) Непустое значение дома не затирается пустым из карточки COALESCE в обе
стороны: и в SET, и в WHERE-гейте.
(в) Словарь: в material_walls едет КАНОН ДОМ.РФ («монолит»), а не сырая фраза
площадки («Монолитно-кирпичный») иначе колонка становится двухсловарной.
"""
from __future__ import annotations
import json
import os
from types import SimpleNamespace
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.domclick.detail import (
DomClickDetailEnrichment,
canon_floor_type,
canon_wall_type,
parse_detail_html,
save_detail_enrichment,
)
_STATE = {
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
"houseInfo": {
"info": {
"wallType": "Монолитно-кирпичный",
"floorType": "Железобетонный",
"quartersCount": 168,
"entranceCount": 4,
"buildingSeries": "II-68",
"energyEfficiency": "B",
}
},
}
_HTML = (
"<html><body><script>window.__SSR_STATE__ = "
f"{json.dumps(_STATE, ensure_ascii=False)};</script></body></html>"
)
class _FakeNested:
def __enter__(self) -> None:
return None
def __exit__(self, *exc: object) -> bool:
return False
class _FakeSession:
"""Пишет все execute(stmt, params); rowcount=1 у каждого оператора.
fetchone() None: UPDATE listings отдаёт текущую цену через RETURNING она нужна
гейту сдвигов разряда (#3376) как второй свидетель. Этой фикстуре цена не нужна,
«строки нет» гейт трактует как отсутствие свидетеля и серию не трогает; факт
«листинг найден» здесь по-прежнему приходит из rowcount, а не из RETURNING.
"""
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
self.calls.append((str(stmt), params or {}))
return SimpleNamespace(rowcount=1, fetchone=lambda: None)
def begin_nested(self) -> _FakeNested:
return _FakeNested()
def commit(self) -> None:
return None
def _house_call(db: _FakeSession) -> dict[str, Any]:
for sql, params in db.calls:
if "UPDATE houses" in sql:
return params
raise AssertionError(f"UPDATE houses не исполнен; операторы: {[s[:40] for s, _ in db.calls]}")
def test_house_fields_reach_houses_upsert() -> None:
"""(а)+(в) Значения из фикстуры доезжают до апсерта дома — канонизированными."""
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
assert e.raw_extra["quarters_count"] == 168
# В raw_payload сырая фраза площадки остаётся как есть — канон только в колонках.
assert e.raw_extra["wall_type"] == "Монолитно-кирпичный"
db = _FakeSession()
save_detail_enrichment(db, 555, e) # type: ignore[arg-type]
params = _house_call(db)
assert params["quarters_count"] == 168
assert params["wall_type"] == "монолит"
assert params["floor_type"] == "Железобетонные"
assert params["lid"] == 555
# ── (в) канонизация словаря (#3253) ───────────────────────────────────────────
# houses.material_walls на проде уже заполнена словарём ДОМ.РФ: кирпич 2662,
# железобетонная панель 2107, иное 1850, монолит 754. Домклик пишет свой словарь.
@pytest.mark.parametrize(
("raw", "expected"),
[
("Кирпичный", "кирпич"), # 2241 карточка на проде
("Панельный", "железобетонная панель"), # 1671
("Монолитный", "монолит"), # 2656
("Монолитно-кирпичный", "монолит"), # 773
("Блочный", "иное"),
("Деревянный", "иное"),
(" монолитный ", "монолит"), # регистр и пробелы не мешают
("Саманный", None), # незнакомое — сырьё в колонку НЕ пишем
("", None),
(None, None),
(42, None), # не-строка из JSON
],
)
def test_canon_wall_type_maps_to_domrf_dictionary(raw: object, expected: str | None) -> None:
assert canon_wall_type(raw) == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("Железобетонный", "Железобетонные"), # форма Домклика
("Железобетонные", "Железобетонные"), # форма, уже лежащая в колонке (avito)
("Деревянные", None),
(None, None),
],
)
def test_canon_floor_type_maps_to_column_form(raw: object, expected: str | None) -> None:
assert canon_floor_type(raw) == expected
def test_unknown_wall_type_is_not_written_to_houses() -> None:
"""Незнакомый тип стен не доезжает до houses — колонка не портится сырьём."""
e = DomClickDetailEnrichment(
item_id="t3253-3",
source_url="u",
raw_extra={"wall_type": "Саманный", "quarters_count": 12},
)
db = _FakeSession()
save_detail_enrichment(db, 558, e) # type: ignore[arg-type]
params = _house_call(db)
assert params["wall_type"] is None
assert params["quarters_count"] == 12 # остальные поля едут как обычно
def test_empty_card_does_not_overwrite_house() -> None:
"""(б) Карточка без дом-полей вообще не трогает houses."""
db = _FakeSession()
e = DomClickDetailEnrichment(item_id="t3253-2", source_url="u", raw_extra={"egrn_area": 42.0})
save_detail_enrichment(db, 556, e) # type: ignore[arg-type]
assert not [s for s, _ in db.calls if "UPDATE houses" in s]
def test_coalesce_protects_filled_columns() -> None:
"""(б) SET и WHERE-гейт оба COALESCE-семантика: пустым непустое не затирается."""
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
db = _FakeSession()
save_detail_enrichment(db, 557, e) # type: ignore[arg-type]
sql = next(s for s, _ in db.calls if "UPDATE houses" in s)
for col in ("total_units", "material_walls", "material_floors"):
assert f"COALESCE(h.{col}," in sql
assert f"h.{col} IS NULL" in sql

View file

@ -1,7 +1,8 @@
"""#3283: бан-страница Авито («доступ ограничен: проблема с IP») приходит от
сайдкара как SidecarBanPageError (HTTP 500 + ban_page-маркер в теле, #3239), но
у Авито её никто не читал она заворачивалась в AvitoSidecarUnavailableError и
уезжала в транспортные сбои ('infra'), а не в отказ площадки ('platform').
сайдкара как SidecarBanPageError (ban_page-маркер в теле, #3239; код ответа — 403
с #3288 п.4, 500 у сайдкара старой сборки), но у Авито её никто не читал — она
заворачивалась в AvitoSidecarUnavailableError и уезжала в транспортные сбои
('infra'), а не в отказ площадки ('platform').
Прод-замер (прогон 5606): 64 бан-страницы, ban_kinds={"infra":26,"platform":7}, при
этом ни ОДНОЙ записи для source=avito в scrape_proxy_source_bans узел не банился и
@ -26,7 +27,7 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError
from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.browser_fetcher import SidecarBanPageError, _raise_for_sidecar_status
from scraper_kit.providers.avito import serp as serp_module
from scraper_kit.providers.avito.detail import fetch_detail
from scraper_kit.providers.avito.serp import AvitoScraper
@ -36,16 +37,40 @@ from app.services.scraper_adapters import RealScraperConfig
_ITEM_URL = "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890"
def _sidecar_ban_page_error(upstream_status: int | None = 500) -> SidecarBanPageError:
"""Ровно то, что сайдкар шлёт на подтверждённую бан-страницу («доступ ограничен:
проблема с IP») зеркало _sidecar_ban_page_error из test_domclick_detail.py."""
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
response = httpx.Response(500, request=request)
return SidecarBanPageError(
"Server error '500' | tradein-browser: BanPageDetectedError: доступ ограничен",
request=request,
response=response,
upstream_status=upstream_status,
_SIDECAR_BAN_CODES = [403, 500]
def _sidecar_ban_page_error(
sidecar_status: int = 403, *, upstream_status: int | None = 403
) -> SidecarBanPageError:
"""Ровно то, что клиент поднимает на подтверждённой бан-странице («доступ
ограничен: проблема с IP») зеркало _sidecar_ban_page_error из test_domclick_detail.py.
Исключение строим НАСТОЯЩИМ `_raise_for_sidecar_status` из ответа сайдкара, а не
литералом: текст менялся (#3288 п.4 убрал httpx-преамбулу, и захардкоженная строка
«Server error '500' | tradein-browser: » пережила фикс, которого уже нет в коде).
`sidecar_status` код ОТВЕТА САЙДКАРА: 403 с #3288 п.4, 500 у сайдкара старой
сборки (`tradein-browser` отдельный образ со своим шагом деплоя, версии штатно
разъезжаются на часы). Признак бана ТЕЛО, поэтому оба кода обязаны дать один и
тот же тип; прогон по `_SIDECAR_BAN_CODES` это и проверяет.
`upstream_status` код, который площадка отдала браузеру.
"""
response = httpx.Response(
sidecar_status,
json={
"error": "BanPageDetectedError: доступ ограничен",
"ban_page": True,
"status": upstream_status,
},
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
)
try:
_raise_for_sidecar_status(response)
except SidecarBanPageError as exc:
return exc
raise AssertionError(
f"ответ сайдкара {sidecar_status} с ban_page обязан дать SidecarBanPageError"
)
@ -53,12 +78,15 @@ def _sidecar_ban_page_error(upstream_status: int | None = 500) -> SidecarBanPage
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() -> None:
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra(
sidecar_status: int,
) -> None:
"""Бан-страница — ГЕНУИННЫЙ отказ площадки: AvitoBlockedError, не
AvitoSidecarUnavailableError. Фальсификация: до фикса SidecarBanPageError падала
в `except Exception` (она подкласс httpx.HTTPStatusError) и уезжала сюда."""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status))
with pytest.raises(AvitoBlockedError) as excinfo:
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
@ -67,17 +95,26 @@ async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() -
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_reports_ban() -> None:
"""Подтверждённый маркер-бан обязан попасть в scrape_proxy_source_bans через
report_ban иначе узел не ротируется и продолжает выдаваться в аренду."""
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again(
sidecar_status: int,
) -> None:
"""Бан рапортует ФЕТЧЕР (`report_platform_ban`), провайдер — уже нет (#3288).
До #3288 здесь стоял `assert_called_once()`, и это было верно, пока фетчер о
бане не знал. Теперь рапорт идёт из `_post_fetch` РАНЬШЕ и по правильному
lease; повтор отсюда приходит после возможной ротации по fail-streak и банит
свежий узел, который к площадке не ходил. Что бан всё-таки доезжает до пула
пин по значению в tests/test_3288_avito_ban_per_source.py (там настоящий
BrowserFetcher с фейковым пулом, а не MagicMock).
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status))
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _ITEM_URL in bf.report_ban.call_args.args[0]
bf.report_ban.assert_not_called()
@pytest.mark.asyncio
@ -99,7 +136,10 @@ async def test_fetch_detail_transport_failure_stays_infra_no_report_ban() -> Non
@pytest.mark.asyncio
async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() -> None:
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying(
sidecar_status: int,
) -> None:
"""Бан-страница у SERP → AvitoBlockedError + report_ban, БЕЗ ретраев.
Детект маркерный (SidecarBanPageError.__doc__), переспрашивать его нечем, а
@ -109,7 +149,7 @@ async def test_serp_sidecar_ban_page_raises_platform_block_without_retrying() ->
"""
scraper = AvitoScraper(RealScraperConfig())
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
scraper._browser.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(sidecar_status))
scraper._browser.report_ban = MagicMock() # sync-хук, не корутина (см. test_2686)
sleep_mock = AsyncMock()

View file

@ -0,0 +1,387 @@
"""#3288: бан площадки снимает узел АДРЕСНО (узел×источник), а не глобально по здоровью.
Прод-замер 31.0801.09: `resolve_proxy_url` отказывал ВСЕМ четырём источникам
(`ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7`),
при том что живая проба тех же узлов проходила они стояли ровно на потолке
`MAX_CONSECUTIVE_FAILS`. Механизм: сайдкар на бан-странице Авито («доступ ограничен:
проблема с IP») отвечает `ban_page: true` в теле (кодом 403 с #3288 п.4, кодом 500 у
сайдкара старой сборки), клиент поднимает
`SidecarBanPageError` но она подкласс `httpx.HTTPStatusError`, и общий
`except Exception` в `_post_fetch` звал `_report_fetch_result(False)`
`mark_health(ok=False)`. `mark_health` решение ГЛОБАЛЬНОЕ для пула: три бан-страницы
Авито выбивали узел и у Яндекса, и у Циана, и у Домклика, которые не возражали вовсе.
Что закрепляем по ЗНАЧЕНИЮ:
(а) подтверждённая бан-страница `AvitoBlockedError`, `ban_kind_of_exception` ==
'platform', `mark_banned(lease, source='avito')` вызван;
(б) на бан-странице `mark_health` НЕ вызван вовсе, а на настоящем транспортном сбое
(ReadTimeout) вызван с ok=False, как и раньше;
(в) `NoProxyAvailableError` (пул пуст, к площадке не ходили) ни health-fail, ни бан;
через обёртку `fetch_detail` он остаётся распознаваемым ПО ТИПУ в цепочке причин
(приём `_iter_causes`), а не по подстроке «no proxy available» (#3272);
(г) порядок веток `except`: ban-ветка стоит ДО общего `except Exception`. Пин по
значению тот же HTTP 500 с маркером и без него разводит судьбу узла;
(д) при РОТАЦИИ после бана свежий узел не наказывается за чужой отказ: дедуп по
`_banned_lease_id` сбрасывается взятием нового lease, поэтому единственный
допустимый рапорт из самого фетчера, до ротации. Второй такой же рапорт
сверху (из провайдера) банил бы узел, который к площадке не ходил;
(е) бан-страница стоит РОВНО один POST: `SidecarBanPageError` подкласс
`httpx.HTTPError`, и ретрай `fetch()` забирал её себе, удваивая fail-streak.
Зеркалит стиль tests/test_3196_domclick_ban_kind.py и
tests/test_3283_avito_sidecar_ban_is_platform_ban.py; харнесс пула (фейковый провайдер,
подмена `_client` после `__aenter__`) из tests/test_kit_browser_fetcher_proxy_pool.py.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError
from scraper_kit.browser_fetcher import (
_LEASE_ROTATE_AFTER_FAILS,
BrowserFetcher,
SidecarBanPageError,
)
from scraper_kit.contracts import ProxyLease
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
from scraper_kit.providers.avito.detail import fetch_detail
from scraper_kit.proxy_errors import NoProxyAvailableError
from app.services.scrape_runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM
_ITEM_URL = "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890"
_LEASE = ProxyLease(id=13, url="http://node13:8080", kind="http")
class _FakePool:
"""ProxyProvider-заглушка: пишет, ЧТО именно узнал пул об исходе (зеркало
_FakeProxyProvider из test_kit_browser_fetcher_proxy_pool.py)."""
def __init__(self, lease: ProxyLease | None = _LEASE) -> None:
self._lease = lease
self.health: list[tuple[int, bool]] = []
self.banned: list[tuple[int, str]] = []
self.touched: list[int] = []
self.released: list[int] = []
def acquire(self, provider: str) -> ProxyLease | None:
return self._lease
def release(self, lease: ProxyLease) -> None:
self.released.append(lease.id)
def mark_health(self, lease: ProxyLease, ok: bool, **_: Any) -> None:
self.health.append((lease.id, ok))
def touch(self, lease: ProxyLease) -> None:
self.touched.append(lease.id)
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
self.banned.append((lease.id, source))
_SIDECAR_BAN_CODES = [403, 500]
def _ban_page_response(sidecar_status: int = 403) -> httpx.Response:
"""РОВНО то, что отдаёт сайдкар на распознанной бан-странице (browser/server.py):
`ban_page: true` + статус целевой навигации. Ответ настоящий, а не мок, чтобы
проба шла тем же трактом, что и прод (_raise_for_sidecar_status разбирает тело).
`sidecar_status` код ОТВЕТА САЙДКАРА, а не площадки: 403 с #3288 п.4, 500 у
сайдкара старой сборки. `tradein-browser` отдельный образ со своим шагом
деплоя, поэтому версии сайдкара и backend штатно разъезжаются на часы; оба кода
обязаны давать один диагноз, и тесты ниже прогоняются по `_SIDECAR_BAN_CODES`.
"""
request = httpx.Request("POST", "http://browser:3000/fetch")
return httpx.Response(
sidecar_status,
json={
"error": "BanPageDetectedError: бан-страница (проблема с IP)",
"ban_page": True,
"status": 403,
},
request=request,
)
def _plain_500_response() -> httpx.Response:
"""Настоящая внутренняя ошибка сайдкара: тот же код 500, но БЕЗ маркера бана."""
request = httpx.Request("POST", "http://browser:3000/fetch")
return httpx.Response(
500,
json={"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"},
request=request,
)
async def _fetcher(post_result: Any, pool: _FakePool) -> BrowserFetcher:
"""Фетчер с живым lease из пула и замоканным httpx-клиентом.
post_result готовый httpx.Response (вернётся) либо Exception (будет брошен).
"""
bf = BrowserFetcher(
endpoint="http://browser:3000",
source="avito",
proxy_provider=pool,
use_pool=True,
environment="production",
)
await bf.__aenter__()
client = MagicMock()
if isinstance(post_result, Exception):
client.post = AsyncMock(side_effect=post_result)
else:
client.post = AsyncMock(return_value=post_result)
client.aclose = AsyncMock(return_value=None)
bf._client = client
return bf
def _iter_causes(exc: BaseException) -> list[BaseException]:
"""Цепочка причин без зацикливания — копия приёма из domclick_detail_backfill.py."""
seen: set[int] = set()
out: list[BaseException] = []
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
out.append(cur)
seen.add(id(cur))
cur = cur.__cause__ or cur.__context__
return out
# ── (а) бан-страница → отказ ПЛОЩАДКИ + адресный бан пары «узел×источник» ──────
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
async def test_ban_page_raises_platform_block_and_bans_node_for_avito_only(
sidecar_status: int,
) -> None:
"""Бан-страница: AvitoBlockedError, диагноз 'platform', mark_banned(13, 'avito').
Фальсификация: до фикса SidecarBanPageError доезжала до общего except в
_post_fetch mark_health(ok=False), а строки в scrape_proxy_source_bans не
появлялось вовсе (за 7 суток 0 записей для source=avito при 58 прогонах banned).
Вторая фальсификация (#3288 п.4): заведи в `_sidecar_ban_page_status` гейт
«признавать ban_page только при 500» 403-кейс покраснеет по значению
(AvitoSidecarUnavailableError вместо бана), т.е. в час рассинхрона версий
отказ площадки снова уезжал бы в инфра-ветку.
"""
pool = _FakePool()
bf = await _fetcher(_ban_page_response(sidecar_status), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError) as ei:
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert ban_kind_of_exception(ei.value) == BAN_KIND_PLATFORM
assert pool.banned == [(13, "avito")], (
"узел должен быть забанен РОВНО один раз и РОВНО для avito: одно событие "
"доезжает до report_ban трижды (POST, ретрай fetch(), провайдер), а каждый "
"mark_banned растит ban_count и удлиняет отдых пары"
)
# ── (б) здоровье узла: бан-страница молчит, транспортный сбой — говорит ────────
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
async def test_ban_page_does_not_mark_health_failure(sidecar_status: int) -> None:
"""Ключевое значение issue: на бан-странице mark_health НЕ вызывается вовсе.
Именно этот вызов гнал узел к MAX_CONSECUTIVE_FAILS и выбивал его из выдачи
ВСЕМ источникам (замер 01.09: узлы 9, 13, 14 на потолке при banned_for_source=0).
"""
pool = _FakePool()
bf = await _fetcher(_ban_page_response(sidecar_status), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.health == [], f"бан площадки не должен трогать здоровье узла: {pool.health}"
assert pool.touched, "heartbeat (touch) при этом обязан идти — аренда живая"
async def test_real_transport_failure_still_marks_health_failure() -> None:
"""Контроль к предыдущему: настоящий транспортный сбой (таймаут) health-fail'ит,
как и до фикса. Без этой пары фикс неотличим от «выключили учёт здоровья»."""
pool = _FakePool()
request = httpx.Request("POST", "http://browser:3000/fetch")
bf = await _fetcher(httpx.ReadTimeout("sidecar timeout", request=request), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoSidecarUnavailableError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.health == [(13, False), (13, False)], (
f"таймаут — сбой НАШЕГО тракта, он обязан остаться в здоровье узла "
f"(две записи: fetch() делает один ретрай): {pool.health}"
)
assert pool.banned == [], "таймаут не бан: площадка себя не показала"
# ── (в) пустой пул — ни бан, ни health-fail ───────────────────────────────────
async def test_empty_pool_is_neither_ban_nor_health_failure() -> None:
"""`NoProxyAvailableError` поднимается ДО POST'а: узла нет, площадки не видели."""
pool = _FakePool(lease=None)
bf = BrowserFetcher(
endpoint="http://browser:3000",
source="avito",
proxy_provider=pool,
use_pool=True,
environment="production",
)
with pytest.raises(NoProxyAvailableError):
await bf.__aenter__()
assert pool.health == []
assert pool.banned == []
async def test_empty_pool_through_wrapper_stays_infra_and_recognizable_by_type() -> None:
"""Обёртка `fetch_detail` даёт AvitoSidecarUnavailableError ('infra'), но исходный
тип остаётся в цепочке причин часть B (стоп «нечем ходить») ловит его через
`_iter_causes`, а НЕ подстрокой «no proxy available» (на ней обжёгся #3272)."""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=NoProxyAvailableError("avito"))
with pytest.raises(AvitoSidecarUnavailableError) as ei:
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert ban_kind_of_exception(ei.value) == BAN_KIND_INFRA
assert any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(ei.value))
bf.report_ban.assert_not_called()
# ── (г) порядок веток except ──────────────────────────────────────────────────
def test_sidecar_ban_page_is_a_subclass_of_httpx_error() -> None:
"""Причина, по которой порядок веток вообще имеет значение (#3287): широкая ловля
забирает бан себе. Если это перестанет быть правдой тесты выше проверяют не то,
что думают."""
assert issubclass(SidecarBanPageError, httpx.HTTPStatusError)
async def test_same_500_different_marker_gives_different_node_fate() -> None:
"""Пин порядка веток по значению: ОДИН и тот же HTTP 500 разводит судьбу узла
ровно по маркеру `ban_page` в теле. Плоская 500 health-fail без бана; поставь
ban-ветку после `except Exception` и бан-страница даст тот же исход, что здесь."""
pool = _FakePool()
bf = await _fetcher(_plain_500_response(), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoSidecarUnavailableError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.health == [(13, False), (13, False)]
assert pool.banned == []
# ── (д) ротация после бана: наказан тот узел, который к площадке ходил ─────────
class _RotatingFakePool(_FakePool):
"""Пул с НЕСКОЛЬКИМИ узлами: `acquire()` выдаёт их по очереди.
Отличие от `_FakePool` выше (вечный узел 13) несущее: дедуп рапортов в
`BrowserFetcher.report_ban` держится на `_banned_lease_id`, а тот сбрасывается
взятием нового lease на неподвижном пуле дефект «второй рапорт банит свежий
узел» физически не проявляется, и тест его не видел бы.
"""
def __init__(self, leases: list[ProxyLease]) -> None:
super().__init__(lease=None)
self._queue = list(leases)
self.acquired: list[int | None] = []
def acquire(self, provider: str) -> ProxyLease | None:
lease = self._queue.pop(0) if self._queue else None
self.acquired.append(lease.id if lease is not None else None)
return lease
async def test_rotation_after_ban_does_not_ban_the_fresh_node() -> None:
"""Узел 13 поймал бан-страницу, fail-streak сменил его на 14 — забанен ТОЛЬКО 13.
Прод-сценарий: узел уже сыпался (streak на единицу ниже потолка), бан-страница
добивает его до ротации. Фальсификация: верни `browser_fetcher.report_ban(...)`
в ветку `SidecarBanPageError` в providers/avito/detail.py к тому кадру lease
уже сменился, дедуп по `_banned_lease_id` сброшен взятием нового lease, и
значение станет [(13, 'avito'), (14, 'avito')]: свежий узел получает 6-часовой
отдых (с эскалацией ban_count) за отказ, которого он не видел. При трёх узлах
в пуле это выбивает две трети выдачи с одной бан-страницы.
"""
pool = _RotatingFakePool([_LEASE, ProxyLease(id=14, url="http://node14:8080", kind="http")])
bf = await _fetcher(_ban_page_response(), pool)
bf._lease_fail_streak = _LEASE_ROTATE_AFTER_FAILS - 1
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.acquired == [13, 14], f"ротация обязана была произойти: {pool.acquired}"
assert pool.banned == [(13, "avito")], (
f"забанен должен быть узел, который сходил на площадку и получил отказ, "
f"а не тот, что пришёл ему на смену: {pool.banned}"
)
# ── (е) одна бан-страница — один POST ─────────────────────────────────────────
@pytest.mark.parametrize("sidecar_status", _SIDECAR_BAN_CODES)
async def test_ban_page_costs_exactly_one_post(sidecar_status: int) -> None:
"""Ретрай `fetch()` не имеет права трогать бан-страницу: это ответ площадки.
Фальсификация: убери `except SidecarBanPageError: raise` перед
`except (httpx.HTTPError, ...)` в `fetch()` станет 2 POST'а, а с ними и +2 к
`_lease_fail_streak` вместо +1 (ротация вдвое раньше задуманного).
"""
pool = _FakePool()
bf = await _fetcher(_ban_page_response(sidecar_status), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert bf._client.post.await_count == 1, (
f"бан-страницу ретраить нечем — площадка уже ответила: "
f"{bf._client.post.await_count} POST'ов"
)
# ── (ж) пустой пул при ротации не подменяет диагноз ───────────────────────────
async def test_empty_pool_during_post_ban_rotation_keeps_platform_diagnosis() -> None:
"""Ротация — best-effort: её `NoProxyAvailableError` не должна съесть бан.
Второго узла в пуле нет, поэтому `_acquire_lease()` внутри ротации падает.
Фальсификация: убери `except NoProxyAvailableError` в `report_platform_ban`
наверх уедет она вместо `SidecarBanPageError`, провайдер завернёт её в
AvitoSidecarUnavailableError, и подтверждённый отказ площадки попадёт в
`scrape_runs.ban_kind` как 'infra' (ровно та подмена, что чинил #3283).
"""
pool = _RotatingFakePool([_LEASE])
bf = await _fetcher(_ban_page_response(), pool)
bf._lease_fail_streak = _LEASE_ROTATE_AFTER_FAILS - 1
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError) as ei:
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert ban_kind_of_exception(ei.value) == BAN_KIND_PLATFORM
assert pool.banned == [(13, "avito")], f"бан обязан быть отрапортован до ротации: {pool.banned}"

View file

@ -0,0 +1,373 @@
"""Вид блока решает обрыв по доле и статус прогона (#3288, часть B).
Прогон 5425 оборвался по доле блоков (`abort_reason='ratio'`, статус `banned`) на
48 «блоках», из которых 41 был `AvitoSidecarUnavailableError` отказ НАШЕГО тракта,
а не площадки: та же карточка не доехала до Авито, и 41 других карточек прогон
успел честно обогатить. `record_block()` вида не принимал вовсе, поэтому в числитель
скользящего окна (#3184) падало всё подряд.
Проверка ПО ЗНАЧЕНИЮ сравниваются `abort_reason()`, счётчики и то, какой именно
финализатор дёрнут (`mark_banned` / `mark_failed` / `mark_done`), а не «не бросило
исключение». К каждому послаблению идёт контроль на противоположную ошибку: 14
настоящих `AvitoBlockedError` из 20 обязаны рвать прогон как раньше (#3184 не
меняется), а пустой пул опознаётся по ЦЕПОЧКЕ `__cause__`, не по подстроке
«no proxy available» на подстроке уже обжёгся #3272.
Харнесс бэкфилла зеркалит tests/test_3338_backfill_counter_identity.py, оттуда же
тождество attempted = enriched + blocked + gone + failed.
"""
from __future__ import annotations
import os
import sys
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.avito_exceptions import ( # noqa: E402
AvitoBlockedError,
AvitoSidecarUnavailableError,
)
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
from app.services import scrape_runs as runs_svc # noqa: E402
from app.services.backfill_block_breaker import BlockRatioBreaker # noqa: E402
from app.services.scrape_runs import ( # noqa: E402
BAN_KIND_INFRA,
BAN_KIND_PLATFORM,
BAN_KIND_UNKNOWN,
)
from app.tasks.avito_detail_backfill import ( # noqa: E402
AvitoDetailBackfillResult,
run_avito_detail_backfill,
)
_A_FETCH = "app.tasks.avito_detail_backfill.fetch_detail"
_A_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment"
_A_RUNS = "app.tasks.avito_detail_backfill.runs_mod"
_A_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep"
_A_SETTINGS = "app.tasks.avito_detail_backfill.settings"
_A_SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
_A_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
_A_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"
_A_ROTATE_PROXY = "app.tasks.avito_detail_backfill.rotate_proxy"
_WINDOW = 20
_THRESHOLD = 0.7
def _breaker(snapshot_size: int = 100) -> BlockRatioBreaker:
return BlockRatioBreaker(
window_size=_WINDOW,
ratio_threshold=_THRESHOLD,
safety_min=5,
snapshot_size=snapshot_size,
)
# ── брейкер: что попадает в числитель доли ────────────────────────────────────
def test_twenty_sidecar_blocks_do_not_abort_by_ratio() -> None:
"""20 подряд отказов сайдкара при снапшоте больше окна — не 'ratio' (приёмка)."""
breaker = _breaker()
for _ in range(20):
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() is None, (
f"abort_reason={breaker.abort_reason()!r} при 20 infra-отказах: площадка "
"не отказывала ни разу, рвать прогон не по чему"
)
assert (breaker.window_blocks, breaker.window_len) == (0, 20), (
f"окно {breaker.window_blocks}/{breaker.window_len}: infra идёт в ЗНАМЕНАТЕЛЬ "
"(как record_failure), а не в числитель"
)
assert breaker.consecutive_blocks == 0, (
f"consecutive_blocks={breaker.consecutive_blocks}: infra не двигает серию, "
"иначе на коротком снапшоте прогон рвал бы safety-net по тем же отказам"
)
def test_fourteen_platform_blocks_of_twenty_still_abort_by_ratio() -> None:
"""Контроль на противоположную ошибку: #3184 не меняется (14/20 = порог 0.7)."""
breaker = _breaker()
for _ in range(6):
breaker.record_success()
for _ in range(14):
breaker.record_block(BAN_KIND_PLATFORM)
assert breaker.abort_reason() == "ratio", (
f"abort_reason={breaker.abort_reason()!r} при 14 настоящих блоках из 20 — "
"критерий #3184 обязан сработать ровно на пороге"
)
assert breaker.window_blocks == 14, f"числитель {breaker.window_blocks}, ожидали 14"
def test_infra_does_not_pad_the_numerator_to_the_threshold() -> None:
"""13 platform + 7 infra = 0.65, а не 1.0: infra числитель не добивает."""
breaker = _breaker()
for _ in range(7):
breaker.record_block(BAN_KIND_INFRA)
for _ in range(13):
breaker.record_block(BAN_KIND_PLATFORM)
assert breaker.abort_reason() is None, (
f"abort_reason={breaker.abort_reason()!r}: 13/20 = 0.65 ниже порога 0.7, "
"прогон рвётся только если infra зачли блоками"
)
# ── бэкфилл: тот же вопрос сквозь задачу ──────────────────────────────────────
def _fake_avito_settings() -> MagicMock:
return MagicMock(
scraper_fetch_mode="browser",
avito_detail_backfill_use_curl=False,
detail_backfill_block_ratio_window=_WINDOW,
detail_backfill_block_ratio_threshold=_THRESHOLD,
browser_http_endpoint="http://browser:9000",
avito_detail_backfill_rotate_after_attempts=15,
avito_detail_backfill_rotate_on_ban_max=0,
avito_detail_backfill_rotate_on_ban_min_gap=10,
)
def _mock_avito_db(n: int) -> MagicMock:
snapshot = [
{
"id": i + 1,
"source_url": f"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_{i + 1}",
}
for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.request_context_reset = MagicMock()
instance.lease_id = 42
return MagicMock(return_value=instance)
async def _run_avito(
fetch_results: list[Any], *, snapshot_size: int
) -> tuple[AvitoDetailBackfillResult, MagicMock]:
runs = MagicMock()
db = _mock_avito_db(snapshot_size)
with (
patch(_A_SETTINGS, _fake_avito_settings()),
patch(_A_SESSION),
patch(_A_SCRAPER),
patch(_A_RUNS, runs),
patch(_A_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_A_FETCH, AsyncMock(side_effect=fetch_results)),
patch(_A_SAVE, return_value=True),
patch(_A_ROTATE_PROXY, AsyncMock()),
patch(_A_SLEEP, new_callable=AsyncMock),
):
counters = await run_avito_detail_backfill(
db,
run_id=3288,
params={
"batch_size": snapshot_size,
"budget_sec": 3600,
"max_consecutive_blocks": 10,
},
)
return counters, runs
def _assert_identity(c: AvitoDetailBackfillResult, *, expected_attempted: int) -> None:
outcomes = c.enriched + c.blocked + c.gone + c.failed
assert (c.attempted, outcomes) == (expected_attempted, expected_attempted), (
f"attempted={c.attempted} vs исходы={outcomes} "
f"(enriched={c.enriched} blocked={c.blocked} gone={c.gone} failed={c.failed}), "
f"ожидали {expected_attempted} — попытка без исхода (#3338)"
)
@pytest.mark.asyncio
async def test_sidecar_run_walks_the_whole_snapshot_and_is_not_banned() -> None:
"""20 отказов сайдкара + 10 успехов: снапшот пройден целиком, обрыва нет."""
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
fetches += [MagicMock() for _ in range(10)]
counters, runs = await _run_avito(fetches, snapshot_size=30)
finished = runs.mark_backfill_finished.call_args
assert finished is not None, "прогон не финализирован mark_backfill_finished"
# Приёмка #3288 первой строкой: величина, по которой прогон 5425 и оборвался.
assert finished.args[2].get("abort_reason") is None, (
f"abort_reason={finished.args[2].get('abort_reason')!r} при 20 отказах сайдкара: "
"обрыва по доле быть не могло — площадка не отказала ни разу"
)
_assert_identity(counters, expected_attempted=30)
assert (counters.blocked, counters.enriched) == (20, 10), (
f"blocked={counters.blocked} enriched={counters.enriched}: прогон обязан дойти "
"до конца снапшота, отказ нашего тракта его не рвёт"
)
assert finished.kwargs["aborted_by_blocks"] is False, (
"aborted_by_blocks=True при нулевых отказах площадки — прогон уйдёт в 'banned'"
)
assert finished.kwargs["ban_kinds"] == {BAN_KIND_INFRA: 20}, (
f"ban_kinds={finished.kwargs['ban_kinds']}: перепись диагнозов не должна "
"теряться — она и решает статус"
)
@pytest.mark.asyncio
async def test_real_platform_bans_still_abort_by_ratio() -> None:
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
fetches: list[Any] = [MagicMock() for _ in range(6)]
fetches += [AvitoBlockedError("firewall/soft-block") for _ in range(14)]
counters, runs = await _run_avito(fetches, snapshot_size=30)
_assert_identity(counters, expected_attempted=20)
finished = runs.mark_backfill_finished.call_args
assert finished.args[2].get("abort_reason") == "ratio", (
f"counters={finished.args[2]}: 14 отказов площадки из 20 — ровно порог #3184"
)
assert finished.kwargs["aborted_by_blocks"] is True, "обрыв по доле обязан быть помечен"
@pytest.mark.asyncio
async def test_empty_pool_stops_run_with_its_own_reason() -> None:
"""Пустой пул — не блок: прогон завершается 'нечем ходить', а не баном."""
wrapped = AvitoSidecarUnavailableError("Avito detail browser fetch failed for https://...")
wrapped.__cause__ = NoProxyAvailableError("avito")
counters, runs = await _run_avito([wrapped], snapshot_size=30)
_assert_identity(counters, expected_attempted=1)
assert (counters.blocked, counters.failed) == (0, 1), (
f"blocked={counters.blocked} failed={counters.failed}: к площадке не ходили — "
"это отказ нашей стороны, а не блок"
)
assert runs.mark_backfill_finished.call_args is None, (
"прогон с пустым пулом не должен идти через backfill-honest-status: у него своя причина"
)
failed = runs.mark_failed.call_args
assert failed is not None, "прогон не финализирован mark_failed"
assert failed.args[3]["no_proxy_stop"] == 1, f"counters={failed.args[3]}: нет no_proxy_stop"
assert "пул прокси пуст" in failed.args[2], f"причина={failed.args[2]!r}"
@pytest.mark.asyncio
async def test_empty_pool_recognised_by_cause_not_by_text() -> None:
"""#3272: подстрока «no proxy available» в тексте — не доказательство пустого пула."""
liar = AvitoBlockedError("firewall page: no proxy available for provider='avito'")
counters, runs = await _run_avito([liar, MagicMock()], snapshot_size=2)
_assert_identity(counters, expected_attempted=2)
assert (counters.blocked, counters.enriched) == (1, 1), (
f"blocked={counters.blocked} enriched={counters.enriched}: отказ площадки с "
"«no proxy available» в тексте остаётся блоком — цепочка причин пуста"
)
assert runs.mark_failed.call_args is None, (
"прогон остановлен как «пул пуст» по подстроке в тексте — ровно ошибка #3272"
)
# ── статус прогона: диагноз infra ≠ 'banned' ──────────────────────────────────
def _finish(ban_kinds: dict[str, int], *, enriched: int) -> tuple[MagicMock, ...]:
counters = {"attempted": 90, "enriched": enriched, "blocked": 48, "gone": 0, "failed": 1}
with (
patch.object(runs_svc, "mark_banned") as banned,
patch.object(runs_svc, "mark_failed") as failed,
patch.object(runs_svc, "mark_done") as done,
):
runs_svc.mark_backfill_finished(
MagicMock(),
5425,
counters,
source="avito_detail_backfill",
aborted_by_blocks=True,
ban_kinds=ban_kinds,
)
return banned, failed, done
def test_infra_dominant_run_is_not_marked_banned() -> None:
"""41 infra из 48 при 41 обогащённой карточке — прогон 'done', не 'banned'."""
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=41)
assert banned.call_count == 0, (
"прогон помечен 'banned' при доминирующем infra — запись утверждает про "
"площадку то, чего не было"
)
assert (failed.call_count, done.call_count) == (0, 1), (
f"mark_failed={failed.call_count} mark_done={done.call_count}: 41 карточка "
"обогащена, прогон работу сделал"
)
def test_pure_infra_run_with_result_is_not_banned() -> None:
"""Основной кейс без примеси: перепись целиком infra, карточки есть → не 'banned'."""
banned, failed, done = _finish({BAN_KIND_INFRA: 20}, enriched=10)
assert (banned.call_count, failed.call_count, done.call_count) == (0, 0, 1), (
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
f"mark_done={done.call_count}: 20 отказов сайдкара при 10 обогащённых — 'done'"
)
def test_infra_dominant_run_without_result_stays_banned() -> None:
"""Контроль на ОБРАТНУЮ ошибку: ноль результата — по-прежнему 'banned' (#2764/#3196).
Диагноз infra на нулевом прогоне неотличим от бана площадки, опознанного по 5xx
(yandex, #3196): статус остаётся 'banned', честность несёт ban_kind. Понижение
до 'failed' прятало бы настоящий бан под «нашу поломку» падало 2 теста CI.
"""
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=0)
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
f"mark_done={done.call_count}: нулевой прогон обязан остаться 'banned'"
)
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_INFRA, (
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}: диагноз обязан доехать"
)
def test_run_without_census_stays_banned() -> None:
"""Источник видов не различает (ban_kinds пуст) → прежнее поведение: 'banned'."""
banned, failed, done = _finish({}, enriched=41)
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
f"mark_done={done.call_count}: без переписи диагноза нет — статус не меняем"
)
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_UNKNOWN, (
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'unknown'"
)
def test_platform_dominant_run_is_still_banned() -> None:
"""Контроль на противоположную ошибку: настоящий бан площадки — по-прежнему 'banned'."""
banned, failed, done = _finish({BAN_KIND_PLATFORM: 41, BAN_KIND_INFRA: 7}, enriched=41)
assert banned.call_count == 1, (
f"mark_banned={banned.call_count} (failed={failed.call_count} done={done.call_count}): "
"площадка отказала в 41 блоке из 48 — это ровно 'banned'"
)
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
)

Some files were not shown because too many files have changed in this diff Show more