Merge remote-tracking branch 'forgejo/main' into local/pr2836
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m36s

This commit is contained in:
bot-backend 2026-08-17 10:41:25 +03:00
commit c66047683c
186 changed files with 21592 additions and 1699 deletions

View file

@ -25,7 +25,8 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
## Path triggers (Forgejo Actions, `.forgejo/workflows/`)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `ops/db-bootstrap/**`, `ops/docker-prune.sh`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- ⚠️ `ops/**` целиком **не** триггерит — только перечисленные подпути. Любой новый файл в `ops/`, который исполняется на VM (cron / шаг деплоя), надо добавлять в `paths:` явно, иначе он не доедет до `/opt/gendesign` и будет молча исполняться в старой версии
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md``.forgejo/workflows/deploy-obsidian.yml`
- `docs/**` alone → НЕ триггерит деплой

View file

@ -41,9 +41,21 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
не доверяй `tail`.
деструктивный DDL хитит прод на деплое.
**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит
миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754):
```bash
git fetch origin main
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
```
`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы.
Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная
формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо:
`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть.
## Rapid-merge trap

View file

@ -51,9 +51,25 @@ jobs:
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
- 'auth/**'
# Реестр городов — фронтовый файл, но его читает БЭКЕНДОВЫЙ тест
# (tests/test_public_mera_api.py сверяет то, что мы предлагаем
# выбрать, с тем, на что умеет отвечать проба покрытия). Без этой
# строки правка одного лишь дропдауна не гоняла бы сверку — а
# разошлись списки ровно так: город добавили на фронте, в пороги
# покрытия не внесли, и житель Серова получал «вы вне области».
- 'tradein-mvp/frontend/src/lib/city-registry.ts'
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'
# Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в
# КОРНЕ репы, но его читает фронтовый тест
# (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет,
# что каждый маршрут публичного сайта действительно раздаётся на
# meraocenka.ru. Без этой строки правка одного лишь Caddyfile не
# запускала бы НИ ОДИН гейт, и удаление короткого адреса из
# allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить»
# стала бы ссылкой в 404.
- 'Caddyfile'
- '.forgejo/workflows/ci-tradein.yml'
browser:
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
@ -85,6 +101,21 @@ jobs:
CI_PG: ci-pg-tradein-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
with:
# ПОЛНАЯ история, а не дефолтный depth=1 (#2683).
# tests/test_migration_numbering.py сверяет номер новой миграции с
# origin/main и с точкой ветвления. Ровно этот флаг их и даёт: при
# depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*`
# (видно в логе прогона), при depth=1 — только `+<sha>:refs/remotes/
# pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет.
# Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера
# git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection
# refused), сеть есть только у самого checkout.
#
# Гейт при отсутствии эталона краснеет, а не пропускается: молча
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
# Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде.
fetch-depth: 0
- name: Поднять Postgres и собрать схему tradein
working-directory: .
@ -116,7 +147,7 @@ jobs:
# бы, а тесты всё равно скипались.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
postgis/postgis:16-3.4
@ -183,13 +214,22 @@ jobs:
restore-keys: |
uv-tradein-${{ runner.os }}-
- name: Sync deps (incl. dev group — pytest)
- name: Sync deps (incl. dev group — pytest, ruff)
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
# вверх от cwd.
run: uv sync --frozen
- name: Lint (ruff check)
# Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint]
# select = E F I B UP N RUF), но до этого шага их никто не гонял в CI —
# "дерево чистое" было непроверенным утверждением, а не гарантией.
# Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше),
# т.е. ровно то, что видит `uv sync --frozen` в Dockerfile.
# Blocking: любое нарушение → job RED (не декоративно).
run: uv run ruff check .
- name: Run pytest (tradein-mvp/backend)
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
#
@ -221,7 +261,7 @@ jobs:
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог

View file

@ -65,6 +65,45 @@ jobs:
python3 scripts/check-workflow-ports.py --selftest
python3 scripts/check-workflow-ports.py
- name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
#
# ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один
# workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При
# этом deploy.yml применяет его не через `reload` (тот отказался бы
# принять битый конфиг и оставил бы старый работать), а через
# `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер
# в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru,
# obsidian, status. То есть цена опечатки в этом файле — полный
# даунтайм, а гейта на неё не было.
#
# `docker cp`, а НЕ `-v "$PWD:/etc/caddy"`. Job сам исполняется внутри
# контейнера, и `docker run` создаёт КОНТЕЙНЕР-БРАТ на том же демоне:
# путь в `-v` резолвится на ХОСТЕ, а `$PWD` — это путь внутри job-
# контейнера, которого на хосте нет. Первая версия этого шага так и
# упала: `open /etc/caddy/Caddyfile: no such file or directory`.
# Копирование не зависит от того, как смонтирован workspace.
#
# Образ тот же `caddy:2`, что в docker-compose.prod.yml — проверяем ровно
# тем парсером, который будет читать конфиг на проде.
#
# Копируем и `caddy/` — Caddyfile делает `import caddy/users.caddy.snippet`,
# и без него validate упадёт на импорте (файл в репозитории есть).
#
# Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую
# строку — это нормально, синтаксис от их значений не зависит.
run: |
set -euo pipefail
cid=$(docker create -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile)
docker cp Caddyfile "$cid:/work/Caddyfile"
docker cp caddy "$cid:/work/caddy"
rc=0
docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit "$rc"
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
@ -142,7 +181,7 @@ jobs:
# здесь не нужен вовсе, в отличие от tradein-лэйна.
run: |
set -u
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
postgres:16
@ -233,11 +272,18 @@ jobs:
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
# term-missing → видно непокрытые строки прямо в job-логе.
run: |
# #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит
# в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как
# «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется:
# ниже `exit $rc`.
rc=0
uv run pytest -q -rs --ignore=tests/smoke \
--cov=app \
--cov-report=term-missing:skip-covered \
--cov-report=xml:coverage.xml \
--cov-fail-under=65
--cov-fail-under=65 || rc=$?
echo "### pytest вернул код $rc"
exit $rc
- name: Coverage summary → job output
# Дешёвый human-readable итог. Бежит даже если gate упал (if: always) —
@ -246,20 +292,34 @@ jobs:
# если переменная пустая/файла нет, печатаем в обычный лог (fallback).
if: always()
run: |
echo "### шаг «Coverage summary» начался"
[ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; }
report="$(uv run coverage report --skip-covered --sort=cover | tail -40)"
# NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с
# кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` —
# то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал
# неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден.
# `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после
# упавшей команды просто не выполнится, и код возврата снова потеряется.
cov_rc=0
uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$?
echo "### coverage report вернул код $cov_rc"
report="$(tail -40 /tmp/cov_report.txt)"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{ echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
else
echo "$report"
fi
echo "### шаг «Coverage summary» закончился успешно"
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
run: |
echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})"
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
echo "### шаг «Снести тестовый Postgres» закончился успешно"
frontend-tests:
runs-on: ubuntu-latest

View file

@ -176,6 +176,11 @@ jobs:
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
steps:
- uses: actions/checkout@v4
with:
# Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует
# origin/main и общего предка с HEAD, а даёт их именно depth=0 — при
# depth=1 checkout тянет один sha и ветки main в клоне нет.
fetch-depth: 0
- name: Install uv
# Официальный standalone-инсталлер: системный `pip install uv` на
@ -227,10 +232,49 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-backend
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# валит весь шаг ДО push нового образа — деплой тогда молча
# пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай
# без cache-from — ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
@ -251,6 +295,52 @@ jobs:
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push tradein-backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2,
# issue #2): успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча
# оставалась навсегда, следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше).
# Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при
# упавшем build — этот шаг единственный это заметит: манифеста с этим SHA
# не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy
# ниже пропускается вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
@ -267,8 +357,41 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
@ -279,6 +402,10 @@ jobs:
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
- name: Build & push tradein-frontend
# id + continue-on-error — см. tradein-backend (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
@ -303,6 +430,47 @@ jobs:
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
push: true
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-browser:
runs-on: ubuntu-latest
needs: changes
@ -321,10 +489,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-browser
# id + continue-on-error — см. tradein-backend выше (#2841): битый blob
# в удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
@ -335,6 +540,41 @@ jobs:
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Retry build & push tradein-browser без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
push: true
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, test, build-backend, build-frontend, build-browser]
@ -444,8 +684,9 @@ jobs:
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
# #2216) glob'ом не подхватывается.
# ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
# до main, здесь и применяется, а имя закрепляется в _schema_migrations.
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
# первый deploy после внедрения tracking на уже-наполненной prod-БД
@ -869,3 +1110,33 @@ jobs:
# The changes job reads this file on the next run to compute cumulative diff.
echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha
echo "→ Deployed SHA marker updated: $GITHUB_SHA"
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда `test` или один из build-* падает (например,
# битый blob в buildcache роняет `docker/build-push-action` — до ретрая
# выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это
# было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя
# tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`,
# кроме отмены прогона) и сама падает, если deploy не завершился success —
# неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/
# health-check/сверка образов #2679). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
deploy-status:
runs-on: ubuntu-latest
needs: [test, build-backend, build-frontend, build-browser, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "test: ${{ needs.test.result }}"
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "build-browser: ${{ needs.build-browser.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи test/build-backend/build-frontend/build-browser/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"

View file

@ -20,6 +20,19 @@ on:
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
# до прода до следующего чужого коммита в backend/.
- "ops/db-bootstrap/**"
# RBAC roles config (auth/roles.yaml, bind-mounted read-only ТОЛЬКО в backend —
# см. docker-compose.prod.yml; worker монтирует лишь ./data и ./reports).
# app.core.auth кэширует парсинг на весь lifetime процесса (@lru_cache) — без
# этого триггера правка ролей вступала бы в силу в случайный момент, только на
# следующий чужой деплой (`up -d --force-recreate --no-deps backend worker beat`
# ниже сбрасывает кэш перезапуском процесса; сам файл в образ не запекается,
# ребилда картинок для этого не нужно).
- "auth/**"
# То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM
# по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard`
# шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами
# исполнял бы старую версию — молча и без единого сигнала.
- "ops/docker-prune.sh"
workflow_dispatch:
concurrency:
@ -71,10 +84,49 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push backend (lean — без Chromium)
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# (registry cache, не local) валит весь шаг ДО push нового образа —
# деплой тогда молча пропускается (#2841), хотя код собрать можно, просто
# без кеша. cache-from нефатален: при падении ретраим БЕЗ него ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
@ -86,6 +138,53 @@ jobs:
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), а cache-to ОСТАВЛЕН: успешный
# ретрай пушит свежие слои в buildcache-тег и тем самым сам перезаписывает
# битый blob (mode=max — полная перезапись манифеста). Раньше cache-to был
# опущен и здесь тоже — но следующий обычный прогон опять получает cache-from
# на детерминированно битый тег и падает СНОВА: самолечения не было НИКОГДА
# (ревью #2841 R2, issue #2). Если и retry упадёт — шаг красный БЕЗ
# continue-on-error, job честно FAILURE, и deploy ниже корректно
# пропускается (уже настоящая причина, не кеш).
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner
push: true
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry напрямую через buildx (уже настроен
# выше). Если act_runner не заполняет outcome (не проверено живым прогоном,
# см. ревью), ретрай выше молча НЕ побежит при упавшем build, а этот шаг —
# единственный, кто это заметит: манифеста с этим SHA не будет → шаг падает
# БЕЗ continue-on-error → job честно FAILURE → deploy ниже пропускается
# вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-worker:
runs-on: ubuntu-latest
needs: changes
@ -102,10 +201,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push worker (с Chromium для Playwright)
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
@ -117,6 +253,45 @@ jobs:
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Retry build & push worker без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner-with-chromium
push: true
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Проверить, что worker:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_WORKER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
@ -133,10 +308,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push frontend
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./frontend
@ -150,6 +362,47 @@ jobs:
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push frontend без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, build-backend, build-worker, build-frontend]
@ -467,8 +720,50 @@ jobs:
docker image prune -af || true
docker builder prune -af || true
# Health check
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214,
# уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3).
# `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не
# последняя команда &&-списка, а POSIX прямо освобождает от errexit
# все команды AND/OR-списка кроме последней. После 30 неуспешных
# попыток цикл завершался кодом последнего sleep (0) — скрипт тихо
# продолжался, деплой уходил success с мёртвым бэкендом.
healthy=""
for i in $(seq 1 30); do
curl -fsS http://localhost:8000/health && break
if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
# skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на
# первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился.
# Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает,
# если deploy не завершился success — неважно, пропущен он (build упал) или
# упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
deploy-status:
runs-on: ubuntu-latest
needs: [build-backend, build-worker, build-frontend, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-worker: ${{ needs.build-worker.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи build-backend/build-worker/build-frontend/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"

View file

@ -1,91 +0,0 @@
name: CI
on:
push:
branches:
- main
- 'feat/**'
- 'fix/**'
- 'refactor/**'
- 'chore/**'
- 'docs/**'
- 'perf/**'
- 'test/**'
- 'hotfix/**'
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: gendesign
POSTGRES_USER: gendesign
POSTGRES_PASSWORD: gendesign
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U gendesign"
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.12
- name: Install system deps for geo + WeasyPrint
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
- name: Install Python deps
run: uv sync
- name: Lint (ruff)
run: uv run ruff check .
- name: Type check (mypy strict on core)
run: |
uv run mypy \
app/services/generative \
app/services/site_finder/scorer.py
- name: Test (pytest)
run: uv run pytest -q
env:
DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci || npm install
- run: npm run lint
- run: npm run type-check
- run: npm run build

160
Caddyfile
View file

@ -222,8 +222,7 @@ www.gendsgn.ru {
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что
# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО
# автоматический HTTPS в Caddy изолирован per-hostname. Повторные неудачные попытки ДО
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
# validations/hostname/hour) — не критично, просто подождать; `docker volume
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
@ -251,22 +250,108 @@ meraocenka.ru {
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
# юридических документов (оферта ссылается на meraocenka.ru/refund,
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
# поэтому обязаны резолвиться сами по себе.
#
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
# в scripts/smoke-mera-perimeter.sh.
handle /trade-in/mera-public/* {
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
# фронте — обычный <a>, потому что next/link подставляет basePath).
#
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
# который в ней указан. Каноничность для поисковиков задана отдельно, через
# `alternates.canonical` на каждой странице.
#
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
# (и проверка в scripts/smoke-mera-perimeter.sh).
#
# NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина
# техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а
# для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
# `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
# когда вариант выберут и он станет корнем.
@meraPages path /estimate /oferta /refund /privacy /v3
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Тот же адрес со слэшем на конце → 301 на канонический вид без слэша.
# Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер
# `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$
handle @meraShortSlash {
redir * /{re.shortslash.1} permanent
}
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
# страницы, а не два работающих.
#
# Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
# канонический адрес один.
#
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста)
# под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
#
# `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он
# начинается со слэша, Caddy разбирает как inline path-matcher — то есть
# `redir / permanent` означает «для пути / редиректить на permanent», а не
# «редиректить на /». Проверено на живом Caddy: без `*` длинные адреса
# отдавали пустой 200 (матчер не совпадал, директива не срабатывала, тело
# пустое) — хуже, чем 404, потому что выглядит как рабочая пустая страница.
@meraLongRoot path /trade-in/mera-public /trade-in/mera-public/
handle @meraLongRoot {
redir * / permanent
}
# Длинные адреса страниц → короткие. Пути перечислены ПОИМЁННО, обе формы
# (со слэшем на конце и без) — не шаблоном и не регекспом.
#
# ПОЧЕМУ НЕ РЕГЕКСП С ЗАХВАТОМ ХВОСТА. Очевидный вариант
# `path_regexp ^/trade-in/mera-public/(.+)$` + `redir /{re.…1}` — открытый
# редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
# `/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`,
# а браузеры трактуют `/\` как `//` — Location уводит на ЧУЖОЙ хост. Это
# готовая фишинговая заготовка с домена, который напечатан внутри оферты и
# уходит модератору эквайера. Проверено на живом Caddy, воспроизводится.
# С поимённым списком такой путь просто не матчится и падает в 404 ниже.
#
# ПОЧЕМУ `uri strip_prefix` + `{uri}`, А НЕ `redir /oferta` в каждой ветке.
# `{uri}` переносит query-строку: уже размещённые ссылки с UTM-метками
# после редиректа не теряют атрибуцию. Обёртка `route` обязательна —
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
handle @meraLongPages {
route {
uri strip_prefix /trade-in/mera-public
redir * {uri} permanent
}
}
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
@ -292,8 +377,38 @@ meraocenka.ru {
}
}
# Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до
# бэкенда. Под /api/public/ по определению не лежит ничего закрытого:
# гарантию даёт структура пакета app/api/public/, а не аккуратность этого
# матчера (разбор — в app/api/public/mera.py). Матчер тем не менее узкий:
# /trade-in/api/v1/* по-прежнему падает в catch-all 404 ниже.
#
# ПОЧЕМУ ПУТЬ С ПРЕФИКСОМ /trade-in, А НЕ КОРОТКИЙ /api/public/*.
# Тот же URL обязан работать и на gendsgn.ru/trade-in/mera-public — ту же
# страницу оттуда открывают для QA (там она за basic_auth). На gendsgn.ru
# корневой /api/* уже занят бэкендом Site Finder, то есть короткий путь
# потребовал бы там ВТОРОГО handle, выигрывающего у существующего по
# специфичности — то есть работоспособность публичной формы зависела бы от
# порядка сортировки матчеров в чужом site-блоке. С префиксом /trade-in
# запрос ловит уже существующий `handle /trade-in/api/*` (:123), и здесь
# нужен ровно один новый handle. Цена — префикс /trade-in виден в devtools
# публичного домена; он там и так виден на всех чанках Next (basePath).
#
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
# анонимного домена — расширять доверие без нужды.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
}
}
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
# /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется.
handle {
respond 404
}
@ -345,25 +460,6 @@ errors.gendsgn.ru {
}
}
# Uptime Kuma — self-hosted uptime monitoring + public status page (#75 B6-1).
# DNS: A-record status.gendsgn.ru → IP VPS (добавить перед деплоем стека).
# Контейнер из docker-compose.uptime.yml (project gendesign-uptime) на shared
# gendesign_shared network. Если стек не запущен — Caddy отдаёт 502 ТОЛЬКО на
# этом домене, main-сайт не страдает (как obsidian.gendsgn.ru).
#
# ВНИМАНИЕ: status-page НАМЕРЕННО публичен (trust-building для пилотов, issue #75).
# Admin-панель Kuma (/dashboard, /manage-*) защищена собственным логином Kuma —
# НЕ кладём её за caddy/users.caddy.snippet, иначе double-auth сломает setup.
status.gendsgn.ru {
encode zstd gzip
reverse_proxy uptime-kuma:3001
log {
output file /var/log/caddy/status.gendsgn.ru.log
}
}
# Forgejo — self-hosted git (migration 2026-05-16).
# DNS: A-record git.gendsgn.ru → IP VPS.
# Forgejo container из forgejo-migration/docker-compose.yml на shared

View file

@ -85,12 +85,10 @@ docker-compose.prod.yml main стек (backend, frontend, postgres, redis, work
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
├── ci.yml lint (ruff) + mypy + pytest на PR
├── ci.yml lint (ruff) + pytest на PR
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations)
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
```
---
@ -158,7 +156,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
**Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026):
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + pytest (coverage gate ≥65%). mypy strict в гейте не гоняется (доступен вручную — `uv run mypy app/services/generative app/services/site_finder/scorer.py`). Блокирует merge при провале.
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*

2
backend/.gitignore vendored
View file

@ -1 +1,3 @@
.coverage
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
coverage.xml

View file

@ -2319,12 +2319,31 @@ def analyze_parcel(
-- (303 строки = 303 distinct) COUNT(*) по дедуп-физлотам корректен.
SELECT
np.domrf_obj_id,
ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
-- #2464-D: границы правдоподобия, как в двух соседних запросах
-- по этой же таблице (BETWEEN 30000 AND 600000) здесь их не было.
-- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп +
-- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827,
-- из них 118 в 10 замапленных проектах и 86 в незамапленных.
-- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на
-- 2.4%, market_avg_price (среднее средних) 138 056 138 008;
-- NULL не появляется нигде. Ставим границы не ради этих 48 ,
-- а потому что среднее считается ПО ПРОЕКТУ и один лот держит
-- группу без ограничения сверху: максимум в таблице
-- 19 198 429 /м² (ЖК «Дебют»), и он вне экрана только потому,
-- что проект пока не замаплен (замаплено 308 имён из 881, список
-- растёт). Одна строка маппинга и это число на экране.
-- FILTER, а не WHERE: строки нужны целиком, иначе поедут
-- units_sold / units_available, считающие ВСЕ лоты.
ROUND(AVG(oll.price_per_m2_rub) FILTER (
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available,
-- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик
-- обещал бы выборку шире, чем на самом деле участвовала.
COUNT(*) FILTER (
WHERE oll.price_per_m2_rub IS NOT NULL
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
) AS lots_with_price
FROM nearby_projects np
JOIN obj_lots_latest oll

View file

@ -508,3 +508,24 @@ async def health() -> dict[str, str]:
"environment": settings.environment,
"version": app.version,
}
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health {
# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на
# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor
# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по
# статусу. media_type="application/json" — Content-Type совпадает с GET;
# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы
# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload-
# заголовки (Content-Length) для HEAD, требует совпадения только заголовков
# представления (Content-Type).
# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть
# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в
# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации
# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута,
# который фронт никогда не вызывает.
@app.head("/health", include_in_schema=False)
async def health_head() -> Response:
return Response(status_code=200, media_type="application/json")

View file

@ -30,7 +30,12 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkServerError
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkRateLimitError,
NspdBulkServerError,
NspdBulkWafError,
)
from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857
logger = logging.getLogger(__name__)
@ -182,6 +187,13 @@ async def harvest_quarter(
try:
cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id)
result.snapshot_requests += 1
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: бан IP / исчерпанные ретраи — НЕ «этот cat не дошёл».
# Контракт harvest_quarter (Raises:) обещает пробросить их наверх,
# а голый except ниже их глотал: прогон доходил до status='done'
# с частичными данными. Прод-замер 13.08: 23 job'а, 50 WAF-блоков,
# 0 упавших — то есть бан ни разу не остановил сбор.
raise
except Exception as e:
logger.warning(
"harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s",
@ -279,6 +291,9 @@ async def harvest_quarter(
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
raise
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
@ -399,6 +414,18 @@ async def _grid_walk_category(
requests += 1
server_errors += 1
continue
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: 403 WAF — бан IP, а не «этот cell не дошёл». Продолжать
# обход значит углублять бан и дописать в БД ложный нулевой слой.
# Зеркало уже исправленных nspd_bulk_client.get_features_in_bbox_grid
# и nspd_client.get_features_in_bbox_grid (#2464-G).
logger.warning(
"_grid_walk_category: WAF/rate-limit layer=%d quarter=%s cell=%d — прерываем",
layer_id,
quarter,
idx,
)
raise
except Exception as e:
# Прочие (сетевые / parse) ошибки одного cell — тоже не валим квартал,
# но это НЕ server-side 500 → не учитываем в server_errors (иначе сеть

View file

@ -3,10 +3,10 @@
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
покрытие domrfobjective, глубина истории, шок-окно) в ОДИН отчётный уровень
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
покрытие domrfobjective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987.
цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987.
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x
движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только
@ -28,8 +28,10 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ,
мало сделок скоростные метрики статистически ненадёжны.
analog_count (ЖК-аналоги, = market_metrics.obj_count) high3 / medium2 / 1 low
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» ТЗ §15-пример).
domrf_coverage главный риск проекта (domrfobjective ~2.5%, см. market_metrics
docstring): низкое покрытие скрытый/будущий слой §9.3 недооценён.
domrf_coverage имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
domrfobjective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
медиана 40%, среднее 31.7%. Низкое покрытие рынок и конкуренция оценены хуже.
history_months зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (2 года) / _MIN_OBS=8: короткий
ряд связь ratesales / тренды не установлены.
confounded шок-окно (is_confounded_window, PR2): ряд пересекает структурный
@ -91,9 +93,11 @@ _DEAL_COUNT_LOW: int = 15
_ANALOG_COUNT_HIGH: int = 3
_ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low
# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%).
# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог
# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства).
# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое,
# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК.
# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6.
# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина
# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз.
_DOMRF_COVERAGE_HIGH: float = 0.6
_DOMRF_COVERAGE_LOW: float = 0.2
@ -252,23 +256,36 @@ _QUALITY_WORD: dict[Confidence, str] = {
def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
"""domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ
(структурный §15-пример «покрытие domrfobjective 2.5%»). None low.
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
маппинга domrfobjective, но такого продьюсера НЕТ и не было: слот
`supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в
`orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из
`analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`,
то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective.
Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% ·
max 70%. Это не «~2.5% покрытия domrfobjective», как было написано здесь
раньше, другая величина другого порядка.
Ключ фактора НЕ переименован намеренно: его читает фронт
(`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт.
Порог и значение не меняются правится только то, что читает человек.
None low.
"""
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
if coverage is None:
note = (
"Доля будущих проектов с известными планировками и площадями неизвестна — "
"оценка будущего предложения и конкуренции менее надёжна"
"Доля ближних ЖК с известной ценой из Objective неизвестна — "
"оценка рынка и конкуренции менее надёжна"
)
else:
pct = round(float(coverage) * 100.0, 1)
note = (
f"Известные планировки и площади есть у {pct}% будущих проектов "
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
"будущего предложения и конкуренции"
f"Цена из Objective известна у {pct}% ближних ЖК "
f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки "
"рынка и конкуренции"
)
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
@ -294,9 +311,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor:
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
)
return ConfidenceFactor(
name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
)
return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note)
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
@ -479,7 +494,9 @@ def compute_report_confidence(
deal_count_months: окно наблюдения для deal_count (мес) добавляет «за N мес»
в ноту фактора («7 сделок за 6 мес мало»). None нота без периода.
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта).
domrf_coverage: доля ближних ЖК с ценой из Objective [0,1]. Имя ключа
историческое про маппинг domrfobjective, продьюсера для которого
нет и не было (#2464-H, см. _coverage_factor).
history_months: глубина ряда (мес).
confounded: True, если окно ряда пересекает шок-период (PR2).
advisory: весь стек советующий cap 'medium' (по умолчанию True; почти всегда).

View file

@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None
def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None:
"""Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE.
Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ
per-branch НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8%
спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе,
который §15 призван флагать):
`supply_layers.domrf_coverage` уже ДОЛЯ [0,1] (0.025) берём как есть.
`analyze.market_data_coverage_pct` всегда ПРОЦЕНТ (2.5 == 2.5%) /100 доля.
Нет сигнала None (#990 → тянет в low: слой §9.3 недооценён).
Источники по приоритету (единица ЯВНАЯ per-branch НЕ угадываем по величине,
иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%):
`supply_layers.domrf_coverage` ДОЛЯ [0,1] берём как есть.
`analyze.market_data_coverage_pct` ПРОЦЕНТ (40 == 40%) /100 доля.
Нет сигнала None.
#2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот
`supply_layers.domrf_coverage` никто не заполняет `_summarize_supply_layers`
в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим нет
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
величина у неё другая: не «покрытие маппинга domrfobjective ~2.5%», как
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
Порядок веток оставлен: если продьюсер появится, приоритет у него.
"""
if supply_layers is not None:
coverage = supply_layers.get("domrf_coverage")

View file

@ -559,7 +559,11 @@ class NSPDClient:
"""
# Импортируем здесь чтобы избежать circular import:
# nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain)
from app.scrapers.nspd_bulk_client import NSPDBulkClient
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkServerError,
NspdBulkWafError,
)
xmin, ymin, xmax, ymax = bbox
width_m = xmax - xmin
@ -607,10 +611,45 @@ class NSPDClient:
results = await asyncio.gather(*tasks, return_exceptions=True)
features: list[NSPDFeature] = []
for r in results:
if isinstance(r, Exception):
logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r)
# #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход
# возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился
# неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669
# с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл.
# Ниже — зеркало уже исправленного близнеца
# nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror).
server_errors = 0
ok_cells = 0
first_server_error: NspdBulkServerError | None = None
for idx, r in enumerate(results):
if isinstance(r, NspdBulkWafError):
# 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход
# бессмысленно, а пустой результат соврал бы про отсутствие зон.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s",
layer_id,
idx,
r,
)
raise r
if isinstance(r, NspdBulkServerError):
server_errors += 1
if first_server_error is None:
first_server_error = r
logger.debug(
"get_features_in_bbox_grid layer=%d cell=%d server error: %s",
layer_id,
idx,
r,
)
continue
if isinstance(r, Exception):
# Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ
# считаем server-side, иначе сеть ложно поднимет layer_failed.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r
)
continue
ok_cells += 1
for bulk_feat in r:
raw = {
"id": bulk_feat.id,
@ -618,6 +657,20 @@ class NSPDClient:
"properties": bulk_feat.properties,
}
features.append(NSPDFeature.from_raw(raw))
# Были server-side отказы И ни одна ячейка не прошла — лёг слой или
# весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто
# ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого.
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
logger.warning(
"get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
"(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого",
layer_id,
effective_n,
effective_n,
server_errors,
)
raise first_server_error
return features
raw_features = asyncio.run(_run_grid())
@ -679,6 +732,10 @@ class NSPDClient:
dict[layerId, list[NSPDFeature]]. Ключи все запрошенные layerId
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
"""
# Локальный импорт по той же причине, что в get_features_in_bbox_grid:
# nspd_client ← nspd_bulk_client дало бы circular import на top-level.
from app.scrapers.nspd_bulk_client import NspdBulkServerError
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
result: dict[int, list[NSPDFeature]] = {}
for layer_id in layer_ids:
@ -686,7 +743,15 @@ class NSPDClient:
feats = self.get_features_in_bbox_grid(
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
)
except (NspdLiteError, NspdLiteWafError) as exc:
except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc:
# #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError
# («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение —
# пустой список на слой, — потому что именно это обещает докстрока
# («пустой list если слой пуст / упал») и на это опирается вызывающий.
# NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать
# обход остальных слоёв значит углублять бан.
# Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему
# неразличимы — у функции нет канала для флага. Отдельным заходом.
logger.warning(
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
layer_id,
@ -840,9 +905,19 @@ class NSPDClient:
`layers_fetched` в этом случае содержит только `('search',)`.
Raises:
NspdLiteWafError при 403/429 на любом из layer запросов caller
должен делать backoff. Partial-success НЕ возвращается; вся
операция атомарна (failure exception).
NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings)
caller должен делать backoff.
NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) —
бан IP, обход прерывается сразу.
NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и
ни одна ячейка не прошла) иначе вернулся бы пустой список,
неотличимый от честного «здесь ничего нет».
До #2464-G это место обещало атомарность, которой не было: grid-walk
глушил любое исключение ячейки и отдавал []. Теперь обещание верно
для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН:
если часть ячеек упала по сети, а часть прошла, вернётся то, что
собралось, с warning'ом в лог на каждую упавшую ячейку.
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.

View file

@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text("""
SELECT
a.room_bucket,
SUM(a.deals_window) AS deals_window,
-- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2
-- объявлен как float (не Optional), и NULL ронял бы контракт API.
-- Пустые комнатности получают площадь 0 м², и это тоже неправда но
-- честный NULL требует правки схемы + перегенерации типов фронта
-- и решения, что писать в area_bin. Отдельным заходом: #2867.
COALESCE(
SUM(a.area_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
)::numeric(10, 2) AS avg_area_m2,
COALESCE(
-- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL →
-- средней цены нет, и это NULL, а не «0 /м²». Схема так и объявлена
-- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже
-- умеет None (пропускает строку во взвешенном роллапе) но COALESCE
-- делал эту ветку недостижимой.
-- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет зависит
-- от того, сколько замапленных проектов попало в радиус, поэтому цифры
-- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635;
-- 323 проекта имеют хотя бы одну пустую комнатность, 80 пустые ВСЕ.
-- При объединении по два пустых остаётся 255 из 1267, по всему городу
-- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0.
(
SUM(a.price_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
/ NULLIF(SUM(a.deals_window), 0)
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
array_agg(DISTINCT a.project_name) AS matched_project_names,
MIN(a.window_start) AS window_start,

View file

@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object:
def _pct_share_to_percent(value: object) -> float | None:
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1).
#2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E
раньше писалось в `load_index`, но это категориальная колонка
('open'|'limited'|'closed'|NULL) число в ней фронт отбрасывает в
«неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`.
Функцию оставляю с тестами: она описывает формат листа, и она понадобится
в тот момент, когда под процент загрузки заведут числовую колонку.
Если такого решения не будет удалить вместе с тестом, а не держать молча.
None/мусор None.
"""
num = parse_reserve_number(value)
@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
rows_seen += 1
district = _cell(row, 1) # B
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
# Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места
# под неё в power_supply_centers нет — load_index категориальный,
# current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже).
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
name_norm = normalize_sc_name(str(sc_name))
@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
"reserve": reserve,
"asof": reserve_asof,
"district": str(district).strip() if district else None,
"load_pct": load_pct,
"name_norm": name_norm,
}
@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
load_index = COALESCE(
load_index,
CAST(:load_pct AS text)
),
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
-- load_index категориальная колонка
-- ('open'|'limited'|'closed'|NULL, см.
-- data/sql/180_connection_capacity.sql:35), её
-- заполняет rosseti_wfs_loader._map_load_index.
-- Раньше тут стоял COALESCE(load_index,
-- CAST(:load_pct AS text)) при пустой ячейке
-- в колонку легло бы число строкой ("72.5"),
-- а фронтовый classifyLoadIndex такое значение
-- отбрасывает в null («неизвестно»), и в
-- power_summary.by_load_index появился бы
-- бакет с именем "72.5".
-- Сегодня не стреляло только потому, что у всех
-- 3416 строк load_index уже заполнен
-- (open 2741 / limited 346 / closed 329, NULL 0)
-- и COALESCE не проваливался.
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm

View file

@ -36,6 +36,48 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы
# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`.
# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`,
# которого внутри CTE нет (`missing FROM-clause entry for table "o"`).
_COMPETITORS_SQL_TMPL = """
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
@ -173,9 +215,17 @@ def compute_velocity(
# только если явно передан. #38: при NULL реального класса используем
# obj_class_fallback (yandex_match / price_inference) — реальный obj_class
# в приоритете (COALESCE), поведение для размеченных ЖК не меняется.
class_filter = (
"AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else ""
)
# Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM —
# голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT,
# и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"`
# (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity
# молча выпадал из отчёта. Не срабатывало только потому, что единственный
# вызывающий (parcels.py) obj_class не передаёт.
# NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а
# в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870,
# «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9,
# «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки.
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else ""
# SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
# db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
# (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
@ -183,45 +233,7 @@ def compute_velocity(
with db.begin_nested():
comp_rows = (
db.execute(
text(
f"""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
),
text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)),
{
"parcel_wkt": parcel_geom_wkt,
"radius_m": radius_km * 1000.0,

View file

@ -406,16 +406,17 @@ def build_beat_schedule() -> dict:
# Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц.
# kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д.
# Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления.
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
# за ~5 недель полного обновления.
#
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# cooldown 24-48h (проверить через targeted test).
# schedule["scrape-kn-catalog-objects-weekly"] = {
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
# "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
# "kwargs": {"region_code": 66, "max_objects": 300},
# "options": {"queue": "celery"},
# }
@ -430,10 +431,10 @@ def build_beat_schedule() -> dict:
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 UTC.
# напр. четверг 04:00 МСК.
# schedule["scrape-kn-catalog-flats-weekly"] = {
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК
# "kwargs": {"region_code": 66, "max_flats": 300},
# "options": {"queue": "celery"},
# }
@ -542,13 +543,20 @@ def build_beat_schedule() -> dict:
}
# Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings.
# Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
# 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды,
# оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due
# task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три
# часа раньше обещанного.
# Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК
# завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC
# = 03:00-04:00 МСК), с которым она делит источник — tradein.houses.
# Не в job_settings (технический ETL, не требует конфигурации UI).
# Идемпотентен через ON CONFLICT (source, ext_house_id).
# Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения.
schedule["newbuilding-crossload-nightly"] = {
"task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload",
"schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК
"schedule": _parse_cron("30 0 * * *"), # 00:30 МСК
"options": {"queue": "celery"},
}

View file

@ -110,9 +110,9 @@ class TestCompetitorsSortOrder:
sorted_rows = sorted(_ROWS_MIXED, key=_sort_key)
first = dict(sorted_rows[0].items())
assert first["site_status"] == "Строящиеся", (
f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'"
)
assert (
first["site_status"] == "Строящиеся"
), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'"
def test_flat_count_desc_would_break_order(self) -> None:
"""Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми."""
@ -180,14 +180,44 @@ class TestObjPricingPushdown:
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
physflat-дедуп CTE obj_lots_latest (alias oll) см. test_obj_pricing_*_physflat
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
#2464-D: у среднего цены появились границы правдоподобия (те же, что в двух
соседних запросах по objective_lots) см. test_price_avg_has_sanity_bounds.
"""
sql = self._competitor_sql()
assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "AS avg_price_per_m2_rub" in sql
assert "lots_with_price" in sql
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
assert "GROUP BY np.domrf_obj_id" in sql
def test_price_avg_has_sanity_bounds(self) -> None:
"""#2464-D: среднее цены считается по лотам в границах правдоподобия.
Среднее считается ПО ПРОЕКТУ, поэтому один лот держит группу без ограничения
сверху: максимум в objective_lots 19.2 млн /м² (замер 13.08). Границы
30000..600000 уже стоят в двух соседних запросах по этой же таблице; здесь
их не было. Дальше значение уходит в market_avg_price и на экран.
"""
sql = self._competitor_sql()
bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000"
assert (
f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql
), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)"
# Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем
# реально участвовало в среднем.
assert (
f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql
), "lots_with_price должен считать ту же популяцию, что и среднее"
# FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены
# молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты.
assert (
"COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
), "units_sold не должен зависеть от границ цены"
assert (
"COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
), "units_available не должен зависеть от границ цены"
def test_obj_pricing_dedups_physflat_inline(self) -> None:
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.

View file

@ -96,15 +96,24 @@ def pytest_sessionfinish(session, exitstatus) -> None:
unlisted = sorted(_observed_skips - _allowed_skips())
if not unlisted:
return
print(
f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
head = (
f"НЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
)
print(f"\n{head}")
for nodeid in unlisted:
print(f" - {nodeid}")
print(
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
"пропуск без записи неотличим от пройденной проверки."
)
# #2871: под Actions дублируем в ::error:: — иначе сообщение тонет.
# 13.08 этот сторож четыре прогона подряд ронял job'у совершенно правильно,
# а его строка лежала посреди тысячи других (обычный print, по-русски) —
# и поиск по «FAILED / ERROR» её не находил. Причину искали три часа
# в диске, раннере, покрытии и кэше. Сторож, который роняет прогон,
# обязан кричать так, чтобы его нашли.
if os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"):
print(f"::error::{head} " + "; ".join(unlisted))
if exitstatus == 0:
session.exitstatus = 1

View file

@ -39,6 +39,7 @@ from sqlalchemy.orm import Session
from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL
from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL
from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL
from tests.integration.conftest import requires_test_db
# NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time
@ -103,9 +104,9 @@ class TestNeighborsSummarySql:
for kw in forbidden_aliases:
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
# формы CTE-биндинга.
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
)
assert (
f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql
), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
@ -167,3 +168,39 @@ class TestPsycopg3CastAntipattern:
f"{name} содержит psycopg v3 antipattern: {matches}. "
f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md."
)
# ── velocity: конкуренты в радиусе (#2464-G) ─────────────────────────────────
class TestVelocityCompetitorsSql:
"""``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``.
Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G
не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который
существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE
``latest_obj`` (FROM domrf_kn_objects, без алиаса)
``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08).
Почему это не падало в проде: единственный вызывающий
(``analyze_parcel``) ``obj_class`` не передаёт ветка мёртвая.
Падало бы молча исключение глотает ``except`` в ``compute_velocity``,
и блок velocity просто исчезал бы из отчёта с одной строкой в логе.
Тест закрывает обе ветки, а не только ту, что сегодня исполняется.
"""
@requires_test_db
@pytest.mark.integration
@pytest.mark.parametrize(
"class_filter",
["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"],
ids=["no_class_filter", "with_class_filter"],
)
def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None:
"""Обе подстановки шаблона парсятся и планируются против реальной схемы."""
_explain_text(
phantom_check_session,
_COMPETITORS_SQL_TMPL.format(class_filter=class_filter),
{"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"},
)

View file

@ -190,6 +190,70 @@ class TestGetFeaturesInBboxGrid:
# 4 cells: 1 error + 3 good_feat → 1 unique feature
assert any(f.feature_id == "feat-ok" for f in result)
# ── #2464-G: отказ слоя больше не маскируется пустым результатом ──────────
def _grid(self, side_effect: Any, *, grid_n: int = 2) -> list[NSPDFeature]:
"""Прогнать grid-walk с подменённым wms_feature_info."""
mock_client_instance = AsyncMock()
mock_client_instance.wms_feature_info = AsyncMock(side_effect=side_effect)
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
with patch(
"app.scrapers.nspd_bulk_client.NSPDBulkClient",
return_value=mock_client_instance,
):
return NSPDClient().get_features_in_bbox_grid(
36328, self.BBOX, grid_n=grid_n, step_m=1.0
)
def test_waf_403_aborts_grid_instead_of_empty_result(self) -> None:
"""403 WAF на ячейке — бан IP, обход прерывается.
До #2464-G исключение глушилось и метод отдавал [] — «зон здесь нет»,
неотличимое от честного пустого слоя. На проде это 124 дампа из 669
с territorial_zones_count=0, у 50 из которых соседний legacy-слой
данные всё-таки нашёл.
"""
from app.scrapers.nspd_bulk_client import NspdBulkWafError
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
raise NspdBulkWafError("HTTP 403 WAF")
with pytest.raises(NspdBulkWafError):
self._grid(_wms)
def test_all_cells_5xx_raises_instead_of_empty_result(self) -> None:
"""Все ячейки упали с 5xx — слой лёг целиком, а не «пуст»."""
from app.scrapers.nspd_bulk_client import NspdBulkServerError
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
raise NspdBulkServerError("HTTP 500 ServiceException")
with pytest.raises(NspdBulkServerError):
self._grid(_wms)
def test_partial_5xx_keeps_data_and_does_not_raise(self) -> None:
"""Часть ячеек 5xx, часть прошла — отдаём собранное, не бросаем.
Контроль к двум тестам выше: правка НЕ превращает любую ошибку в отказ.
Именно этот тест ловил бы обратную крайность «чуть что, роняем обход».
"""
from app.scrapers.nspd_bulk_client import NspdBulkServerError
good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"})
call_n: list[int] = [0]
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
call_n[0] += 1
if call_n[0] <= 2:
raise NspdBulkServerError("HTTP 500 ServiceException")
return [good_feat]
result = self._grid(_wms)
assert any(
f.feature_id == "feat-ok" for f in result
), "успешные ячейки должны попасть в результат, даже если часть слоя упала"
def test_returns_nspd_feature_instances(self) -> None:
"""Метод возвращает list[NSPDFeature] а не NSPDBulkFeature."""
bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"})

View file

@ -133,19 +133,33 @@ class TestFactorFromCount:
assert "12.5 мес истории" in f_frac.note
# ── _coverage_factor — покрытие domrf↔objective в % ────────────────────────────
# ── _coverage_factor — покрытие рынка ценами Objective в % ─────────────────────
class TestCoverageFactor:
def test_low_coverage_percent_in_note(self) -> None:
# Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15).
# 2.5% покрытия → low, % в ноте (структурный §15).
f = _coverage_factor(0.025)
assert f.level == "low"
assert f.value == 0.025
assert "2.5%" in f.note
# #1963: нота человеческая, без внутр.жаргона «domrf↔objective».
assert "domrf" not in f.note
assert "будущ" in f.note # говорит про будущее предложение/проекты
def test_note_names_what_is_actually_measured(self) -> None:
"""#2464-H: нота называет ближние ЖК и цену, а не «будущие проекты».
Значение фактора ВСЕГДА приходит из `analyze.market_data_coverage_pct`
= competitors_priced / competitors_total, то есть доля ближних ЖК (3 км)
с ценой из Objective. Слот `supply_layers.domrf_coverage`, под который
писалась старая формулировка, никто не заполняет.
"""
f = _coverage_factor(0.4)
assert "ближних ЖК" in f.note, f.note
assert "Objective" in f.note, f.note
assert (
"будущ" not in f.note
), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
def test_high_coverage(self) -> None:
f = _coverage_factor(0.75)
@ -158,6 +172,14 @@ class TestCoverageFactor:
assert "неизвестн" in f.note
assert "domrf" not in f.note
def test_factor_key_unchanged(self) -> None:
"""Ключ фактора остаётся `domrf_coverage` — его читает фронт.
Контроль к правке #2464-H: меняем только человеческий текст, не контракт
(ForecastConfidenceBlock / ConfidencePanel маппят имя в RU-подпись).
"""
assert _coverage_factor(0.4).name == "domrf_coverage"
def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
# До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) —

View file

@ -1393,6 +1393,65 @@ async def test_grid_walk_marks_layer_failed_when_all_cells_500() -> None:
assert layer_failed is True
@pytest.mark.asyncio
async def test_grid_walk_reraises_waf_instead_of_swallowing() -> None:
"""#2464-A: 403 WAF прерывает обход, а не превращается в «cell не дошёл».
Контракт harvest_quarter (Raises:) обещает пробросить NspdBulkWafError, но
голый `except Exception` в цикле ячеек его глотал. Прод-замер 13.08:
23 job'а в cadastre_jobs, суммарно 50 WAF-блоков — и НИ ОДНОГО упавшего
job'а. То есть бан ни разу не остановил сбор, как обещано.
"""
from app.scrapers.nspd_bulk_client import NspdBulkWafError
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF"))
with pytest.raises(NspdBulkWafError):
await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
@pytest.mark.asyncio
async def test_grid_walk_reraises_rate_limit() -> None:
"""#2464-A: исчерпанные ретраи — тоже не «пустой слой» (caller может retry)."""
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=NspdBulkRateLimitError("429"))
with pytest.raises(NspdBulkRateLimitError):
await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
@pytest.mark.asyncio
async def test_grid_walk_still_tolerates_network_error_per_cell() -> None:
"""Контроль обратной крайности: сетевая ошибка ячейки обход НЕ роняет.
Зелёный с обеих сторон правки проверяет, что #2464-A не превратил любое
исключение в отказ квартала.
"""
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=OSError("connection reset"))
upserted, requests, layer_failed = await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
assert upserted == 0
assert requests == 9
assert layer_failed is False, "сетевые сбои НЕ должны поднимать layer_failed"
@pytest.mark.asyncio
async def test_grid_walk_layer_not_failed_when_some_cells_ok() -> None:
"""Issue #252: если хоть один cell прошёл — layer_failed=False (слой жив, просто пуст)."""

View file

@ -34,6 +34,7 @@ tests/test_layout_tz_pdf.py
# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py.
# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**.
tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap
tests/integration/test_analyze_parcels_sql.py::TestVelocityCompetitorsSql::test_explain_competitors
tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary
tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query

View file

@ -1,11 +1,11 @@
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
корневого каталога не задача этого PR, поэтому здесь проверяются инварианты, которые
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был
(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога
и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git:
tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты,
выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
регрессии, которые иначе всплывают только на проде во время деплоя.
Тест не требует БД только чтение файлов.
"""

View file

@ -184,10 +184,36 @@ def test_load_ps_35_220_parse_and_match() -> None:
assert first["installed"] == 40.0
assert first["reserve"] == 15.0
assert first["district"] == "Ленинский"
assert first["load_pct"] == 41.0 # доля 0.41 → 41.0%
assert first["asof"] == date(2026, 6, 30)
def test_load_ps_35_220_does_not_write_load_percent() -> None:
"""#2464-B: степень загрузки НЕ уходит в UPDATE и не попадает в load_index.
Раньше значение колонки E писалось как
`load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index
категориальная колонка ('open'|'limited'|'closed'|NULL,
data/sql/180_connection_capacity.sql:35): число строкой фронт отбрасывает
в «неизвестно» (classifyLoadIndex), а в power_summary.by_load_index
появлялся бы бакет с именем вроде "41.0".
На проде не стреляло только потому, что load_index заполнен у всех строк
(open 2741 / limited 346 / closed 329, NULL 0 замер верификации 13.08),
и COALESCE не проваливался.
"""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=1)
ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
# Комментарии из SQL убираем: слово load_index встречается в пояснении,
# а проверять надо ИСПОЛНЯЕМЫЙ текст, а не прозу вокруг него.
sql_code = "\n".join(line.split("--", 1)[0] for line in str(db.calls[0][0]).splitlines())
assert "load_index" not in sql_code, sql_code
for _sql, params in db.calls:
assert "load_pct" not in params, params
def test_load_ps_35_220_unmatched_counted() -> None:
"""ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем."""
from datetime import date

View file

@ -9,3 +9,21 @@ def test_health() -> None:
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
def test_health_head_ok_no_body() -> None:
"""HEAD /health — то, что реально шлёт внешний uptime-monitor через Caddy
(`handle /health { reverse_proxy backend:8000 }`, Caddyfile:60), не GET.
Starlette не добавляет HEAD автоматически к `@app.get()` (в отличие от
низкоуровневого `Route(methods=["GET"])`) без явного `@app.head()`
прод-эндпоинт отдаёт 405 на HEAD.
"""
client = TestClient(app)
response = client.head("/health")
assert response.status_code == 200
assert response.content == b""
# RFC 9110 §9.3.2 — заголовки представления (Content-Type) должны совпадать
# с GET; Content-Length допустимо не совпадать (payload header field, MAY
# быть опущен для HEAD).
assert response.headers["content-type"] == "application/json"

View file

@ -0,0 +1,44 @@
"""Проверка, что сторож пропусков кричит под Actions (#2871)."""
from __future__ import annotations
import types
import tests.conftest as ct
def _run_guard(monkeypatch, capsys, *, ci: bool, observed: set[str]) -> str:
monkeypatch.setattr(ct, "_observed_skips", observed)
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
monkeypatch.delenv("CI", raising=False)
if ci:
monkeypatch.setenv("GITHUB_ACTIONS", "true")
session = types.SimpleNamespace(exitstatus=0)
ct.pytest_sessionfinish(session, 0)
return capsys.readouterr().out, session.exitstatus
def test_guard_emits_error_annotation_under_actions(monkeypatch, capsys) -> None:
out, rc = _run_guard(monkeypatch, capsys, ci=True, observed={"tests/x.py::test_y"})
assert "::error::" in out, "под Actions сторож обязан подниматься в аннотации"
assert "tests/x.py::test_y" in out
assert rc == 1
def test_guard_stays_quiet_locally(monkeypatch, capsys) -> None:
"""Контроль: локально ::error:: не нужен, человеческое сообщение остаётся."""
out, rc = _run_guard(monkeypatch, capsys, ci=False, observed={"tests/x.py::test_y"})
assert "::error::" not in out
assert "НЕУЧТЁННЫЙ ПРОПУСК" in out
assert rc == 1
def test_guard_silent_when_all_skips_declared(monkeypatch, capsys) -> None:
"""Контроль: без незадекларированных пропусков сторож молчит и не роняет."""
monkeypatch.setattr(ct, "_observed_skips", set())
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
session = types.SimpleNamespace(exitstatus=0)
ct.pytest_sessionfinish(session, 0)
assert capsys.readouterr().out == ""
assert session.exitstatus == 0

View file

@ -78,6 +78,16 @@ services:
image: postgis/postgis:16-3.4
logging: *default-logging
restart: unless-stopped
# #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ,
# и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10:
# база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один
# параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает
# в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это
# и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices).
# 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12.
# tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса).
# Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер.
shm_size: 1gb
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
@ -331,9 +341,19 @@ services:
REDIS_URL: redis://redis:6379/2
SECRET_KEY: ${GLITCHTIP_SECRET}
PORT: "8080"
EMAIL_URL: consolemail://
# Почта отключена по умолчанию: consolemail:// печатает письмо в stdout и
# никуда его не отправляет. Реальный адрес приходит из /opt/gendesign/.env
# (GLITCHTIP_EMAIL_URL) — в репозитории пароля почтового ящика быть не должно.
#
# ВАЖНО про схему DSN (django-environ, парсер GlitchTip): для порта 465 с
# implicit SSL нужна схема smtp+ssl://, а НЕ smtps:// — вторая помечена
# deprecated и включает STARTTLS (EMAIL_USE_TLS), то есть 465 с ней рвёт
# соединение. Для 587/STARTTLS схема — smtp+tls://.
# smtp+ssl://alerts%40meraocenka.ru:ПАРОЛЬ@smtp.beget.com:465
# Логин — почтовый адрес целиком, @ в нём кодируется как %40.
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
DEFAULT_FROM_EMAIL: errors@gendsgn.ru
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
ENABLE_USER_REGISTRATION: "true"
ENABLE_ORGANIZATION_CREATION: "false"
restart: always
@ -365,9 +385,27 @@ services:
REDIS_URL: redis://redis:6379/2
SECRET_KEY: ${GLITCHTIP_SECRET}
CELERY_WORKER_AUTOSCALE: "1,3"
# Письма и веб-хуки шлёт celery, то есть ИМЕННО этот контейнер, а не web.
# До этой правки почтовых переменных здесь не было вовсе: настройка одного
# glitchtip-web не дала бы ни одного отправленного письма — worker брал
# умолчания образа. Значения обязаны совпадать с web (см. комментарий там).
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
# Нужен для абсолютных ссылок внутри писем и веб-хуков: без него
# уведомление приходит со ссылкой в никуда.
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
restart: always
mem_limit: 384m
networks: [default]
# GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15,
# см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО
# этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web),
# получателю `webhook` нужен доступ к http://tradein-backend:8000/... —
# tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было
# вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того,
# как переменная окружения с URL вообще имеет смысл). default — обязательно
# явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis
# выше про неявную привязку к default).
networks: [default, shared]
caddy:
image: caddy:2

180
ops/docker-prune.sh Executable file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Периодическая уборка docker-мусора на прод-VM.
#
# ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201
# том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых
# прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не
# было ни одного.
#
# Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml
# снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил,
# а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он
# подбирает то, что runner не убрал за собой, и то, что накопилось раньше.
#
# ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно):
# - остановленные контейнеры старше 24ч;
# - висячие (dangling) образы старше 7 суток;
# - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и
# FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data,
# tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются
# НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune`
# такой разницы не делает, поэтому здесь он намеренно не используется;
# - зависшие (running, но фактически брошенные) job-контейнеры раннера Forgejo
# Actions старше JOB_CONTAINER_MAX_AGE_HOURS. 2026-08-15: живьём на проде
# обнаружены три штуки в статусе Up 4-8 недель (раннер не убрал контейнер
# после прерванного/упавшего workflow — task killed, рестарт раннера в
# процессе job'а и т.п.). CI job физически не идёт сутками, поэтому что
# угодно с этим именем старше порога — гарантированный мусор, а не активная
# задача. `docker container prune` их не видит: тот фильтрует только
# status=exited, а эти контейнеры формально Up.
#
# Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает).
# Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы):
# 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1
#
# Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup
# tradein, 05:00+ backfill'ы.
#
# Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI)
# копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая.
set -euo pipefail
DRY_RUN="${DRY_RUN:-0}"
STOPPED_AGE="${STOPPED_AGE:-24h}"
IMAGE_AGE="${IMAGE_AGE:-168h}"
# Job CI никогда не идёт сутками — что угодно с именем job-контейнера раннера
# старше этого порога снимается безусловно (см. секцию 4 ниже).
JOB_CONTAINER_MAX_AGE_HOURS="${JOB_CONTAINER_MAX_AGE_HOURS:-24}"
log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; }
before_pct="$(disk_used_pct)"
log "старт: диск занят ${before_pct}%"
if [[ "$DRY_RUN" == "1" ]]; then
log "DRY_RUN=1 — только показываю"
fi
# ── 1. остановленные контейнеры ───────────────────────────────────────────────
if [[ "$DRY_RUN" == "1" ]]; then
# `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»),
# поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка.
log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \
$(docker container ls -aq --filter "status=exited" | wc -l)"
else
log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \
2>&1 | tail -1)"
fi
# ── 2. висячие образы ─────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == "1" ]]; then
log "висячих образов: $(docker image ls -qf dangling=true | wc -l)"
else
log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)"
fi
# ── 3. тома-сироты известных форм ─────────────────────────────────────────────
# Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой
# отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся.
mapfile -t candidates < <(
docker volume ls -qf dangling=true \
| grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true
)
skipped="$(docker volume ls -qf dangling=true \
| grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)"
if [[ -n "$skipped" ]]; then
log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')"
fi
if [[ "${#candidates[@]}" -eq 0 ]]; then
log "томов-сирот известных форм нет"
elif [[ "$DRY_RUN" == "1" ]]; then
log "томов к удалению: ${#candidates[@]}"
else
removed=0
for v in "${candidates[@]}"; do
if docker volume rm "$v" >/dev/null 2>&1; then
removed=$((removed + 1))
fi
done
log "томов удалено: ${removed} из ${#candidates[@]}"
fi
# ── 4. зависшие job-контейнеры раннера Forgejo Actions ───────────────────────
# Фильтр по имени — ЯКОРЬ на начало (`^FORGEJO-ACTIONS-TASK-`), не "содержит
# подстроку": `docker ps --filter name=` матчит как regex, поэтому `^...`
# гарантирует точный префикс, а не случайное совпадение где-то в середине
# имени сервисного контейнера. Долгоживущие сервисные контейнеры (forgejo,
# forgejo-runner*, gendesign-*, tradein-*, couchdb) под этот префикс не
# подпадают вообще — но ниже всё равно есть explicit-skip как страховка на
# случай будущего переименования, а не молчаливая надежда на то, что фильтр
# никогда не ошибётся.
#
# Возраст — из `docker inspect .State.StartedAt` (RFC3339), НЕ из текстового
# "Up 4 weeks" в выводе `docker ps`: тот округляет к ближайшей крупной единице
# и не пригоден для сравнения с порогом в часах.
mapfile -t job_ids < <(docker ps -aq --filter "name=^FORGEJO-ACTIONS-TASK-" || true)
job_removed=0
job_candidates=0
if [[ "${#job_ids[@]}" -eq 0 ]]; then
log "зависших job-контейнеров нет"
else
for id in "${job_ids[@]}"; do
name="$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##' || true)"
[[ -z "$name" ]] && continue
case "$name" in
forgejo | forgejo-runner* | gendesign-* | tradein-* | couchdb)
log "job-контейнеры: ПРОПУЩЕН сервисный '${name}' (не должен был пройти фильтр имени)"
continue
;;
esac
started_at="$(docker inspect --format '{{.State.StartedAt}}' "$id" 2>/dev/null || true)"
[[ -z "$started_at" || "$started_at" == "0001-01-01T00:00:00Z" ]] && continue
started_epoch="$(date -u -d "$started_at" +%s 2>/dev/null || echo 0)"
[[ "$started_epoch" -eq 0 ]] && continue
now_epoch="$(date -u +%s)"
age_hours=$(((now_epoch - started_epoch) / 3600))
[[ "$age_hours" -lt "$JOB_CONTAINER_MAX_AGE_HOURS" ]] && continue
job_candidates=$((job_candidates + 1))
size="$(docker ps -a --filter "id=${id}" --size --format '{{.Size}}' 2>/dev/null \
| awk '{print $1}' || true)"
if [[ "$DRY_RUN" == "1" ]]; then
log "job-контейнеры: [dry-run] снял бы '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?})"
continue
fi
if docker rm -f "$id" >/dev/null 2>&1; then
job_removed=$((job_removed + 1))
log "job-контейнеры: снят '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?} освобождён)"
else
log "job-контейнеры: НЕ удалось снять '${name}' (id ${id:0:12})"
fi
done
if [[ "$job_candidates" -eq 0 ]]; then
log "job-контейнеры: ${#job_ids[@]} шт., ни один не старше порога ${JOB_CONTAINER_MAX_AGE_HOURS}ч"
elif [[ "$DRY_RUN" == "1" ]]; then
log "job-контейнеры: к снятию ${job_candidates} из ${#job_ids[@]}"
else
log "job-контейнеры: снято ${job_removed} из ${job_candidates} кандидатов (порог ${JOB_CONTAINER_MAX_AGE_HOURS}ч)"
fi
fi
after_pct="$(disk_used_pct)"
log "готово: диск занят ${after_pct}% (было ${before_pct}%)"
# Сигнал в лог, если места всё равно мало — повод посмотреть глазами.
if [[ "$after_pct" -ge 85 ]]; then
log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно"
fi

View file

@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta
Throttle: при >10 401 events за 60s однократный digest event
(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда
дропаются перед отправкой в GlitchTip 401 от неаутентифицированного запроса
не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это
был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит
лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) просто
больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring.
Реальный Caddy JSON access log (v2) структура:
{
"level": "info",
@ -73,6 +80,41 @@ _shutdown = False
_last_exc_sent: float = 0.0
_EXC_THROTTLE_S: float = 300.0
# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое
# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ
# для before_send-фильтра.
_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"})
def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ
ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый
basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого
фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или
storm-digest) в GlitchTip remote_ip в message/тегах раздувал кардинальность
(3 738 issue, 2 019 различных заголовков, топ 222 события на «GET
/wp-admin/install.p»), топя содержательные алерты (OperationalError, sweep
failures) в шуме сканеров.
Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера
так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по
тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest необработанные
исключения самого форвардера (`capture_exception` в конце `main()`, реальный
баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except
Exception` ниже в `main()`).
"""
tags = event.get("tags")
event_type = None
if isinstance(tags, dict):
event_type = tags.get("event_type")
elif isinstance(tags, list):
# sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]]
# вместо dict — на всякий случай поддерживаем обе формы.
event_type = dict(tags).get("event_type") if tags else None
if event_type in _BASIC_AUTH_EVENT_TYPES:
return None
return event
def _signal_handler(signum: int, frame: object) -> None:
global _shutdown
@ -221,6 +263,7 @@ def main() -> None:
traces_sample_rate=0.0,
attach_stacktrace=False,
send_default_pii=False,
before_send=_drop_basic_auth_noise,
# Отключаем интеграции которые не нужны тонкому sidecar
default_integrations=False,
)

View file

@ -0,0 +1,77 @@
"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise).
Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый
basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019
различных заголовков (remote_ip раздувал кардинальность), топя содержательный
сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send),
но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта
`capture_exception` без `event_type`-тега, аналог "500 должен пройти").
"""
from __future__ import annotations
import os
# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём
# ДО импорта forwarder.py, иначе импорт падает KeyError.
os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1")
from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise
def test_drops_individual_basic_auth_401() -> None:
"""emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера,
не ошибка сервиса, должен быть отброшен (return None)."""
event = {
"tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"},
"message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_basic_auth_storm_digest() -> None:
"""emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже
не ошибка сервиса, дропаем."""
event = {
"tags": {"event_type": "basic_auth_storm"},
"message": "basic_auth storm — 15 failed attempts in 60s",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_when_tags_serialized_as_list_of_tuples() -> None:
"""Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]]
вместо dict фильтр обязан поддерживать обе формы."""
event = {"tags": [("event_type", "basic_auth_failed")]}
assert _drop_basic_auth_noise(event, {}) is None
def test_passes_through_forwarder_own_crash() -> None:
"""500-аналог: unhandled exception самого форвардера (capture_exception в
конце main(), реальный баг скрипта напр. PermissionError на STATE_FILE) не
несёт event_type-тег должен пройти НЕТРОНУТЫМ, не быть молча проглоченным
вместе с ботовым шумом."""
event = {
"level": "error",
"exception": {"values": [{"type": "PermissionError", "value": "denied"}]},
}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_event_without_tags() -> None:
event: dict = {"message": "something unrelated"}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_unrelated_tag_value() -> None:
event = {"tags": {"event_type": "something_else"}}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None:
"""Явная фиксация словаря — emit_event → basic_auth_failed,
emit_digest basic_auth_storm (см. forwarder.py)."""
assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"})

View file

@ -0,0 +1,36 @@
# systemd-journald drop-in — cap persistent journal disk usage on prod VPS.
#
# ЗАМЕР 2026-08-15 (ssh gendesign, read-only): `/var/log` занимал 3.1G. Наивная
# первая проверка `journalctl --disk-usage` показала только 174M и навела на
# ложный след «основной объём — не journald». На деле `journalctl --disk-usage`,
# запущенный НЕ из группы systemd-journal/adm, недосчитывает — он не может
# полноценно перечислить архивные *.journal файлы без прав на чтение. Прямой
# `du -sh /var/log/journal` дал 2.5G — это ~80% всего `/var/log`, ровно 100
# файлов по ~48M в /var/log/journal/<machine-id>/. Второй по размеру вклад —
# традиционный rsyslog (syslog/syslog.1/auth.log/kern.log/ufw.log/dmesg/btmp,
# ~0.6G) — те уже ротируются через logrotate (видны .1/.4.gz копии), отдельного
# вмешательства не требуют и вне scope этого файла.
#
# В /etc/systemd/journald.conf на проде НЕТ SystemMaxUse (все ключи закомменчены
# дефолтами) — без явного лимита journald довольствуется default-правилом
# «до 10% файловой системы», на VPS с диском ~145G это фактически безлимит.
#
# УСТАНОВКА НА СЕРВЕРЕ (руками, deploy.yml этот файл НЕ подхватывает —
# systemd-конфиги вне /opt/gendesign, деплой синкает только сам репозиторий):
# sudo mkdir -p /etc/systemd/journald.conf.d
# sudo cp /opt/gendesign/ops/journald-gendesign.conf.example \
# /etc/systemd/journald.conf.d/gendesign-max-use.conf
# sudo systemctl restart systemd-journald
#
# `restart systemd-journald` применяет лимит немедленно — journald сам
# провакуумит существующие архивные файлы вниз до SystemMaxUse (ожидаемый
# эффект: /var/log/journal схлопнется примерно с 2.5G до ~500M). Это НЕ
# `docker volume rm` / `caddy reload` — под общий deploy-guard не подпадает,
# но всё равно на живом проде: делает user сам после ревью PR.
#
# Значение 500M — консервативный запас на 4 vCPU/4-16G VPS с активным CI
# (docker/forgejo-runner логи в journald тоже льются). При необходимости
# больше retention для дебага — поднять SystemMaxUse, не удалять файл.
[Journal]
SystemMaxUse=500M

0
ops/restore.sh Normal file → Executable file
View file

View file

@ -3,8 +3,16 @@
#
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий
# (у страницы один канонический адрес, старые ссылки не ломаются).
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
# напечатаны внутри самих юридических документов и уходят эквайеру.
# 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга.
# 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ
# /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале
# вела в 404).
# 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на
# публичном домене по-прежнему 404.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
@ -41,16 +49,52 @@ check() {
fi
}
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 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
fi
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а
# не 200: с 15.08.2026 у публичной страницы один канонический адрес.
# Проверка осталась именно здесь, потому что раньше она сторожила
# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при
# переходе на короткие адреса длинные не превратились в 404 (тогда бы
# сломались уже разосланные ссылки).
check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
# эти три URL напечатаны внутри самих документов и уходят в заявку
# эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на
# 404, и заявку завернут. Проверяем все три поимённо, потому что и в
# Caddyfile они перечислены поимённо (allowlist, не шаблон).
check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200
check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
# `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в
# подвале v3 вела в 404.
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
@ -68,6 +112,26 @@ 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
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.
#
# Пара проверок ниже неразделима: первая доказывает, что форма вообще
# работает, вторая — что новый handle не расширил периметр до
# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это
# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный
# префикс, а не поимённый проброс v1-путей).
check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200
check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404
check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404
# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда
# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он
# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит
# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile.
check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
#
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
@ -90,6 +154,27 @@ check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/use
check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301
check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301
# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
# СТОРОН прямо сейчас:
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
# платёжные пути и требует X-Authenticated-User → 401 анониму.
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
echo "========================================"
if [ "$fail" -eq 0 ]; then
echo "ALL CHECKS PASSED"

View file

@ -0,0 +1,7 @@
"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru.
Отдельный пакет, а не ещё один модуль в `app/api/v1/`, намеренно: всё, что
лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по
дереву каталогов, а не по списку исключений в middleware. Разбор в
`app/api/public/mera.py`.
"""

View file

@ -0,0 +1,288 @@
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки.
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
-------------------------------------------------------
На `meraocenka.ru` действует allowlist-by-default: Caddy проксирует поимённо
перечисленные пути, всё остальное 404 (см. корневой Caddyfile, site-блок
meraocenka.ru; регресс scripts/smoke-mera-perimeter.sh). Чтобы открыть там
API, нужно было выбрать одно из двух:
(а) пробросить `/trade-in/api/v1/trade-in/coverage` и `.../geocode/suggest`
поимённо периметр остаётся узким, но одна опечатка в matcher'е
(`/trade-in/api/*` вместо точного пути) открывает наружу ВЕСЬ v1: ~20
ручек, включая PDF расчётов, фотографии объектов, историю и админку;
(б) завести отдельный префикс, под которым по определению не может лежать
ничего закрытого, и пробрасывать его целиком.
Выбрано (б). Разница не в удобстве, а в цене ошибки: при (а) безопасность
периметра держится на аккуратности матчера, при (б) на структуре кода.
Добавить сюда ручку с приватными данными нужно СПЕЦИАЛЬНО (положить файл в
`app/api/public/`), случайно нельзя.
Тот же принцип, что уже применён на фронте: публичный лэндинг вынесен в
`app/mera-public/` с guard-скриптом на граф импортов, а не помечен флагом
внутри общего дерева.
АНОНИМНОСТЬ
-----------
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками
не префиксом: множество там frozenset с проверкой `path in ...`, и
добавление префиксной ветки ради двух путей расширило бы механизм, которым
пользуется весь бэкенд, ради одной фичи.
ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ
-----------------------
Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage`
чистое чтение (один SELECT), `/suggest` прокси автокомплита. Это не
случайность, а условие, при котором публичная форма может работать ДО того,
как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица
попадает в `trade_in_estimates` раньше любого согласия, а пути удаления
данных в бэкенде нет). Платный расчёт, который писать будет, открывается
отдельно и только вместе с этим контуром.
БЮДЖЕТЫ
-------
Общий `RateLimitMiddleware` (300/60с на IP) здесь недостаточен: `/suggest`
через DaData-тир геокодера платный внешний вызов, то есть абуз стоит денег,
а не только CPU. Поэтому у каждой ручки свой, заведомо более узкий per-IP
бюджет поверх общего тот же приём, что у анонимного чата поддержки
(app/api/v1/support.py, `_anon_ip_limiter`).
Лимитеры in-process: при нескольких репликах бэкенда бюджет умножится на их
число. Сейчас реплика одна (docker-compose, tradein-backend), что и делает
допущение верным; при масштабировании выносить в Redis (issue заводить
тогда же, не раньше: преждевременный вынос добавит зависимость без выигрыша).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, suggest_addresses
from app.api.v1.trade_in import coverage_probe
from app.core.db import get_db
from app.core.public_request import install_address_log_redaction, public_request_scope
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse
logger = logging.getLogger(__name__)
# Публичная форма обещает, что введённый адрес нигде не сохраняется. По базам
# это так, по журналам не было — геокодер печатал запрос открытым текстом, а
# прод пишет stdout в persistent journald. Ставим редакцию логов в момент
# импорта модуля (его импортирует app/main.py) — то есть ровно тогда, когда
# публичные ручки вообще появляются в приложении. Разбор — в
# app/core/public_request.py.
install_address_log_redaction()
router = APIRouter()
# Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес
# с debounce'ом — это единицы запросов на один адрес, поэтому 40/мин хватает
# на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг
# осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь,
# нажал ещё раз».
_SUGGEST_LIMIT = 20
_COVERAGE_LIMIT = 15
_WINDOW_S = 60.0
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S)
# ── Общий суточный потолок публичных подсказок ──────────────────────────────
#
# Per-IP окна одного клиента ограничивают, но не ограничивают СУММУ. Считаем:
# 20 запросов/мин с одного адреса — это 28 800 в сутки, а весь бесплатный тир
# DaData у проекта — 10 000 в сутки И ОН ОБЩИЙ с закрытым контуром. То есть без
# этого потолка один настойчивый клиент (или один скрипт) за несколько часов
# выедает квоту, и подсказки перестают работать у ПЛАТЯЩИХ пилотов, а не только
# у него. Найдено состязательным ревью и подтверждено на проде: достаточно
# упомянуть в запросе не-екатеринбургский город, чтобы локальный кадастровый
# тир отключился и запрос гарантированно ушёл во внешний сервис.
#
# 2000/сутки — заведомо меньше десятой доли тира: публичная форма не должна
# уметь навредить закрытому контуру в принципе. Порог достижим только абузом
# (живой посетитель тратит единицы запросов на адрес), поэтому исчерпание —
# сигнал, а не штатный режим: логируем ошибкой.
_DAILY_SUGGEST_BUDGET = 2000
_daily_suggest_limiter = SlidingWindowLimiter(limit=_DAILY_SUGGEST_BUDGET, window_s=86_400.0)
_GLOBAL_KEY = "public-suggest"
# ── Потолок одновременных подсказок ─────────────────────────────────────────
#
# Кадастровый тир геокодера уходит в FDW-скан ЧУЖОЙ базы (gendesign) и на
# коротком вводе занимает около секунды, всё это время удерживая соединение из
# пула. Пул общий с закрытым контуром и невелик (дефолт SQLAlchemy 5+10), так
# что полтора десятка одновременных публичных подсказок способны положить
# B2B-запросы в том же процессе — при том, что per-IP лимиты каждого из них
# формально соблюдены.
#
# Ждём слот недолго и отвечаем 429, а не копим очередь: очередь под нагрузкой
# превращается в те же занятые соединения плюс растущий таймаут у клиента.
_SUGGEST_CONCURRENCY = 4
_SUGGEST_SLOT_WAIT_S = 2.0
_suggest_slots = asyncio.Semaphore(_SUGGEST_CONCURRENCY)
def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None:
"""429 при превышении per-IP бюджета. Попытку регистрируем ДО работы ручки.
В отличие от отправки сообщения в поддержку (там `record()` только на
успех, чтобы неудача не съедала бюджет), здесь считаем каждую попытку:
внешний вызов геокодера тратится и на запросе, который вернёт пусто,
иначе перебор мусорными строками не стоил бы атакующему ничего.
"""
ip = _client_ip(request)
retry_after = limiter.retry_after(ip)
if retry_after is not None:
logger.info("public mera %s rate-limited for %s", what, ip)
raise HTTPException(
status_code=429,
detail="Слишком много запросов. Попробуйте через минуту.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
limiter.record(ip)
class PublicSuggestInput(BaseModel):
"""Вход публичного автокомплита.
Телом, а не query-параметрами см. `public_suggest`.
"""
q: str = Field(min_length=2, max_length=200)
limit: int = Field(default=8, ge=1, le=10)
city_hint: str | None = Field(default=None, max_length=100)
def _fold(text: str) -> str:
"""ёЁ→еЕ + casefold — та же нормализация, что у городов в trade_in.py."""
return text.translate(str.maketrans("ёЁ", "ее")).casefold()
def _query_with_city(query: str, city_hint: str | None) -> str:
"""Подставить выбранный город В САМУ СТРОКУ запроса.
ЗАЧЕМ. `city_hint` доезжает до геокодера, но НА ВЫДАЧУ ПОДСКАЗОК НЕ ВЛИЯЕТ:
его использует только екатеринбургский кадастровый тир (как признак «речь
не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и
хинта не принимает. Замер на проде 16.08.2026: выбран Серов, введено
«Ленина 1» первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский».
Человек выбирает верхний вариант и считает совсем чужой дом ровно тот
баг #2576, ради которого город и спрашивают.
С городом в строке («Серов Ленина 1») выдача становится серовской целиком
проверено там же.
Для Екатеринбурга подстановка безвредна: три разных адреса дали
побайтово тот же результат с префиксом и без (кадастровый тир парсит
улицу и дом одинаково). Поэтому правило одно на все города, без
исключения для основного трафика исключение пришлось бы поддерживать.
Чинится ЗДЕСЬ, а не в геокодере: там от `city_hint` зависит поведение
закрытого контура (`target_city_ambiguous`), и менять его смысл ради
публичной формы значит трогать чужой контракт.
"""
if not city_hint:
return query
if _fold(city_hint) in _fold(query):
return query
return f"{city_hint}, {query}"
@router.post("/suggest", response_model=SuggestResponse)
async def public_suggest(
request: Request,
payload: PublicSuggestInput,
db: Annotated[Session, Depends(get_db)],
) -> SuggestResponse:
"""Автокомплит адреса для публичной формы (Свердловская область).
ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на
публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а
он пишет URI целиком то есть адрес квартиры лёг бы в файл рядом с IP
посетителя. Мы публично обещаем на этой же странице, что введённый адрес
нигде не сохраняем; лог это сохранение. Тело запроса в лог не попадает,
поэтому обещание остаётся правдой без правки конфигурации логирования (её
легко потерять при следующем рефакторинге Caddyfile а тип запроса
потерять нельзя, сломается сразу и заметно).
Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а
не через query-параметры (frontend `estimate-draft.ts`).
Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран
(`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику:
публичная форма обязана резолвить адрес ровно так же, как платный расчёт,
иначе аноним выберет дом, которого потом «не окажется».
Отличие от v1 ровно одно потолок `limit` 10 вместо 15: выдача сверх
десятка в публичном UI не показывается, а каждый лишний кандидат может
стоить внешнего вызова.
"""
_enforce(_suggest_limiter, request, "suggest")
# Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его
# собственным лимитом, и только оставшееся считаем в общий бюджет.
daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY)
if daily_retry is not None:
logger.error(
"публичные подсказки исчерпали суточный бюджет (%d) — квота геокодера "
"защищена, но форма на лэндинге сейчас без автокомплита",
_DAILY_SUGGEST_BUDGET,
)
raise HTTPException(
status_code=429,
detail="Подсказки адреса временно недоступны. Введите адрес полностью.",
headers={"Retry-After": str(int(daily_retry) + 1)},
)
_daily_suggest_limiter.record(_GLOBAL_KEY)
try:
await asyncio.wait_for(_suggest_slots.acquire(), timeout=_SUGGEST_SLOT_WAIT_S)
except TimeoutError:
raise HTTPException(
status_code=429,
detail="Сервис сейчас занят. Попробуйте ещё раз через несколько секунд.",
headers={"Retry-After": "5"},
) from None
try:
# Пометка публичного запроса нужна ровно здесь: внутри `suggest_addresses`
# геокодер логирует введённую строку, а публичная форма обещает, что
# адрес не попадает в журналы.
with public_request_scope():
return await suggest_addresses(
q=_query_with_city(payload.q, payload.city_hint),
limit=payload.limit,
db=db,
city_hint=payload.city_hint,
)
finally:
_suggest_slots.release()
@router.post("/coverage", response_model=CoverageProbeResponse)
def public_coverage(
request: Request,
payload: CoverageProbeInput,
db: Annotated[Session, Depends(get_db)],
) -> CoverageProbeResponse:
"""Бесплатная проба покрытия (issue #2894) для публичной формы.
Делегирует в `app.api.v1.trade_in.coverage_probe` ту же функцию, что
вызывает закрытый контур. Копии SQL здесь нет намеренно: разбор #2894
показал, что стоит когорте пробы разойтись с когортой платного расчёта
проба честно отвечает «есть данные» там, где расчёт увидит ноль.
Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) бесплатный
шаг доказывает наличие данных, цену продаёт платный.
"""
_enforce(_coverage_limiter, request, "coverage")
return coverage_probe(payload=payload, db=db)

View file

@ -2230,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel):
)
only_status: str = Field(
default="pending",
description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.",
description=(
"Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор "
"'transient_error' на половине пакета; явное значение = только этот статус."
),
)
house_id: int | None = Field(
default=None,
@ -2267,7 +2270,12 @@ async def scrape_house_imv_backfill(
batch_size: сколько домов обработать за запуск (default 50).
request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV
реагирует на частые запросы с datacenter-IP. Не снижать < 3s.
only_status: по умолчанию 'pending'. Для retry failed 'transient_error'.
only_status: по умолчанию 'pending' и тогда половина пакета сама уходит на
повтор домов в 'transient_error' с непотраченным лимитом попыток (#2674:
раньше повтор существовал только как этот параметр, и за 41 прогон его
не передали ни разу 1390 домов застряли навсегда). Явное значение
отключает автоповтор и обрабатывает РОВНО указанный статус, включая
дома, исчерпавшие лимит (imv_transient_attempts >= 3).
house_id: обработать один дом (debug).
Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию.

View file

@ -0,0 +1,219 @@
"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/
`alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в
stdout и никуда их не доставляет аудит на проде 2026-08-15).
GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`)
умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK``
"General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py
send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py
_send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую
``send_webhook()`` ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload
(text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization,
ни подписи, ни X-*). Значит:
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields",...}]}`` просто у uptime пустые/отсутствующие ``fields``/``color``;
2) единственный канал для аутентификации сам URL (как и Slack-вебхуки).
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
нечем (GlitchTip-сторона его не добавляет).
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета тот же
``secrets.compare_digest`` constant-time compare, тот же env. Отличие от
rbac-паттерна: ТАМ пустой секрет fail-open (есть второй рубеж, roles.yaml).
ЗДЕСЬ секрет единственный рубеж вообще, поэтому пустой секрет ИЛИ
несконфигурированный Telegram-бот 503 "не настроено", а не тихий
fail-open настежь.
Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) так его можно добавить в
``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без
regex/prefix-веток в ``rbac_guard``). Сам путь не секрет, секрет только
значение query-параметра.
Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт
``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` оба сейчас сидят
только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared``
(алиас неявный Docker embedded DNS резолвит по ``container_name``, тот же
приём уже используется Caddy ``tradein-backend:8000``, см. Caddyfile).
Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``,
иначе имя ``tradein-backend`` не резолвится общей сети нет.
"""
from __future__ import annotations
import json
import logging
import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, ValidationError
from app.core.config import settings
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
router = APIRouter()
# Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH
# для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот
# же потолок). Суффикс обрезки учтён в _truncate.
_TELEGRAM_MAX_LEN = 4096
_TRUNCATE_SUFFIX = "\n… (обрезано)"
# Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в
# support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
class GlitchTipAttachment(BaseModel):
"""Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ
подмножества полей (uptime не шлёт ``fields``/``color``) все опциональны,
``extra="allow"`` на случай будущих версий GlitchTip."""
model_config = ConfigDict(extra="allow")
title: str | None = None
title_link: str | None = None
text: str | None = None
color: str | None = None
fields: list[dict[str, Any]] | None = None
class GlitchTipWebhookPayload(BaseModel):
"""Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и
uptime-алертов (см. docstring модуля)."""
model_config = ConfigDict(extra="allow")
text: str | None = None
attachments: list[GlitchTipAttachment] | None = None
def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str:
if len(text) <= limit:
return text
return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX
def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None:
"""Ищет значение поля attachment.fields по title (issue-алерты кладут туда
"Project" литералом см. apps/alerts/webhooks.py send_issue_as_webhook)."""
for field in attachment.fields or []:
if str(field.get("title", "")).strip().lower() == label.lower():
value = field.get("value")
return str(value) if value is not None else None
return None
def _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str:
lines = [f"GlitchTip: {payload.text or 'Alert'}"]
for attachment in payload.attachments or []:
block: list[str] = []
project = _field_value(attachment, "Project")
if project:
block.append(f"Проект: {project}")
if attachment.title:
block.append(attachment.title)
if attachment.text:
block.append(attachment.text)
if attachment.title_link:
block.append(f"Ссылка: {attachment.title_link}")
if block:
lines.append("")
lines.extend(block)
lines.append("")
lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC")
return "\n".join(lines)
def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str:
"""Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`,
ни `attachments`, либо тело не JSON-объект вовсе) не роняем запрос,
пересылаем как есть с пометкой (см. требование задачи: неизвестная форма
payload не должна давать 500)."""
text_repr = raw_body.decode("utf-8", errors="replace")
header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть"
return _truncate(
f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"
)
def _build_message(raw_body: bytes, received_at: datetime) -> str:
try:
data = json.loads(raw_body)
except (json.JSONDecodeError, UnicodeDecodeError):
return _format_unknown_payload(raw_body, received_at)
if not isinstance(data, dict):
return _format_unknown_payload(raw_body, received_at)
try:
payload = GlitchTipWebhookPayload.model_validate(data)
except ValidationError:
return _format_unknown_payload(raw_body, received_at)
if payload.text is None and not payload.attachments:
return _format_unknown_payload(raw_body, received_at)
return _truncate(_format_known_payload(payload, received_at))
def _alerts_configured() -> bool:
"""Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id
темы алертов. Отсутствие любой 503, а не тихий no-op и не fail-open."""
return bool(
settings.tradein_internal_auth_secret
and settings.telegram_bot_token
and settings.telegram_alerts_chat_id
)
def _verify_secret(provided: str) -> None:
expected = settings.tradein_internal_auth_secret
if not secrets.compare_digest(provided or "", expected):
logger.warning("glitchtip webhook: invalid or missing secret query param")
raise HTTPException(status_code=401, detail="invalid or missing secret")
@router.post("/ops/glitchtip-webhook")
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
) -> 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-параметр).
"""
if not _alerts_configured():
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
_verify_secret(secret)
raw_body = await request.body()
received_at = datetime.now(UTC)
text = _build_message(raw_body, received_at)
client = TelegramClient(settings.telegram_bot_token)
try:
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
# может легально висеть воркерные минуты ретраев.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
return {"status": "ok"}

View file

@ -51,16 +51,24 @@ _PHONE_MAX_DIGITS = 15
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
# только в audit-лог (#2497 TODO, теперь закрыт).
_CONSENT_POLICY_VERSION = "2026-07"
#
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
_CONSENT_POLICY_VERSION = "2026-08-13"
# Снимок точного текста согласия, который видит пользователь при отправке лида.
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
# будут нести устаревший snapshot под новой version-меткой.
# Снимок точного текста согласия, который видит пользователь при отправке лида
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
# с этой фразой). Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/
# src/components/trade-in/v2/LeadForm.tsx) — если текст меняется, здесь нужно
# поднять _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые
# строки будут нести устаревший snapshot под новой version-меткой.
_CONSENT_TEXT_SNAPSHOT = (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Федеральным законом «О персональных данных» № 152-ФЗ"
"Политикой обработки персональных данных"
)

View file

@ -9,8 +9,9 @@ import asyncio
import calendar
import json
import logging
import math
from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
@ -25,6 +26,8 @@ from app.schemas.trade_in import (
AnalogLot,
AvitoImvSummary,
CianPriceChangeStats,
CoverageProbeInput,
CoverageProbeResponse,
DkpCorridor,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
@ -2549,3 +2552,280 @@ def get_sales_vs_listings(
data_quality="street_only" if total_deals > 0 else "no_data",
pairs=pairs,
)
# ── Coverage probe (#2894) — бесплатный шаг лэндинга, ЦЕНЫ НЕТ ─────────────────
# До оплаты человек видит, СКОЛЬКО похожих квартир продаётся рядом и КАК БЫСТРО
# они уходят — ни одной рублёвой цифры (см. CoverageProbeResponse docstring).
# Один SQL, ноль внешних вызовов, ноль записей — ручка дешёвая специально: её
# планируется открыть анонимам отдельной задачей (#2895, со своим consent-
# гейтом). RBAC здесь НЕ трогаем — путь остаётся закрытым (не в _PUBLIC_PATHS).
# строго 1000м по ТЗ #2894 (НЕ DEFAULT_RADIUS_M эстиматора — тот допускает fallback до 2000)
COVERAGE_RADIUS_M = 1000
COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади
COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS)
# MAJOR-2 (независимый ревью #2894): days_on_market на проде заполнена практически
# только у yandex (avito/cian/domklik — 0 заполнено) — возраст известен у меньшинства
# когорты, и на тонких когортах "медиана" считалась по 1-2 объявлениям. Ниже порога
# n_with_age медиану не отдаём (null) — не продуктовое решение, а честность при
# заведомо шумной статистике по единичным точкам.
COVERAGE_MIN_AGE_SAMPLES = 5
# 15% свежих yandex-строк имеют days_on_market > 365 (максимум 4261) — это почти
# наверняка мёртвое/забытое объявление, которое никто не снял с публикации, а не
# сигнал о реальном времени экспозиции рынка. Отбрасываем как выброс из медианы.
COVERAGE_MAX_AGE_DAYS = 365
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
#
# ⚠️ Эти списки обязаны совпадать с `OBLAST_CITIES`
# (frontend/src/lib/city-registry.ts) — тем, что человек видит в дропдауне.
# Расхождение поймано на проде 16.08.2026: Серов предлагался к выбору, но
# отсутствовал здесь, и житель Серова получал «этот адрес вне области, по
# которой мы собираем данные» — про город В ТОЙ ЖЕ области, который мы ему сами
# и предложили. Сверка теперь автоматическая, см.
# tests/test_public_mera_api.py::test_offered_cities_match_coverage_cities.
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
# Серов добавлен 16.08.2026: в жёлтый тир, а не в зелёный — в радиусе 15 км от
# центра 363 активных объявления (все свежие), это на порядок меньше городов
# вокруг Екатеринбурга, но заведомо не ноль.
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда", "Серов")
COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12
def _fold_city(name: str) -> str:
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
}
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
# `listings.city` найденной когорты — оказалось, что `listings.city` это город
# СВИП-контекста скрейпера (миграция 196 — колонка заполняется тем городом,
# который скрейпер обходил, не геокодом самого объявления). Замер на проде:
# в радиусе 1000 м вокруг Берёзовского 90/90 строк имеют city='Екатеринбург';
# вокруг Ревды 74/74 — city='Первоуральск'. Следствие: продавец в Берёзовском
# видел на лэндинге «Екатеринбург», а сами COVERAGE_GREEN/YELLOW_CITIES для
# городов-спутников были НЕДОСТИЖИМЫ (в БД нет ни одной строки с их city).
# Фикс — детерминированный резолв по координатам ЗАПРОСА (никакого участия
# клиента, никакой моды когорты): ближайший центроид города из списка ниже,
# если он в пределах COVERAGE_CITY_MATCH_RADIUS_KM.
#
# Координаты — константа РЯДОМ С РУЧКОЙ, не таблица в БД: единственный
# существующий кандидат на "готовый реестр городов" — это
# frontend/src/lib/city-registry.ts (OBLAST_CITIES) и backend
# geocoder.py::SVERDLOVSK_OBLAST_CITIES — оба хранят ТОЛЬКО текстовые лейблы
# (city_hint для геокодера), без координат. Заводить миграцию + таблицу ради
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Екатеринбург": (56.8389, 60.6057),
"Верхняя Пышма": (56.9789, 60.5636),
"Берёзовский": (56.9096, 60.8034),
"Среднеуральск": (56.9848, 60.4759),
"Нижний Тагил": (57.9099, 59.9819),
"Каменск-Уральский": (56.4110, 61.9243),
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772),
}
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
r_earth_km = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * r_earth_km * math.asin(math.sqrt(a))
def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
"""
nearest_city: str | None = None
nearest_km = math.inf
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
distance_km = _haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km = distance_km
nearest_city = city
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
return "", 0, False
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
return display, threshold, True
@router.post("/coverage", response_model=CoverageProbeResponse)
def coverage_probe(
payload: CoverageProbeInput,
db: Annotated[Session, Depends(get_db)],
) -> CoverageProbeResponse:
"""Бесплатная проба покрытия (issue #2894) — сколько похожих квартир рядом.
Когорта тот же дедуп/cap-канон, что radius-тиры в estimator._fetch_analogs
(rn_dup по (source, source_id), rn_addr cap по адресу, реюз тех же
приватных helper'ов эстиматора — импорт локальный, как и в остальных
ручках этого файла, чтобы не тащить тяжёлый app.services.estimator
в module-level import graph): ST_DWithin 1000м, rooms точное совпадение,
area ±15%, scraped_at не старше 14 дней, is_active.
MAJOR-1 fix (независимый ревью #2894): когорта пробы обязана быть
ПОДМНОЖЕСТВОМ когорты платного эстиматора, не шире её иначе проба честно
отвечает "ok" там, где платный расчёт увидит 0. Три предиката ниже тот же
канон, что estimator._COMMON_WHERE (app/services/estimator.py:5441/5460) и
inline-копия Tier W (estimator.py:5910/5916/5932, radius-тир, откуда реально
берутся аналоги на 1000 м): guard новостроек, geo_precision != 'city'
(#769 Part E — city-centroid листинги без реального адреса), price_rub > 0.
В ответе НЕТ ни одной цены см. CoverageProbeResponse docstring.
MAJOR-2 (независимый ревью #2894): days_on_market на проде фактически
заполнена только у ОДНОГО источника (yandex) это ограничение данных, а
не продуктовое решение. n_with_age в ответе честно считает, по скольким
объявлениям взята медиана; ниже COVERAGE_MIN_AGE_SAMPLES null (см. поле
в ответе). Значения > COVERAGE_MAX_AGE_DAYS (почти наверняка мёртвое
объявление) в расчёт медианы не берутся.
#oblast (2026-08): house_placement_history.exposure_days — реальная (не
цензурированная) экспозиция history-строк НЕ используется здесь: это
house-level архив (join по house_id, не привязан к текущей radius/rooms/
area когорте один-в-один), а не активные листинги в подобранном радиусе;
сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша
в честности (у нас и так честное имя поля age активного объявления, не
срок продажи). См. openQuestions PR #2894 при ревью.
Повторная проверка ручки (2026-08): город больше НЕ берётся из моды
`listings.city` найденной когорты и НЕ зависит от `payload.city_hint`
оба источника ненадёжны (см. комментарий над `_CITY_CENTROIDS_DEG`).
Город резолвится детерминированно по `payload.lat/lon` через
`_resolve_coverage_city` `city_hint` в payload остаётся только
информационным полем (см. `CoverageProbeInput.city_hint`), на результат
не влияет.
"""
from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS
area_min = payload.area_m2 * (1 - COVERAGE_AREA_TOLERANCE)
area_max = payload.area_m2 * (1 + COVERAGE_AREA_TOLERANCE)
row = (
db.execute(
text(
f"""
WITH base AS (
SELECT
days_on_market,
row_number() OVER (
PARTITION BY address ORDER BY scraped_at DESC
) AS rn_addr,
{_RN_DUP_WINDOW}
FROM listings
WHERE is_active = true
AND rooms = :rooms
AND area_m2 BETWEEN :area_min AND :area_max
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND ST_DWithin(
geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
)
-- MAJOR-1: sync с estimator._COMMON_WHERE (5441) / Tier W (5916)
AND price_rub > 0
-- MAJOR-1: sync с estimator._COMMON_WHERE (5460) / Tier W (5932)
-- guard новостроек, NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
-- MAJOR-1: sync с estimator Tier W (5910/5945-5948, #769 Part E) —
-- исключает city-centroid листинги без реального адреса;
-- IS DISTINCT FROM пропускает NULL (неизвестная точность)
AND (geo_precision IS DISTINCT FROM 'city')
)
SELECT
count(*) AS n_listings,
count(*) FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS n_with_age,
percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market)
FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS median_age_days
FROM base
WHERE rn_addr <= :max_per_addr
AND rn_dup = 1
"""
),
{
"rooms": payload.rooms,
"area_min": area_min,
"area_max": area_max,
"fresh_days": COVERAGE_FRESH_DAYS,
"lat": payload.lat,
"lon": payload.lon,
"radius": COVERAGE_RADIUS_M,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"max_age_days": COVERAGE_MAX_AGE_DAYS,
},
)
.mappings()
.fetchone()
)
n_listings = int(row["n_listings"]) if row else 0
n_with_age = int(row["n_with_age"]) if row and row["n_with_age"] is not None else 0
median_age = (
round(row["median_age_days"])
if row is not None
and row["median_age_days"] is not None
and n_with_age >= COVERAGE_MIN_AGE_SAMPLES
else None
)
city, threshold, supported = _resolve_coverage_city(payload.lat, payload.lon)
if not supported or n_listings == 0:
status: Literal["ok", "thin", "not_covered"] = "not_covered"
# Nit-fix (повторная проверка #2894): threshold неприменим при
# not_covered — см. CoverageProbeResponse.threshold docstring. Раньше
# поддерживаемый (по координатам) город с пустой когортой отдавал
# реальный порог (8/12) вместе с not_covered — противоречило докстрингу.
threshold = 0
elif n_listings >= threshold:
status = "ok"
else:
status = "thin"
logger.info(
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d n_with_age=%d",
payload.rooms,
payload.area_m2,
city,
status,
n_listings,
n_with_age,
)
return CoverageProbeResponse(
status=status,
n_listings=n_listings,
median_listing_age_days=median_age,
n_with_age=n_with_age,
radius_m=COVERAGE_RADIUS_M,
city=city,
threshold=threshold,
)

View file

@ -599,9 +599,13 @@ class Settings(BaseSettings):
cian_valuation_max_rub: float = 500_000_000
# ── #audit-5: data-age guards ─────────────────────────────────────────────
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
sber_index_max_age_days: int = 35
# #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению
# (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток),
# guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно
# место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог
# берёт из такта самой загрузки (scrape_schedules.default_params.interval_days),
# так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться.
# extra="ignore" в model_config защищает от startup-краха на leftover env var.
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
avito_imv_thin_market_threshold: int = 10
@ -629,6 +633,23 @@ class Settings(BaseSettings):
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
estimate_quarter_index_factor_min: float = 0.6
estimate_quarter_index_factor_max: float = 1.8
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
#
# Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
# известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
# 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
# цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
# координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
# денежный — поэтому включение отдельным решением, после замера.
#
# Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
# заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
# сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
# 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
# а не оставлять «на вырост».
estimate_quarter_from_coords_enabled: bool = False
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
@ -976,6 +997,15 @@ class Settings(BaseSettings):
# message_thread_id топика внутри support-группы, в который идут зеркала.
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
# ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ──
# Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода
# НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py.
# Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open —
# это единственный auth-рубеж эндпоинта, в отличие от rbac-путей).
# ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID.
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.

View file

@ -0,0 +1,97 @@
"""Пометка «этот запрос пришёл из публичной формы» и её единственное следствие:
адрес, который ввёл аноним, не попадает в журналы.
ЗАЧЕМ ЭТО СУЩЕСТВУЕТ
--------------------
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что введённый
адрес нигде не сохраняется. По базам данных это правда (обе публичные ручки
только читают), а по журналам не было: геокодер логирует запрос открытым
текстом на каждый вызов, например
INFO app.services.dadata: dadata suggest: 'онуфриева 24' 5 вариантов
Прод пишет stdout контейнеров в journald с persistent-хранилищем
(`tradein-mvp/docker-compose.prod.yml`), то есть строка ложится на диск и живёт
там неделями. Рядом, в access-логе Caddy, лежит IP того же запроса с той же
меткой времени то есть адрес квартиры фактически сохранён и сопоставим с
человеком. Ровно то, что публичная страница обещает не делать.
Найдено состязательным ревью PR публичного периметра (16.08.2026) и
воспроизведено на проде, а не выведено из чтения кода.
ПОЧЕМУ ФИЛЬТР, А НЕ ПРАВКА КАЖДОГО ВЫЗОВА logger
------------------------------------------------
Мест, где адрес попадает в лог, много (`app/services/dadata.py`,
`app/services/geocoder.py` успех, пустая выдача, сетевая ошибка, таймаут,
кадастровый фолбэк), и любое новое добавится незаметно. Обещание не должно
зависеть от того, вспомнил ли автор следующей правки про эту страницу.
Фильтр единственная точка, которая закрывает и уже написанное, и будущее.
ПОЧЕМУ contextvar
-----------------
Публичный и закрытый контуры обслуживает ОДИН процесс, и один и тот же
`suggest()` вызывают оба. Различить их можно только по текущему запросу.
`ContextVar` то, что переживает `await` и копируется в `asyncio.to_thread`
(им геокодер уходит в синхронный кадастровый тир), в отличие от глобального
флага, который в конкурентной обработке принадлежал бы соседнему запросу.
Для B2B-трафика ничего не меняется: там флаг не выставлен, логи прежние они
нужны, чтобы разбирать жалобы пилотов на подсказки.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
#: Истинно, пока обрабатывается запрос анонимной публичной формы.
is_public_request: ContextVar[bool] = ContextVar("mera_is_public_request", default=False)
#: Что видно в журнале вместо сообщения. Уровень и логгер сохраняются — по ним
#: по-прежнему видно, что вызов был и чем закончился.
REDACTED_MESSAGE = "<публичный запрос МЕРЫ: содержимое скрыто>"
#: Логгеры, чьи записи могут содержать введённый адрес.
ADDRESS_LOGGERS = ("app.services.dadata", "app.services.geocoder")
@contextmanager
def public_request_scope() -> Iterator[None]:
"""Помечает текущий запрос публичным на время работы блока."""
token = is_public_request.set(True)
try:
yield
finally:
is_public_request.reset(token)
class RedactPublicAddressFilter(logging.Filter):
"""Заменяет сообщение целиком, пока обрабатывается публичный запрос.
Целиком, а не по ключам: в шаблонах сообщений адрес стоит рядом с
безобидными аргументами (`"%r%d вариантов"`), и отличить их друг от
друга внутри фильтра нельзя. Терять текст сообщения на публичном пути
дешевле, чем хранить адреса; на закрытом контуре текст остаётся полным.
"""
def filter(self, record: logging.LogRecord) -> bool:
if is_public_request.get():
record.msg = REDACTED_MESSAGE
record.args = ()
return True
def install_address_log_redaction() -> None:
"""Вешает фильтр на логгеры, видящие адрес. Идемпотентно.
Фильтр ставится на КОНКРЕТНЫЕ логгеры, а не на корневой хендлер: фильтры
логгера применяются к записям этого логгера, а не ко всему, что через
хендлер проходит, то есть посторонние сообщения (пул соединений, старт
приложения) во время публичного запроса не пострадают.
"""
for name in ADDRESS_LOGGERS:
logger = logging.getLogger(name)
if not any(isinstance(f, RedactPublicAddressFilter) for f in logger.filters):
logger.addFilter(RedactPublicAddressFilter())

View file

@ -32,6 +32,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
# Платёжная нотификация Т-Банка (PR-D2, готовит почву под PR-D3 — путь ещё
# закрыт rbac до того момента). Сервер-к-серверу, без сессии/X-Authenticated-User
# → в общем лимитере попал бы в один и тот же per-IP ключ с любым другим
# анонимным трафиком с той же исходящей сети банка. Мотив НЕ «банк упрётся в
# лимит» — 300/60с и так щедро — а «429 никогда не должен стать причиной, по
# которой денежное состояние разъехалось»: для банка недоставленная нотификация
# = «доставка не удалась», альтернативного канала нет, а очередь ретраев
# растягивается на сутки. Только точный путь notify — НЕ checkout (тот
# инициирует пользователь с сессией/курсором в браузере, абуз там штатно
# лимитируем как любой другой API-путь).
_PAYMENTS_NOTIFY_PATH = "/api/v1/trade-in/payments/notify"
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Sliding-window rate limit на /api/v1/*. Health и статика — без лимита."""
@ -42,6 +54,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
path = request.url.path
# Платёжная нотификация — мимо ОБЩЕГО (per-user/per-IP shared) лимитера,
# но НЕ без лимита вовсе: idiom `_notify_limiter` (`SlidingWindowLimiter`,
# тот же приём, что `support.py:92`/`:319` — узкий per-feature бюджет
# ВМЕСТО общего, не полное отключение защиты). Порог заведомо выше любого
# штатного трафика банка (документированное расписание ретраев неизвестно,
# см. mera-tbank-acquiring-recon.md — берём с кратным запасом), но конечен:
# полное отключение оставило бы путь без backstop против шторма запросов —
# подпись отсекает мусор ПОСЛЕ разбора тела (PR-D3), не до.
if path == _PAYMENTS_NOTIFY_PATH:
retry_after = _notify_limiter.check(_client_ip(request))
if retry_after is not None:
return JSONResponse(
status_code=429,
content={"detail": "Слишком много запросов. Попробуйте позже."},
headers={"Retry-After": str(int(retry_after) + 1)},
)
return await call_next(request)
# Лимитируем только API; health и прочее — пропускаем.
if not path.startswith("/api/"):
return await call_next(request)
@ -143,6 +172,16 @@ class SlidingWindowLimiter:
return None
# Щедрый бюджет для платёжной нотификации (PR-D2): 3000/60с (50 req/s) — на два
# порядка выше любого правдоподобного трафика банка (тест 400/60с проходит с
# запасом в 7.5×), но конечен — backstop против шторма запросов на путь, где
# подпись проверяется уже ПОСЛЕ разбора тела. Ключ — client IP (у сервер-к-
# серверу вызова нет сессии/X-Authenticated-User).
_NOTIFY_RATE_LIMIT = 3000
_NOTIFY_RATE_WINDOW_S = 60.0
_notify_limiter = SlidingWindowLimiter(limit=_NOTIFY_RATE_LIMIT, window_s=_NOTIFY_RATE_WINDOW_S)
def _client_ip(request: Request) -> str:
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.

View file

@ -86,6 +86,26 @@ _PUBLIC_PATHS = frozenset(
# не секрет, читает только process env — быстрая справка для клиента/
# поддержки/смоук-теста, не должна требовать сессию.
"/api/v1/trade-in/version",
# GlitchTip webhook → Telegram (app/api/v1/glitchtip.py): вызывается
# ИЗ glitchtip-worker (docker-сеть gendesign_shared), не может нести
# X-Authenticated-User/сессию. Путь фиксированный и не секрет — секрет
# это query-параметр `secret`, который проверяет сам хендлер
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
"/api/v1/trade-in/ops/glitchtip-webhook",
# Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга
# идентичности нет и не будет — Caddy на этом домене вообще без
# basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не
# пишут в БД адрес физлица, поэтому открываются ДО контура согласия
# 152-ФЗ (#2895), а платный расчёт — только вместе с ним. Разбор
# периметра и бюджетов — app/api/public/mera.py.
#
# Строки ТОЧНЫЕ, префиксной ветки здесь нет: множество проверяется как
# `path in _PUBLIC_PATHS`, и превращать его в префиксный матчер ради
# двух путей значит расширить механизм, от которого зависит auth-гейт
# всего бэкенда. Гарантия «под /api/public/ не лежит ничего закрытого»
# держится на структуре пакета app/api/public/, а не на матчере.
"/api/public/mera/suggest",
"/api/public/mera/coverage",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -40,7 +40,29 @@ logger = logging.getLogger(__name__)
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
# циклическую зависимость.
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
#
# PR-D2: `/api/v1/trade-in/payments/notify` — заранее в skip-набор (defense-in-
# depth), хотя rbac ещё закрывает этот путь до PR-D3. Причины две:
# 1) сам путь не должен попадать в аудит вообще — тело нотификации содержит
# `Token`/`Pan`/`ExpDate` (см. `app/main.py._before_send`, тот же мотив, что
# и вырезание тела из мониторинга); хоть это middleware само по себе тело
# запроса в payload не пишет (только status_code/path/method), путь не
# должен зависеть от того, что кто-то потом добавит поле "body" в событие;
# 2) НЕ авторизующая проверка: `RequestAuditMiddleware` внешний относительно
# `rbac_guard` и читает сырой `X-Authenticated-User` (см. `main.py` порядок
# middleware) — анонимный POST на notify с подделанным заголовком
# `X-Authenticated-User: admin` иначе писал бы фальшивые события в
# `user_events` с атрибуцией admin, при этом rbac при этом ничего не знает
# (сам гейт отдельно, 401 всё равно вернёт до PR-D3).
_PUBLIC_PATHS = frozenset(
{
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/trade-in/payments/notify",
}
)
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил

View file

@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import sentry_sdk
from fastapi import FastAPI
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
@ -20,6 +20,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.public import mera as public_mera
from app.api.v1 import (
admin,
audit,
@ -27,6 +28,7 @@ from app.api.v1 import (
brand,
buildings,
geocode,
glitchtip,
lead,
me,
privacy_admin,
@ -66,17 +68,48 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import redact_telegram_bot_token
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_public_address,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
sendMessage, а include_local_variables=False ниже первый рубеж защиты)."""
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты.
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные
режет `request.data` целиком только для `/payments/*`, остальные пути
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
(before_send и before_send_transaction) вчерашний баг в Птице закрыл
только error-канал, transaction-канал остался вообще без обработчика.
RetryError-стабилизация этот процесс обслуживает /api/v1/geocode/*
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
# брэдкрамб исходящего вызова геокодера несёт его же в query. Публичная
# страница обещает, что адрес нигде не сохраняется; GlitchTip — внешний
# сервис, значит обещание распространяется и на него.
scrubbed = scrub_public_address(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
if detokened is None:
return None
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -88,6 +121,10 @@ if settings.glitchtip_dsn:
# держит base URL с токеном в локальных переменных стек-фрейма — default
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
before_send=_before_send,
# PR-D2: тот же обработчик на transaction-канал — traces_sample_rate=0.0
# сегодня не шлёт трейсы вообще, но это belt-and-suspenders на случай,
# если трейсинг когда-нибудь включат (см. docstring _before_send выше).
before_send_transaction=_before_send,
integrations=[
StarletteIntegration(),
FastApiIntegration(),
@ -210,6 +247,26 @@ def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment}
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только
# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой
# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам —
# только pg_isready для postgres) — маршрут пока используется лишь тестами.
# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder
# (backend/app/main.py, за Caddyfile `handle /health`), не здесь.
# media_type="application/json" — Content-Type совпадает с GET; Content-Length
# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload)
# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для
# HEAD, требует совпадения только заголовков представления (Content-Type).
# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это
# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим
# симметрично, чтобы схема двух бэкендов не разъезжалась.
@app.head("/health", include_in_schema=False)
def health_head() -> Response:
return Response(status_code=200, media_type="application/json")
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
@ -220,7 +277,14 @@ app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
# Публичный B2C-периметр МЕРЫ (meraocenka.ru). Префикс НЕ /api/v1/* намеренно —
# на публичном домене он проксируется целиком, поэтому под ним по определению
# не должно лежать ничего закрытого; разбор — в app/api/public/mera.py.
# Обе ручки перечислены в rbac._PUBLIC_PATHS точными строками.
app.include_router(public_mera.router, prefix="/api/public/mera", tags=["public-mera"])

View file

@ -25,10 +25,40 @@ import re
from typing import Any
from sentry_sdk.types import Event
from tenacity import RetryError
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
# PR-D2 (payments perimeter hardening): + платёжные поля Т-Банка (customer_email/
# customer_phone из checkout, pan/expdate/cardid/rebillid/token/terminalkey из
# notify) — belt-and-suspenders поверх `scrub_payment_request_body` ниже, которая
# вырезает `request.data` для /payments/* целиком: этот словарь всё равно нужен
# для extra/contexts И на случай, если платёжное поле когда-нибудь попадёт в
# error event НЕ через request.data (напр. кто-то положит его в extra вручную).
_PII_KEYS = frozenset(
{
"client_name",
"client_phone",
"client_email",
"phone",
"email",
"name",
"customer_email",
"customer_phone",
"pan",
"expdate",
"cardid",
"rebillid",
"token",
"terminalkey",
}
)
# Сегмент пути платёжного периметра (notify + checkout + любой будущий
# /payments/* суб-путь) — PR-D2, готовит почву под PR-D3 (эндпоинты ещё не
# существуют). Матчим по сегменту, не по конкретному эндпоинту, чтобы не
# требовать правки этого файла на каждый новый платёжный путь.
_PAYMENTS_URL_SEGMENT = "/api/v1/trade-in/payments/"
# Telegram Bot API токен в пути URL: /bot<numeric_id>:<secret-part>/<method>.
# Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query
@ -76,6 +106,31 @@ _URL_SECRET_QUERY_RE = re.compile(
)
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
# httpx error-message URL query stabilization (GlitchTip-noise review round 2,
# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`)
# bakes the FULL request URL — INCLUDING query string — into the exception message:
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response
# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После
# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError
# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст
# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть
# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за
# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто
# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по
# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue,
# невозможные при группировке чисто по stacktrace/culprit).
#
# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных
# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле
# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет).
# Режем query целиком (не только конкретные параметры) — host+path остаются
# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт
# regex (не трогает произвольные строки с `?`, см. тест
# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched).
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -90,32 +145,34 @@ def _scrub(obj: Any) -> None:
_scrub(item)
def _redact_url_secrets_inplace(obj: Any) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
КАЖДОЙ строке event не ключ-based: секрет утекает через httpx span
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
request URL), а не только через известные PII-поля формы. Мутирует dict/list
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`)
сохраняет identity верхнеуровневого `event`, на что опирается контракт
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)`
по КАЖДОЙ строке event (не ключ-based) общий обход, переиспользуемый и для
URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message
URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают
через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx
содержит полный request URL), а не только через известные PII-поля формы.
Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от
`_redact_strings`) сохраняет identity верхнеуровневого `event`, на что
опирается контракт `scrub_pii_event`/`before_send` и существующие тесты
(`out is event`).
"""
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[key] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
elif isinstance(obj, list):
for i, value in enumerate(obj):
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[i] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
@ -123,16 +180,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None:
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
"""Redact consumer-PII + URL query-string секретов/nondeterministic-данных из
error event перед отправкой.
Композиция (обе in-place, сохраняют identity `event`): (1) ключ-based
Композиция (все in-place, сохраняют identity `event`): (1) ключ-based
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
changeip #security-audit). Второй шаг не завязан на конкретные ключи полей —
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
event (не None).
changeip #security-audit), (3) full-text regex-проход, стабилизирующий httpx
error-message URL (`for url '...?...'`) убирает переменный query string
(адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на
каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у
`_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей
ловят секрет/переменные данные в frame locals, breadcrumb, exception message
и т.д., где они могут оказаться независимо от
include_local_variables/traces_sample_rate. Возвращает event (не None).
"""
if not isinstance(event, dict):
return event
@ -141,7 +203,96 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(request.get("data"))
_scrub(event.get("extra"))
_scrub(event.get("contexts"))
_redact_url_secrets_inplace(event)
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
return event
def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Вырезать `event['request']['data']` целиком для платёжных путей (PR-D2).
Ключ-based `scrub_pii_event` НЕ спасает платёжную нотификацию: sentry_sdk
2.64 (`integrations/starlette.py`) кладёт ПОЛНОЕ тело запроса в
`event.request.data`, и `send_default_pii=False` этот путь не гейтит тот
флаг управляет только куками, не телом запроса (проверено живьём на соседнем
продукте). Тело нотификации Т-Банка несёт `Token`/`Pan`/`ExpDate`/`CardId`/
`RebillId`/`DATA` банк сам выбирает имена полей, перечислить их все заранее
нельзя, поэтому единственная безопасная стратегия для этого пути не
отправлять тело целиком, а не пытаться вычистить отдельные ключи.
Матчим по сегменту `/api/v1/trade-in/payments/` (не по конкретному
эндпоинту) покрывает notify, checkout и любой будущий суб-путь одним
фильтром, без правки этого файла на каждое расширение платёжного API.
Сравнение регистронезависимое: `_PUBLIC_PATHS` (rbac) точное множество без
учёта регистра только у Caddy, не у Python, так что нестандартный регистр
пути технически может долететь до обработчика и породить событие.
Композировать с `scrub_pii_event`/`redact_telegram_bot_token`, а не вместо
них этот шаг закрывает только `request.data`, extra/contexts и
traceback-locals остаются на ответственности остальных шагов композиции.
"""
if not isinstance(event, dict):
return event
request = event.get("request")
if isinstance(request, dict):
url = request.get("url")
if isinstance(url, str) and _PAYMENTS_URL_SEGMENT in url.lower():
request.pop("data", None)
return event
_PUBLIC_API_URL_SEGMENT = "/api/public/"
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
_GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru")
_ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$")
def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Убрать введённый анонимом адрес из события GlitchTip.
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес
нигде не сохраняется. GlitchTip внешний сервис, и до этой правки адрес
доезжал туда двумя путями (оба воспроизведены состязательным ревью
16.08.2026, не выведены из чтения кода):
1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса,
а `send_default_pii=False` этот путь не гейтит он про куки и IP, не
про тело. Тело публичной ручки это ровно `{"q": "<адрес>"}`.
Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет
имена вроде `client_phone`, а поле здесь называется `q`.
2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration`
кладёт URL целиком, а адрес там в query (`?q=Малышева+30`).
Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а
убирать целиком состав полей задаёт не только наш код (у геокодеров свои
параметры), поэтому перечислить безопасное заранее нельзя.
Композировать с остальными шагами, а не вместо них.
"""
if not isinstance(event, dict):
return event
request = event.get("request")
if isinstance(request, dict):
url = request.get("url")
if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower():
request.pop("data", None)
crumbs = event.get("breadcrumbs")
values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs
if isinstance(values, list):
for crumb in values:
if not isinstance(crumb, dict):
continue
data = crumb.get("data")
if not isinstance(data, dict):
continue
url = data.get("url")
if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS):
data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url)
return event
@ -174,3 +325,61 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
if not isinstance(event, dict):
return event
return _redact_strings(event) # type: ignore[return-value]
# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ────────
# tenacity.RetryError.__str__() тащит repr() последнего Future
# (`RetryError[<Future at 0x7f... state=finished raised HTTPStatusError>]`) —
# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py
# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim
# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим
# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ
# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True`
# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает
# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само
# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность
# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в
# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1).
#
# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через
# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get`
# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на
# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query
# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE`
# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН
# persistent issue per (culprit, класс исключения-причины) — culprit обязателен:
# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр.
# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError)
# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2
# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration`
# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр.
# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ
# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от
# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных
# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде
# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес,
# IP, id объявления и т.п.), иначе проблема повторится в других терминах.
def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue per
(источник, тип причины) РАЗНЫЕ источники (geocoder / scraper_kit / будущий
retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает.
Определяет тип exception через `hint["exc_info"]` (реальный объект
исключения, тот же контракт что sentry_sdk передаёт в before_send) не
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
`isinstance` (не сравнение `type(...).__name__` со строкой) иначе любой
посторонний класс с совпадающим именем ложно матчился бы, а подкласс
`tenacity.RetryError` промахивался бы. Не-RetryError события возвращает без
изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if not isinstance(exc_value, RetryError):
return event
cause = exc_value.__cause__ or exc_value.__context__
cause_type = type(cause).__name__ if cause is not None else "Unknown"
culprit = event.get("logger") or event.get("transaction") or "unknown"
event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type]
return event

View file

@ -44,7 +44,35 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.observability.sentry_scrub import scrub_pii_event
from app.observability.sentry_scrub import (
scrub_payment_request_body,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда belt-and-
suspenders на случай, если платёжные данные когда-нибудь попадут в
`request`/`extra`. Тот же обработчик на оба канала ниже см.
app/main.py._before_send (идентичный мотив, не дублировать без причины).
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
следом за платёжным body-wipe: этот процесс гоняет
`geocode_missing_listings` (ночной batch, сотни адресов за прогон)
@retry-декорированные Nominatim-хелперы (app/services/geocoder.py) на
исчерпанных ретраях исторически плодили по отдельному GlitchTip issue
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
См. sentry_scrub docstring.
"""
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint)
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -52,7 +80,8 @@ if settings.glitchtip_dsn:
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_pii_event,
before_send=_before_send,
before_send_transaction=_before_send,
integrations=[
SqlalchemyIntegration(),
HttpxIntegration(),

View file

@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: int # медиана ₽/м²
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
period_months: int # окно поиска сделок
period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date)
# #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/
# median/high — включая city-wide widen, если сработал), НЕ по всей таблице.
# period_months отвечает на «где искали», а не «насколько свежи сделки»: прод
# 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок
# даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы
# враньём в пользу свежести именно для них.
# Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день
# квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974
# сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно
# {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи
# на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад».
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
class PriceTrendPoint(BaseModel):
@ -210,6 +223,13 @@ class AggregatedEstimate(BaseModel):
# UI (снизить доверие / переспросить город), НЕ персистится в БД
# (ephemeral, только для текущего POST /estimate ответа).
target_city_ambiguous: bool = False
# #2626: True если координаты дал ПОСЛЕДНИЙ тир geocode() — fallback на `houses`
# (см. `app.services.geocoder._local_houses_match`), а не Nominatim/geoportal/
# cadastral. Значит адрес пользователя не совпал буквально (разговорное/усечённое
# имя улицы или отсутствующий корпус), но был однозначно сопоставлен с домом из
# скрейпленных листингов. Честный сигнал для UI («адрес уточнён автоматически»),
# НЕ персистится в БД (ephemeral, как и `target_city_ambiguous`).
target_address_refined: bool = False
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов
@ -736,3 +756,77 @@ class LocationIndexResponse(BaseModel):
radius_m: int
nearby_poi: list[NearbyPoiOut]
poi_status: str
class CoverageProbeInput(BaseModel):
"""Вход POST /api/v1/trade-in/coverage (issue #2894) — бесплатная проба покрытия.
lat/lon координаты, уже разрезолвленные фронтом (тот же контракт, что
TradeInEstimateInput.lat/lon geocode делает фронт/автокомплит, эта ручка
сама НИКОГО не геокодирует). Город (и, соответственно, порог ok/thin) для
ответа резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon см.
`app.api.v1.trade_in._resolve_coverage_city`.
city_hint ИНФОРМАЦИОННОЕ поле, на результат НЕ влияет (повторная проверка
#2894, 2026-08). Раньше оно участвовало в резолве города как фолбэк —
убрано вместе с модой `listings.city`: оба источника ненадёжны (`city_hint`
непроверенный клиентский вход, `listings.city` город свип-контекста
скрейпера, не адреса объявления, см. комментарий в trade_in.py). Поле
оставлено в схеме, потому что фронт его уже шлёт в других ручках того же
автокомплита (см. TradeInEstimateInput.city_hint) принимаем и молча
игнорируем, чтобы не ронять запрос лишней 422.
"""
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
rooms: int = Field(ge=0, le=10) # 0 = студия
area_m2: float = Field(gt=10, lt=500)
city_hint: str | None = Field(default=None, max_length=100)
class CoverageProbeResponse(BaseModel):
"""Ответ POST /api/v1/trade-in/coverage.
НАМЕРЕННО без единой цены (ни медианы, ни диапазона, ни /м²) продуктовое
правило issue #2894: бесплатный шаг доказывает, что похожие квартиры есть
и как быстро они уходят, а саму цену продукт продаёт на платном шаге.
status:
- "ok" n_listings >= порога для этого города (зелёный/жёлтый список).
- "thin" когорта непустая, но n_listings < порога.
- "not_covered" город вне зелёного/жёлтого списка ИЛИ когорта пустая
(n_listings == 0) независимо от того, поддерживается город или нет.
median_listing_age_days ЧЕСТНОЕ имя: возраст АКТИВНОГО объявления
(days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная
выборка (активные объявления ещё висят) всегда завышена относительно
реального времени экспозиции проданных не путать со «сроком продажи».
ОГРАНИЧЕНИЕ ДАННЫХ (не продуктовое решение, см. coverage_probe docstring):
days_on_market на проде заполнена практически только у источника yandex
возраст известен у меньшинства строк когорты. n_with_age ниже честный
счётчик, по скольким объявлениям посчитана медиана; при n_with_age < порога
(COVERAGE_MIN_AGE_SAMPLES) median_listing_age_days принудительно null.
n_with_age сколько объявлений когорты реально имеют известный
(non-null, не-выброс) days_on_market и вошли в расчёт медианы. Фронт
обязан иметь возможность не показывать median_listing_age_days при
маленьком n_with_age цифра "медиана" по 1-2 объявлениям не медиана.
threshold n, начиная с которого статус переходит в "ok" для резолвленного
города; 0 всегда, когда status == "not_covered" (порог неприменим ни для
города вне зелёного/жёлтого списка, ни для поддерживаемого города с пустой
когортой), НЕ только для неподдерживаемого города.
city резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon запроса (ближайший центроид из
зелёного/жёлтого списка в пределах `COVERAGE_CITY_MATCH_RADIUS_KM`), не из
`city_hint` и не из моды `listings.city` найденной когорты см.
`app.api.v1.trade_in._resolve_coverage_city`.
"""
status: Literal["ok", "thin", "not_covered"]
n_listings: int
median_listing_age_days: int | None
n_with_age: int
radius_m: int
city: str
threshold: int

View file

@ -37,6 +37,12 @@ DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/sugge
_DADATA_TIMEOUT_S = 8.0
_DADATA_SUGGEST_TIMEOUT_S = 5.0
# Троттлинг WARNING «услуга CLEAN выключена на аккаунте» (#dadata-403-noise) —
# статичная конфигурация аккаунта, не транзиентный сбой. Первый раз за процесс
# логируется на WARNING, дальше — DEBUG, чтобы не заливать логи одним и тем же
# сообщением на каждый /estimate (было: logger.error на каждый запрос).
_clean_disabled_warned = False
@dataclass(frozen=True, slots=True)
class DadataAddressResult:
@ -172,15 +178,29 @@ async def clean_address(address: str) -> DadataAddressResult | None:
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
#
# Это НЕ сбой (аккаунт постоянно живёт с выключенной услугой, enrich_address уже
# graceful-деградирует на suggest — см. ниже) — раньше это било logger.error на
# КАЖДЫЙ пользовательский запрос (164 события/запрос-волна в проде), из-за чего
# ERROR переставал значить «настоящий сбой». WARNING один раз за процесс (дальше —
# DEBUG) сохраняет видимость причины без шума на каждый /estimate.
if status == 403 and (
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
):
logger.error(
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
body_preview,
)
global _clean_disabled_warned
if not _clean_disabled_warned:
logger.warning(
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
"полагайся на suggest-fallback (enrich_address). Ответ: %r "
"(повторы этого сообщения в рамках процесса логируются на DEBUG)",
body_preview,
)
_clean_disabled_warned = True
else:
logger.debug(
"dadata: HTTP 403 CLEAN disabled (уже предупреждено WARNING в этом процессе)"
)
else:
logger.error(
"dadata: HTTP %d — auth/secret rejected. "

View file

@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import (
estimate_via_cian_valuation,
)
from scraper_kit.providers.yandex.valuation import (
ValuationHouseMeta,
YandexValuationResult,
YandexValuationScraper,
)
@ -488,6 +487,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0)
# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
"""#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
(`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
и смешивать их напрямую значит класть два разных базиса на одну шкалу: у клиента
с «требует ремонта» медиана 6%, якорь нет, порог `median×1.15` фактически падает до
×1.081, а сработавший blend возвращает половину этих 6% обратно вверх.
Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` рыночная
эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
применил к медиане, чинится расхождение базисов, а не калибровка.
Неизвестный/незнакомый ремонт с любой стороны 1.0 (no-op, прежнее поведение).
"""
base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
if base <= 0:
return 1.0
return _repair_coefficient(target_repair) / base
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.720.93). Estimator
@ -961,13 +986,13 @@ def _save_yandex_history_items(
Batch semantics: single try/except; on any failure the batch rolls back.
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
без единого объявления в истории, до записи панорамы не доходила на проде это
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
переноса: match_or_create_house теперь вызывается и для таких страниц (может
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
остальных 90% новых сущностей класс не появляется, появляется недостающая доля.
#2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
в истории, дома не заводила на проде это 1519 оценок против 1360 домов с
историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
«панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
"""
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
# — stable across re-runs, distinguishes pages for different addresses.
@ -1004,9 +1029,6 @@ def _save_yandex_history_items(
result.address,
)
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
_save_yandex_house_panorama(db, house_id, result.house)
if not result.history_items:
return 0
@ -1091,58 +1113,13 @@ def _save_yandex_history_items(
return 0
# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в
# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но
# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь —
# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету.
#
# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text
# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн,
# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
def _save_yandex_house_panorama(
db: Session,
house_id: int | None,
meta: ValuationHouseMeta,
) -> None:
"""Пишет houses.has_panorama по разобранной мете yandex_valuation.
No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
выше). Best-effort: ошибка логируется и глотается оценка не должна падать из-за
справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
"""
if house_id is None:
return
if meta.year_built is None and meta.total_floors is None:
logger.debug(
"yandex_valuation: has_panorama не пишем для house_id=%s"
"страница не подтверждена (нет ни года, ни этажности)",
house_id,
)
return
try:
with db.begin_nested():
db.execute(
text(
"""
UPDATE houses
SET has_panorama = CAST(:panorama AS boolean)
WHERE id = CAST(:hid AS bigint)
AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
"""
),
{"hid": house_id, "panorama": meta.has_panorama},
)
db.commit()
except Exception as e:
logger.warning(
"yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
house_id,
e,
)
db.rollback()
# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
# 24.0512.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
@ -1158,14 +1135,39 @@ def _fetch_house_imv_anchor(
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
В отличие от `avito_imv_evaluations` (keyed estimate_id пустая, on-demand
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
house_id / пустой таблице estimator продолжает на гео-tier'ах (no regress).
скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
None при любой ошибке / отсутствии house_id / пустой таблице estimator
продолжает на гео-tier'ах (no regress).
СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
~2951 домов, fresh» неправда по обоим пунктам, #2674):
2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id)
поэтому ORDER BY ниже выбирает из 1 кандидата, а не ранжирует). 2633 из
них (98.2%) старше 40 суток, 2366 один прогон 2026-05-24; свежих, после
фикса параметров 2026-08-06, всего 47.
Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
(46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
«панель» вместо «монолита»).
ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
· Гейт «не брать дореформенные строки» ОТКЛОНЁН: band-совместимая строка
находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
224 из 224, то есть просто выключил бы механизм.
· Массовая переоценка не чинит корень и упирается в очередь: 1628 из
2633 домов уже в imv_status='ok', а батч берёт только pending/
transient_error, где сейчас 7144 дома при темпе ~25/прогон.
· Корень не свежесть, а разные базисы ремонта; починено в #2677
(`_anchor_repair_factor`), см. ниже.
Returns dict {recommended_price, lower_price, higher_price, market_count,
rooms, area_m2} или None.
rooms, area_m2, renovation_type} или None.
#2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
(см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
"""
if target_house_id is None:
return None
@ -1175,7 +1177,7 @@ def _fetch_house_imv_anchor(
text(
"""
SELECT recommended_price, lower_price, higher_price,
market_count, rooms, area_m2
market_count, rooms, area_m2, renovation_type
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
@ -1357,6 +1359,80 @@ def _lookup_quarter_indexes(
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
# порог ответ выдан чужой квартал точность покрытие
# 15 м 475 26 94.5% 19.8%
# 25 м 748 59 92.1% 31.2%
# 50 м 1341 271 79.8% 55.9%
# 100 м 2090 644 69.2% 87.2%
# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
# точность 92%, и это ровно тот компромисс, который видно в замере, а не
# заимствованная у соседнего модуля константа.
_TARGET_QUARTER_RADIUS_M = 25.0
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
_TARGET_QUARTER_DEG_GATE = 0.0015
def _lookup_target_quarter_by_coords(
db: Session,
lat: float | None,
lon: float | None,
) -> str | None:
"""Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м).
Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на
проде (замер 2026-08-12) то есть свой кадастр цели известен почти никогда.
Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number`
(tasks/cadastral_geo_match) здесь он применён к координатам САМОЙ цели, а не
к координатам аналога.
Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен
только 3-й компонент кадастрового номера (квартал), а он у соседних зданий
одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не
полный кадастр, чтобы вызывающий код не принял его за кадастр дома.
None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет
или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M.
"""
if lat is None or lon is None:
return None
try:
row = db.execute(
text(
"""
WITH p AS (
SELECT ST_SetSRID(
ST_MakePoint(CAST(:lon AS double precision),
CAST(:lat AS double precision)), 4326) AS g
)
SELECT cb.cad_num
FROM cad_buildings_local cb, p
WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision))
AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision)
ORDER BY cb.geom <-> p.g
LIMIT 1
"""
),
{
"lat": lat,
"lon": lon,
"deg": _TARGET_QUARTER_DEG_GATE,
"radius": _TARGET_QUARTER_RADIUS_M,
},
).first()
except Exception as exc:
logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc)
return None
if row is None:
return None
return _quarter_from_cadastre(str(row[0]))
def _apply_quarter_index(
*,
base_median_ppm2: float,
@ -1491,7 +1567,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
"""#794: monthly {period_month: index_value} for region from sber_price_index.
Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error.
#audit-5a: если latest месяц серии старее sber_index_max_age_days → warning.
#2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest
периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по
построению: period_month метка ПЕРВОГО числа месяца (30 суток уже на
закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых
наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени
нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip
(event_level=ERROR). Свежесть теперь мерит ОДНО место tasks/sber_freshness_monitor,
и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт),
а не календарь.
"""
for dash in SBER_COEFF_DASHBOARDS:
try:
@ -1519,21 +1604,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
if not series:
continue
# #audit-5a: data-age guard — предупреждаем о stale СберИндексе.
latest = max(series)
today = datetime.now(tz=UTC).date()
age_days = (today - latest).days
if age_days > settings.sber_index_max_age_days:
logger.warning(
"sber_index stale #audit-5a: latest=%s age=%d days"
" (> sber_index_max_age_days=%d) region=%s dash=%s"
" — time-adjustment may be outdated",
latest.isoformat(),
age_days,
settings.sber_index_max_age_days,
region,
dash,
)
return series
return {}
@ -1698,6 +1768,10 @@ def _fetch_dkp_corridor(
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
adjusted: list[float] = []
factors_applied: list[float] = []
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
# заявленную свежесть коридора, не участвуя в его границах.
latest_deal: date | None = None
for r in rows:
ppm2 = r["price_per_m2"]
if not ppm2:
@ -1709,6 +1783,8 @@ def _fetch_dkp_corridor(
factor = _sber_time_factor(series, deal_month)
adjusted.append(float(ppm2) * factor)
factors_applied.append(factor)
if dd is not None and (latest_deal is None or dd > latest_deal):
latest_deal = dd
ppm2_values = sorted(adjusted)
# #oblast-D widen: a single street in a small non-EKB town can easily have
@ -1761,6 +1837,7 @@ def _fetch_dkp_corridor(
city_rows = []
city_adjusted: list[float] = []
city_latest: date | None = None
for r in city_rows:
ppm2 = r["price_per_m2"]
if not ppm2:
@ -1771,6 +1848,8 @@ def _fetch_dkp_corridor(
deal_month = date(dd.year, dd.month, 1)
factor = _sber_time_factor(series, deal_month)
city_adjusted.append(float(ppm2) * factor)
if dd is not None and (city_latest is None or dd > city_latest):
city_latest = dd
if len(city_adjusted) > len(ppm2_values):
logger.info(
@ -1781,6 +1860,10 @@ def _fetch_dkp_corridor(
city,
)
ppm2_values = sorted(city_adjusted)
# #2846: коридор теперь описывает city-выборку — и возраст обязан
# переехать вместе с числами, иначе подпись осталась бы от street-
# выборки, которую на витрине уже никто не видит.
latest_deal = city_latest
if not ppm2_values:
return None
@ -1803,6 +1886,7 @@ def _fetch_dkp_corridor(
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
"period_months": period_months,
"latest_deal_date": latest_deal,
}
@ -2677,6 +2761,9 @@ def _price_from_inputs(
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
target_house_cadnum: str | None,
dadata_coarse: bool,
geo: GeocodeResult,
@ -3029,6 +3116,7 @@ def _price_from_inputs(
anchor_total: int | None = None
anchor_higher: int | None = None
anchor_label: str | None = None
anchor_repair_note: str = ""
if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = (
@ -3047,6 +3135,22 @@ def _price_from_inputs(
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
),
)
# #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
# не правим), а в blend уходит якорь, приведённый к базису ремонта target'а
# тому же, в котором median_price уже посчитан выше (repair_coef).
_rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
if _rep_k != 1.0:
anchor_total = round(anchor_total * _rep_k)
anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
logger.info(
"imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
" → anchor %d",
imv_anchor.get("renovation_type"),
repair_state,
_rep_k,
anchor_total,
)
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
@ -3096,8 +3200,8 @@ def _price_from_inputs(
median_price = new_median
median_ppm2 = new_ppm2
explanation = (explanation or "") + (
f" Оценка скорректирована по {anchor_label} "
f"({anchor_used / 1_000_000:.1f} млн ₽)."
f" Оценка скорректирована по {anchor_label}"
f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
)
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали.
@ -3128,13 +3232,16 @@ def _price_from_inputs(
and median_price > 0
and area_m2
):
# target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по
# координатам цели (_lookup_target_quarter_by_coords, вызывающий код).
# Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» —
# снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал
# цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м
# от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза
# по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой).
# Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя
# заменить чужим кварталом, он и есть предмет поправки.
target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum)
if target_quarter is None:
for lot in listings_clean:
cq = _quarter_from_cadastre(lot.get("building_cadastral_number"))
if cq is not None:
target_quarter = cq
break
if target_quarter is not None:
qindex_result = quarter_index_lookup(target_quarter)
@ -4221,6 +4328,22 @@ async def estimate_quality(
# ── Coarse-geo signals ────────────────────────────────────────────────────
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
# квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
# сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
if (
settings.estimate_quarter_from_coords_enabled
and not target_quarter_cadnum
and geo is not None
):
target_quarter_cadnum = await asyncio.to_thread(
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
)
# ── DB-callable wrappers injected into pure pricing ───────────────────────
def _ratio_resolver(
appm2: float | None,
@ -4265,7 +4388,7 @@ async def estimate_quality(
ratio_resolver=_ratio_resolver,
quarter_index_lookup=_qi_lookup,
quarter_indexes_lookup=_qis_lookup,
target_house_cadnum=dadata.house_cadnum if dadata else None,
target_house_cadnum=target_quarter_cadnum,
dadata_coarse=dadata_coarse,
geo=geo,
dadata_qc_geo=dadata.qc_geo if dadata else None,
@ -4660,6 +4783,7 @@ async def estimate_quality(
target_lat=geo.lat,
target_lon=geo.lon,
target_city_ambiguous=geo.city_ambiguous,
target_address_refined=geo.address_refined,
sources_used=sources_used,
data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at,

View file

@ -238,6 +238,11 @@ _FS_MD = "10.5pt" # базовый текст (body), значения в та
_FS_LG = "13pt" # заголовки страниц (h2, PT Serif)
_FS_XL = "16pt" # главный заголовок cover (h1, PT Serif)
_FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок)
# Намеренное исключение из 6-шаговой шкалы: running-footer — @page margin-box с
# фиксированной высотой (19mm ≈ 53.9pt), делить с mono-мета-строкой/wordmark
# практически нечем (см. _page_footer). 135-ФЗ дисклеймер (Блок 4.2) должен
# влезать в ~380-450 симв. на каждой странице без пятой пустой страницы.
_FS_XXS = "5pt" # ТОЛЬКО 135-ФЗ футер-дисклеймер (_page_footer) — не переиспользовать
# ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ──────────────
@ -528,6 +533,20 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
)
# Блок 4.2 юр-требований: должен печататься в подвале КАЖДОЙ страницы отчёта
# (не только cover). Текст утверждён владельцем продукта дословно — не менять
# формулировку без явного запроса. Заведён как модульная константа (не inline
# в _page_footer), чтобы не расползалась по нескольким билдерам страниц.
_PDF_135FZ_FOOTER_NOTICE = (
"Документ содержит индикативный (ориентировочный) расчёт стоимости объекта, "
"сформированный автоматически сервисом «МЕРА». Не является отчётом об оценке "
"по Федеральному закону № 135-ФЗ и не имеет установленной этим законом "
"юридической силы. Не предназначен для использования при ипотечном "
"кредитовании, в судебных разбирательствах, нотариальных действиях и иных "
"случаях, где законом предусмотрено обязательное проведение независимой оценки."
)
def _page_footer(
brand, # type: ignore[no-untyped-def]
report_num: str,
@ -542,7 +561,10 @@ def _page_footer(
строка 1 mono meta ( отчёта / дата / срок действия); тонкая градиентная
линия-разделитель; строка 2 точка акцента + wordmark (brand.name НЕ
хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь
хардкод «МЕРА», white-label остаётся рабочим); строка 3 135-ФЗ дисклеймер
(Блок 4.2, _PDF_135FZ_FOOTER_NOTICE) печатается на КАЖДОЙ странице, т.к.
footer рендерится один раз как running @page margin-box (см. вызов в
generate_trade_in_pdf), а не per-page. Номер версии продукта здесь
НЕ дублируется единственное место вывода версии в PDF running-header
(_page_header product_version_line()); раньше рядом с wordmark висел
decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной
@ -552,6 +574,17 @@ def _page_footer(
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
удалён вынесен приглушённой строкой НАД HUD-баром, чтобы не терять
полезную для печатного многостраничного отчёта информацию.
#footer-height-budget (2026-08-14, Блок 4.2): @bottom-center margin-box
высотой = page margin-bottom (см. _build_css). Добавление 135-ФЗ текста
(~440 симв.) потребовало И сжать существующий HUD-хром (margin-top
64pt, padding-top 86pt, line-height мета/wordmark строк 1.351.15,
градиент-разделитель margin 6pt 03pt 0 экономия ~13pt), И минимально
поднять @page margin-bottom (19mm21mm, +2mm/+5.67pt) сжатия одного
подвала было недостаточно без деградации до нечитаемого. Риск: margin-bottom
режет тело КАЖДОЙ из 4 страниц потенциальный откат к 5-й почти пустой
странице (регрессия, чинившаяся в 42a50cf8) реальным рендером
(WeasyPrint/Pango, недоступен на Windows-деве) не подтверждено, см. PR.
"""
note_html = ""
if page_note:
@ -583,18 +616,18 @@ def _page_footer(
# тела страницы) и был источником сложности; заменён на простую тонкую
# градиентную линию-разделитель между строками meta/wordmark.
return f"""
<div style="margin-top:6pt;">
<div style="margin-top:4pt;">
{note_html}
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:6pt;
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
color:{_MUTED_2};">
color:{_MUTED_2};line-height:1.15;">
<div style="white-space:nowrap;overflow:hidden;">
ОТЧЁТ <span style="color:{_MUTED};">{_html.escape(report_num)}</span>
<span style="margin-left:16pt;">ДАТА
<span style="color:{_MUTED};">{report_date.strftime("%d.%m.%Y")}</span></span>
{valid_until_html}
</div>
<div style="height:1pt;margin:6pt 0;background:linear-gradient(90deg,
<div style="height:1pt;margin:3pt 0;background:linear-gradient(90deg,
transparent,{_LINE_DOTTED} 15%,{_ACCENT} 50%,{_LINE_DOTTED} 85%,
transparent);"></div>
<div style="display:flex;align-items:center;gap:7pt;min-width:0;">
@ -606,6 +639,11 @@ def _page_footer(
{_html.escape(brand.name).upper()}</span>
</div>
</div>
<div style="margin-top:3pt;font-family:'PT Sans','DejaVu Sans',sans-serif;
font-size:{_FS_XXS};line-height:1.15;color:{_MUTED_2};
overflow-wrap:anywhere;">
{_html.escape(_PDF_135FZ_FOOTER_NOTICE)}
</div>
</div>
"""
@ -1516,11 +1554,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
# ── Page 3: Deals ────────────────────────────────────────────────────────────
_ROMAN_QUARTER = ("I", "II", "III", "IV")
def deals_as_of_label(estimate: AggregatedEstimate) -> str | None:
"""#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки.
Раньше страница печатала «Период сделок: 08.2025 08.2026» окно ПОИСКА,
посчитанное как `today - period_months*30 today`. Правым концом оно обещало
сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде
(замер 2026-08-12) I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно
по тем сделкам, из которых страница строит диапазон и таблицу примеров.
Гранулярность квартал: rosreestr пишет deal_date = первый день квартала
(#1995, ровно то, что помечает AnalogLot.date_precision == "quarter").
Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания
сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока
такого нет) подписывается месяцем.
None сделок нет либо ни у одной нет даты: подписывать нечего.
"""
dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date]
if not dated:
return None
newest, precision = max(dated, key=lambda p: p[0])
if precision == "day":
return f"по {newest.strftime('%m.%Y')}"
return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}"
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
n_deals = len(estimate.actual_deals)
today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30)
# #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
# Реальная граница — as-of по показанным сделкам; окна поиска на странице
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
deals_as_of = deals_as_of_label(estimate)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%»,
@ -1597,9 +1665,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
<tr><td style="padding:4pt 0;">Период сделок</td>
<td class="bold" style="text-align:right;">
{_mono(f"{period_start.strftime('%m.%Y')} {today.strftime('%m.%Y')}")}</td></tr>
{f'''<tr><td style="padding:4pt 0;">Сделки</td>
<td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
if deals_as_of else ""}
</table>
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
@ -1932,7 +2000,11 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
}}
@page {{
size: A4;
margin: 25mm 18mm 19mm 18mm;
/* bottom 19mm21mm (#footer-height-budget, Блок 4.2): +2mm — минимум,
которого не хватило внутри @bottom-center margin-box (высота margin-box
= margin-bottom) даже после сжатия HUD-хрома _page_footer под 135-ФЗ
дисклеймер на каждой странице. См. арифметику в _page_footer(). */
margin: 25mm 18mm 21mm 18mm;
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
}}

View file

@ -44,6 +44,22 @@ class GeocodeResult:
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
city_ambiguous: bool = False
# #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses`
# (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/
# cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы
# («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует
# корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по
# нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён
# автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`.
# Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный
# источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4),
# поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого
# адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) —
# если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined`
# вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см.
# `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не
# из кэша).
address_refined: bool = False
# ── EKB bounding boxes ───────────────────────────────────────────────────────
@ -372,6 +388,134 @@ def _names_unrecognized_locality(address: str) -> bool:
return bool(_LOCALITY_MARKER_RE.search(normalized))
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
# использовал, и если названный топоним туда не дожил — топоним подменён.
#
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
# сам целевой город.
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
# списка городов области, ради ухода от которого всё и делается).
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
_STREET_LEVEL_RE = re.compile(
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
r"|корпус|лит|литера|снт|сад|гск)\b"
)
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
_LOCALITY_TYPE_WORDS = frozenset(
{
"поселок",
"пос",
"село",
"деревня",
"дер",
"город",
"гор",
"округ",
"муниципальный",
"городской",
"сельское",
"поселение",
"тер",
"территория",
"станция",
"пгт",
"рп",
}
)
_WORD_RE = re.compile(r"[а-я][а-я-]*")
def _fold(value: str) -> str:
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
return " ".join(value.lower().replace("ё", "е").split())
def _claimed_locality(address: str) -> str | None:
"""Имя населённого пункта, названное в тексте адреса, или None.
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
(страна регион район НП улица дом), пропускаем уровни выше/ниже
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент имя НП.
None означает «НП не назван» это основной трафик формы («Малышева 30»), и
для него инвариант не применяется вовсе.
"""
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
segment = segment.strip()
if not segment:
continue
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
return None # улица/дом: будь НП назван, он шёл бы раньше
if (
_COUNTRY_RE.search(segment)
or _REGION_LEVEL_RE.search(segment)
or _DISTRICT_LEVEL_RE.search(segment)
):
continue
name = " ".join(
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
)
if name:
return name
return None
def _city_substituted(address: str, result: GeocodeResult) -> bool:
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
Три условия вместе:
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
2. этого имени НЕТ в адресе, который вернул провайдер то есть топоним не
пережил геокодинг;
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
собственному ответу провайдера он называет Екатеринбург либо не
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
тогда «внутри ЕКБ» подтверждают координаты).
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
Советская ул., 5» ответ «Екатеринбург, Советская улица, 5» имя не дожило,
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
корректный ответ по посёлку («Кедровка, Екатеринбург, » Nominatim и Yandex
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит»
«М-12 Ачит-Екатеринбург», «Лесной» «Лесной переулок»), считается дожившим
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
приставки («пионерский, советская»), понижается до `locality` честная
деградация, координаты не теряются.
"""
claimed = _claimed_locality(address)
if claimed is None or _EKATERINBURG_RE.search(claimed):
return False
answer = _fold(result.full_address or "")
if any(word in answer for word in claimed.split()):
return False # топоним дожил до ответа — провайдер искал там, где просили
if not is_within_ekb_bbox(result.lat, result.lon):
return False
answer_locality = _claimed_locality(answer)
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
@ -598,7 +742,23 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
return oblast_fallback
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError
# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query
# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на
# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py
# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет
# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review
# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion).
# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай
# если голый 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:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
@ -797,7 +957,8 @@ async def _nominatim_query_city_aware(
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
@ -1060,12 +1221,13 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
ВНИМАНИЕ, цепочки различаются не путать:
* `geocode()` : geoportal cadastral `_cadastral_forward_sync`
Nominatim None. Тира DaData тут НЕТ.
Nominatim `_local_houses_match` (#2626, houses-фолбэк)
None. Тира DaData тут НЕТ.
* `suggest()` : cadastral DaData Nominatim (единственный вызов
`_dadata_suggest`).
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None
оценка не строится. Это сознательный выбор: честный отказ вместо
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку,
даёт None оценка не строится. Это сознательный выбор: честный отказ вместо
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776`
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
@ -1182,6 +1344,283 @@ def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSugge
)
# ── Local `houses` fallback (#2626) — последний тир geocode() ───────────────
# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов,
# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses):
# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1»
# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»);
# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР),
# пользователь пишет только последнее слово имени.
# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с
# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не
# резолвят, а houses чаще содержит именно то написание, которым реально пользуются
# люди (агрегировано из объявлений, а не из официального ГАР).
#
# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/
# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата
# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`.
# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем
# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house`
# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже).
_LOCAL_HOUSE_TOKEN_RE = re.compile(
r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)",
re.IGNORECASE,
)
def _norm_local_house(raw: str) -> str:
"""Канон номера дома для houses-фолбэка.
«49 к 1» / «49-к1» / «49 корпус 1» «49к1»; «88 / 2» «88/2»; «35А» «35а».
"""
s = raw.strip().lower()
s = re.sub(r"\s+", "", s)
s = re.sub(r"корпус|корп\.?", "к", s)
s = re.sub(r"-(к\d+)", r"\1", s)
s = re.sub(r"-([а-яё])$", r"\1", s)
return s
# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж.
# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут
# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE`
# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки
# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер
# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая,
# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15».
_RE_LOCAL_APT_TAIL = re.compile(
r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$",
re.IGNORECASE,
)
def _extract_local_house_token(address: str) -> str | None:
"""Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов,
которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у
`_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение номер дома в русском
адресе почти всегда в хвосте строки. None, если цифр нет вовсе.
Квартирный/этажный/подъездный хвост зачищается ДО поиска номера
(`_RE_LOCAL_APT_TAIL`) иначе «последнее число в строке» это номер
квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`).
"""
s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.")
if not s:
return None
s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.")
if not s:
return None
matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s))
if not matches:
return None
return _norm_local_house(matches[-1].group(1))
# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением
# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого
# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё
# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ
# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа.
# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом
# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b`
# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) —
# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .»)
# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses).
_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE)
def _clean_local_house_street(segment: str) -> str:
"""«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» /
«начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы.
Общая нормализация и для запроса пользователя (уже typeless из
`_parse_street_house`, но повторный проход no-op), и для `houses.address`.
"""
s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower())
return " ".join(s.split())
def _row_local_house(address: str) -> tuple[str, str] | None:
"""Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm).
Номер дома ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1»,
«X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки)
покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор
после номера отбрасывается). Известный неполный случай (не встретился в
выборке): номер дома БЕЗ запятой перед ним вернёт None, строка просто не
станет кандидатом (не ложный матч).
"""
segments = [s.strip() for s in address.split(",") if s.strip()]
if len(segments) < 2:
return None
m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1])
if not m:
return None
house_norm = _norm_local_house(m.group(1))
street_norm = _clean_local_house_street(" ".join(segments[:-1]))
if not street_norm or not house_norm:
return None
return street_norm, house_norm
def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool:
"""True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы
в `houses` «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя),
регистронезависимо. Точное равенство тоже проходит (частый случай короткие
однословные улицы, «Малышева» == «Малышева»)."""
return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm)
# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер,
# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) —
# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у
# нас в вводе просто нет данных, какой именно».
_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$")
def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
"""Последний локальный тир `geocode()` (#2626) — fallback на `houses`
(скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW).
Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата.
Допущения, все defensive (при неоднозначности None, не гадаем):
1. Улица матчится «по хвосту» (`_street_tail_matches`) ловит расхождение
разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в
houses («Начдива Онуфриева»).
2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox
(`is_within_ekb_bbox_wide`) `houses` НЕ ЕКБ-only реестр (в отличие от
geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ,
местами вплоть до другого региона (#2626 review R2 #2 — прод-пример
«улица Маяковского, 7» в houses это Серов, а не запрошенный
Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС
пользователя, не страхует от грязной строки-источника.
3. Номер дома сперва точное совпадение; нет пробуем `<номер>к1` (частый
случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но
ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же
номера («24к2», «24/2» и т.п.) иначе «к1» такая же угадайка, как и
любой другой корпус, и реальные дома могут быть в 250-400м друг от друга
(#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3
три разных здания).
4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым
координатам разные source-строки ОДНОГО дома не в счёт), возвращает
None угадывать нельзя.
SQL дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на
`houses.address`, но тир последний и редкий не на каждый запрос) с
детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо
выбирал бы, какая из двух ~идентичных source-строк станет ответом
#2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство
номера) в Python, что и делает её юнит-тестируемой без реальной БД
(см. `test_geocoder_local_houses_fallback.py`).
Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной
(см. `geocode()`) `houses`-координаты из скрейпленных объявлений менее
надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный
(#2626 review R2 #4).
"""
query_street_norm = _clean_local_house_street(street)
if not query_street_norm:
return None
query_house_norm = _norm_local_house(house)
if not query_house_norm:
return None
last_word = query_street_norm.split()[-1]
try:
rows = db.execute(
text("""
SELECT address, lat, lon
FROM houses
WHERE address ILIKE CAST('%' || :w || '%' AS text)
AND lat IS NOT NULL AND lon IS NOT NULL
ORDER BY address, id
"""),
{"w": last_word},
).fetchall()
except Exception:
logger.warning(
"local houses fallback query failed for street=%r house=%r",
street,
house,
exc_info=True,
)
return None
# Street-tail + bbox фильтр — один проход, дальше переиспользуется и для
# точного совпадения, и для corpus-1 догадки, и для sibling-guard.
street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr)
for r in rows:
parsed = _row_local_house(str(r.address or ""))
if parsed is None:
continue
row_street_norm, row_house_norm = parsed
if not _street_tail_matches(row_street_norm, query_street_norm):
continue
lat, lon = float(r.lat), float(r.lon)
if not is_within_ekb_bbox_wide(lat, lon):
continue
street_rows.append((row_house_norm, lat, lon, str(r.address)))
def _candidates(house_norm: str) -> list[tuple[str, float, float]]:
out: list[tuple[str, float, float]] = []
seen_coords: set[tuple[float, float]] = set()
for row_house_norm, lat, lon, addr in street_rows:
if row_house_norm != house_norm:
continue
coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников
if coord_key in seen_coords:
continue
seen_coords.add(coord_key)
out.append((addr, lat, lon))
return out
exact = _candidates(query_house_norm)
if len(exact) == 1:
addr, lat, lon = exact[0]
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
if len(exact) > 1:
logger.info(
"local houses fallback: %d неоднозначных кандидата для %r %r — skip",
len(exact),
street,
house,
)
return None
# Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО
# если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»).
if query_house_norm.isdigit():
corpus1 = f"{query_house_norm}к1"
siblings = {
row_house_norm
for row_house_norm, _lat, _lon, _addr in street_rows
if row_house_norm != corpus1
and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None
and m.group(1) == query_house_norm
}
if siblings:
logger.info(
"local houses fallback: корпус-1 %r неоднозначен — есть другие "
"корпуса/дроби %s — skip",
corpus1,
sorted(siblings),
)
return None
guessed = _candidates(corpus1)
if len(guessed) == 1:
addr, lat, lon = guessed[0]
logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr)
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
if len(guessed) > 1:
logger.info(
"local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip",
corpus1,
len(guessed),
)
return None
def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
"""Reverse lookup via gendesign_cad_buildings FDW.
@ -1287,6 +1726,42 @@ async def suggest(
# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
применяется ОДНОЙ точкой на выходе поэтому покрывает разом все источники,
включая попадание в кэш: отравленная запись, записанная до этого фикса,
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
Сработал инвариант `confidence="locality"` + `city_ambiguous=True`.
`locality` не косметика: `estimator._geocode_is_coarse` уже трактует его
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
оценки, (б) через `tasks.geocode_missing` проставляет листингу
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
за собой чужие оценки. Координаты НЕ выбрасываются деградация честная и
видимая, а не отказ.
"""
result = await _geocode_resolve(address, db, city_hint)
if result is None or not _city_substituted(address, result):
return result
logger.warning(
"geocode city substitution (#2590): %r%r (%.5f, %.5f) provider=%s"
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
address[:80],
(result.full_address or "")[:80],
result.lat,
result.lon,
result.provider,
)
return replace(result, confidence="locality", city_ambiguous=True)
async def _geocode_resolve(
address: str, db: Session, city_hint: str | None = None
) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args:
@ -1422,6 +1897,45 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
except Exception:
logger.exception("nominatim geocoder failed")
# 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None.
# 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом
# был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не
# резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий
# в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у
# geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск
# коллизии улица+дом с другим городом региона, что и мотивировал #2582);
# координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри
# `_local_houses_match` (гейт здесь фильтрует только запрос пользователя,
# не грязь в самой таблице — #2626 review R2 #2).
if use_local_ekb and parsed is not None:
local_street, _parsed_house = parsed
local_house = _extract_local_house_token(address) or _parsed_house
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
if hit is not None:
result = GeocodeResult(
lat=hit.lat,
lon=hit.lon,
full_address=hit.full_address,
provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal
confidence="exact",
city_ambiguous=city_ambiguous,
address_refined=True,
)
# НЕ кэшируем: houses-координаты (скрейпленные листинги) менее
# надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый
# и локальный — кэш только продлевал бы жизнь возможной ошибке
# источника (#2626 review R2 #4). Побочный эффект: `address_refined`
# переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не
# только первый (было известным пределом до этого фикса).
logger.info(
"geocode local houses fallback: %s → (%.5f, %.5f) [%s]",
addr_norm,
result.lat,
result.lon,
hit.full_address,
)
return result
return None
@ -1490,7 +2004,8 @@ def _format_reverse_address(addr: dict) -> str | None:
return ", ".join(parts)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.

View file

@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
UPDATE houses
SET imv_status = 'ok',
last_imv_attempt_at = NOW(),
imv_error_reason = NULL
imv_error_reason = NULL,
imv_transient_attempts = 0
WHERE id = :hid
"""),
{"hid": house_id},
@ -424,12 +425,19 @@ def _mark_status(
status: str,
reason: str | None = None,
) -> None:
# #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом
# падал по временной причине», а не «сколько раз его трогали». no_params /
# no_address / not_found счётчик не двигают: они не занимают retry-слот.
db.execute(
text("""
UPDATE houses
SET imv_status = :s,
last_imv_attempt_at = NOW(),
imv_error_reason = :r
imv_error_reason = :r,
imv_transient_attempts = CASE
WHEN :s = 'transient_error' THEN imv_transient_attempts + 1
ELSE imv_transient_attempts
END
WHERE id = :hid
"""),
{"hid": house_id, "s": status, "r": reason},
@ -443,6 +451,104 @@ _IMVStatus = Literal[
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error"
]
# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем
# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08)
# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039
# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на
# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует,
# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно.
# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status=
# 'transient_error' и считаются как
# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3.
_MAX_TRANSIENT_ATTEMPTS = 3
# Доля пакета под повтор transient_error. Половина — потому что остальные слоты
# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке
# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер
# головы очереди на 12.08), так что pending на половине пакета всё равно идёт
# быстрее, чем на целом до правки.
_RETRY_SLOTS_SHARE = 0.5
# Дом без пригодных параметров backfill всё равно пометит no_params — но только
# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним
# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ
# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»).
# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по
# imv_error_reason продолжают работать.
# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё
# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом.
# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы
# поштучный путь.
_PREMARK_UNUSABLE_SQL = text("""
WITH unusable AS (
SELECT h.id,
CASE WHEN NOT EXISTS (
SELECT 1 FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)
THEN 'no listings with rooms+area'
ELSE 'unknown house_type'
END AS reason
FROM houses h
WHERE h.imv_status = ANY(CAST(:statuses AS text[]))
AND h.lat IS NOT NULL
AND h.lon IS NOT NULL
AND h.address IS NOT NULL
AND (
NOT EXISTS (
SELECT 1 FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)
OR COALESCE(
NULLIF(TRIM((
SELECT mode() WITHIN GROUP (ORDER BY l.house_type)
FROM listings l
WHERE l.house_id_fk = h.id
AND l.rooms IS NOT NULL
AND l.area_m2 IS NOT NULL)), ''),
NULLIF(TRIM(h.house_type), '')
) IS NULL
)
)
UPDATE houses
SET imv_status = 'no_params',
last_imv_attempt_at = NOW(),
imv_error_reason = unusable.reason
FROM unusable
WHERE houses.id = unusable.id
""")
# Основная очередь: один статус, как и было (only_status — публичный параметр
# admin-API, семантику не трогаем).
_QUEUE_SQL = text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = :status
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
""")
# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending
# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST
# transient_error не попал бы в пакет, пока не кончится pending (по замеру
# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход.
_RETRY_QUEUE_SQL = text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = 'transient_error'
AND imv_transient_attempts < :max_attempts
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
""")
@dataclass
class HouseIMVBackfillResult:
@ -452,6 +558,23 @@ class HouseIMVBackfillResult:
errors: int = 0
duration_sec: float = field(default=0.0)
status_counts: dict[str, int] = field(default_factory=dict)
# #2674: сколько домов пакета пришло из retry-очереди transient_error и
# сколько помечено no_params до пакета (без запроса к площадке).
retried: int = 0
premarked: int = 0
def _premark_unusable(db: Session, statuses: list[str]) -> int:
"""Пометить no_params дома, по которым запрос к площадке невозможен. → сколько.
Не новое поведение, а тот же вердикт _process_one_house одним запросом: на
12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area,
1812 без house_type) каждый занимал слот пакета и паузу, чтобы получить
ответ, который виден в SQL.
"""
res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses})
db.commit()
return int(res.rowcount or 0)
def _beat(heartbeat: Callable[[], None] | None) -> None:
@ -480,7 +603,11 @@ async def backfill_house_imv(
batch_size: max houses to process (ignored when house_id given).
request_delay_sec: sleep between Avito API calls (default 5s anti-bot).
only_status: process houses with this imv_status (default 'pending').
Use 'transient_error' to retry failures.
Use 'transient_error' to retry failures. При значении по умолчанию
часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор
transient_error с непотраченным лимитом попыток (#2674) — явно
переданный only_status этот проход отключает, оператор получает
ровно то, что попросил, включая исчерпавшие лимит дома.
house_id: process a single specific house (debug).
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
домов caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
@ -509,23 +636,45 @@ async def backfill_house_imv(
.all()
)
else:
rows = (
# Повторный проход только на расписании (only_status по умолчанию): явный
# only_status от оператора — это ручной запрос ровно одного статуса.
retry_lane = only_status == "pending"
statuses = [only_status] + (["transient_error"] if retry_lane else [])
result.premarked = _premark_unusable(db, statuses)
if result.premarked:
logger.info(
"house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area "
"или house_type) — слоты пакета не потрачены",
result.premarked,
)
retry_rows: list = []
if retry_lane:
retry_rows = (
db.execute(
_RETRY_QUEUE_SQL,
{
"max_attempts": _MAX_TRANSIENT_ATTEMPTS,
"batch": int(batch_size * _RETRY_SLOTS_SHARE),
},
)
.mappings()
.all()
)
result.retried = len(retry_rows)
# Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на
# 12.08) возвращается pending — пакет не простаивает.
fresh_rows = (
db.execute(
text("""
SELECT id, address, full_address, lat, lon
FROM houses
WHERE imv_status = :status
AND lat IS NOT NULL
AND lon IS NOT NULL
AND address IS NOT NULL
ORDER BY last_imv_attempt_at NULLS FIRST, id
LIMIT :batch
"""),
{"status": only_status, "batch": batch_size},
_QUEUE_SQL,
{"status": only_status, "batch": max(batch_size - result.retried, 0)},
)
.mappings()
.all()
)
rows = list(fresh_rows) + list(retry_rows)
result.checked = len(rows)
if not rows:
@ -534,9 +683,11 @@ async def backfill_house_imv(
return result
logger.info(
"house_imv_backfill: %d houses (status=%r delay=%.1fs)",
"house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)",
result.checked,
only_status,
result.retried,
result.premarked,
request_delay_sec,
)
@ -610,11 +761,14 @@ async def backfill_house_imv(
result.duration_sec = time.time() - t0
logger.info(
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s",
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d "
"retried=%d premarked=%d %.1fs %s",
result.checked,
result.saved,
result.skipped,
result.errors,
result.retried,
result.premarked,
result.duration_sec,
result.status_counts,
)

View file

@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
"commission_year": ["cian_serp", "yandex_realty_nb"],
"commission_month": ["yandex_realty_nb"], # raw RU month name
"developer_name": ["cian", "yandex_realty_nb"],
"has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag
# #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259).
# В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала,
# источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда,
# потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на
# проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML).
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
# Yandex Valuation enrichment (existing house attrs)
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],

View file

@ -217,6 +217,7 @@ async def _job_deactivate_stale(
) -> None:
from app.core.config import settings as _settings
from app.tasks.deactivate_stale_avito import (
CAP_MULT,
DEFAULT_MIN_CONFIRMATIONS,
DEFAULT_REVISIT_FLOOR_QUANTILE,
deactivate_stale_listings,
@ -236,6 +237,16 @@ async def _job_deactivate_stale(
revisit_floor_quantile: float = params.get(
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
)
# Пустой (NULL) listing_segment -- легаси-строки до миграции 011 + жертвы
# отсутствующего COALESCE в ON CONFLICT (base.py upsert никогда не перезаписывает
# listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не
# элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py.
null_segment_only: bool = params.get("null_segment_only", False)
# Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель,
# а не голая константа: источник с непропорционально длинным хвостом переобхода
# относительно своего ttl_days переопределяет его через default_params (ключ
# "cap_mult"), не трогая дефолт для остальных источников.
cap_mult: float = params.get("cap_mult", CAP_MULT)
loop = asyncio.get_event_loop()
await loop.run_in_executor(
@ -249,6 +260,8 @@ async def _job_deactivate_stale(
staleness_column=staleness_column,
min_confirmations=min_confirmations,
revisit_floor_quantile=revisit_floor_quantile,
null_segment_only=null_segment_only,
cap_mult=cap_mult,
),
)
@ -447,6 +460,12 @@ async def _job_house_imv_backfill(
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
"total_seen": result.checked,
"new_count": result.saved,
# #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error)
# и сколько домов ушло в no_params до пакета одним запросом. Без этих
# двух счётчиков в scrape_runs.counters проверить, что застрявшие
# действительно возвращаются в очередь, можно только по houses.
"retried": result.retried,
"premarked": result.premarked,
}
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
# «сделали то, что собирались», а не «не поймали известное исключение».

View file

@ -19,10 +19,19 @@ reap_stale_leases) — она рассчитана на долгоживущие
и без мутаций.
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
(тот же карантинный порог, что у acquire), нет активной строки в
scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов меньший consecutive_fails,
при равенстве более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку:
это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер.
(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now())
в scrape_proxy_source_bans для ЭТОГО source это по-прежнему жёсткий фильтр, не
влияющий на порядок. Порядок среди прошедших фильтр (замер 2026-08-10, #2825 доп.):
сначала узлы БЕЗ ИСТОРИИ банов по этому source, затем по возрастанию ban_count
даже если сама строка бана истекла (banned_until <= now()), её ban_count всё равно
учитывается, ведь строка НЕ удаляется сразу (purge только через 7 суток чистой
работы, см. 210-я миграция) и остаётся памятью «этот узел здесь уже банился N раз».
Внутри равного ban_count прежние критерии без изменений: меньший consecutive_fails,
при равенстве более свежий last_ok_at (NULLS LAST). Так хронически банящийся узел
(здоров по health-check, но регулярно ловит 403 от конкретной площадки) не всплывает
первым сразу после истечения TTL свежий healthcheck сам по себе больше не решает.
Не изобретаем ротацию/балансировку: это резолвер «дай рабочий прокси прямо сейчас»,
не lease-менеджер.
FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный
`SCRAPER_PROXY_URL` РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет":
@ -39,7 +48,9 @@ FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review
env в обход учёта банов.
НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials url
несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback
несёт логин/пароль, в лог никогда не идёт целиком), id узла и ban_count по этому
source (0, если истории нет) чтобы по логу было видно, что узел с историей банов
выбран осознанно (пул исчерпан по чистым узлам), а не тихо; при legit-fallback
warning с текстом «пуст» (сценарий 1); при exhaustion error с разбивкой
banned_for_source/unhealthy_or_disabled (сценарий 2) тексты НАМЕРЕННО разные, чтобы
их нельзя было спутать в логах/алертах.
@ -98,6 +109,10 @@ class _Candidate:
id: int
url: str
label: str | None
ban_count: int
"""ban_count по scrape_proxy_source_bans ДЛЯ ЭТОГО source (0, если строки нет —
узел ни разу не банился этой площадкой). Учитывает и истёкшие строки бана
(banned_until <= now(), но ещё не спурженные) см. докстринг модуля."""
def _safe_label(proxy_id: int, label: str | None, url: str) -> str:
@ -113,23 +128,31 @@ def _safe_label(proxy_id: int, label: str | None, url: str) -> str:
def _pick_candidate(db: Session, source: str) -> _Candidate | None:
"""READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел."""
"""READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел.
LEFT JOIN (не EXISTS) на scrape_proxy_source_bans нужен сам ban_count для
ранжирования, а не только факт активного бана. Активный бан (banned_until > now())
по-прежнему полный фильтр в WHERE, это НЕ меняется; но истёкшая (и ещё не
спурженная) строка бана остаётся в ORDER BY как история см. докстринг модуля.
COALESCE(b.ban_count, 0) узел без единой строки истории по source ранжируется
как ban_count=0, естественно раньше любого узла с реальной историей банов.
"""
row = (
db.execute(
text(
"""
SELECT id, url, label
FROM scrape_proxies
WHERE enabled
AND consecutive_fails < CAST(:max_fails AS integer)
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
WHERE b.proxy_id = scrape_proxies.id
AND b.source = CAST(:source AS text)
AND b.banned_until > now()
)
ORDER BY consecutive_fails ASC, last_ok_at DESC NULLS LAST, id
SELECT sp.id, sp.url, sp.label, COALESCE(b.ban_count, 0) AS ban_count
FROM scrape_proxies AS sp
LEFT JOIN scrape_proxy_source_bans AS b
ON b.proxy_id = sp.id
AND b.source = CAST(:source AS text)
WHERE sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
AND (b.banned_until IS NULL OR b.banned_until <= now())
ORDER BY COALESCE(b.ban_count, 0) ASC,
sp.consecutive_fails ASC,
sp.last_ok_at DESC NULLS LAST,
sp.id
LIMIT 1
"""
),
@ -143,7 +166,16 @@ def _pick_candidate(db: Session, source: str) -> _Candidate | None:
# середине более широкой операции).
if row is None:
return None
return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"])
return _Candidate(
id=int(row["id"]),
url=str(row["url"]),
label=row["label"],
# .get(..., 0) — не .__getitem__: production-SELECT ВСЕГДА проецирует
# ban_count (см. запрос выше), но нулевой default защищает от полного KeyError
# у сторонних fake-db в других test-модулях (напр. test_2830_pool_bypass_tails),
# которые мокают этот же db.execute() урезанным dict без нового столбца.
ban_count=int(row.get("ban_count", 0)),
)
@dataclass(frozen=True)
@ -239,10 +271,11 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
if candidate is not None:
logger.info(
"proxy_egress: source=%s -> pool proxy id=%d (%s)",
"proxy_egress: source=%s -> pool proxy id=%d (%s) ban_count=%d",
source,
candidate.id,
_safe_label(candidate.id, candidate.label, candidate.url),
candidate.ban_count,
)
return candidate.url

View file

@ -303,6 +303,18 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st
#1348: blocking psycopg work — must run via asyncio.to_thread, never directly
on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard).
#2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией
(limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в
DO UPDATE ставил одну и ту же метку всем строкам ряда на проде все 639 строк
несли время последнего прогона, включая период 2017-01. Как признак свежести
колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период»,
то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым
периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка
2026-08-06 и она останется такт публикации до этого PR невосстановим.
Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно
scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted),
и именно оттуда его берёт tasks/sber_freshness_monitor.
"""
for city_label, period_month, segment, dash, value in rows_to_upsert:
db.execute(
@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st
ON CONFLICT (city, period_month, dashboard)
DO UPDATE SET
index_value_rub_m2 = EXCLUDED.index_value_rub_m2,
segment = EXCLUDED.segment,
fetched_at = now()
segment = EXCLUDED.segment
-- fetched_at НЕ трогаем (#2846): она = «впервые увидели период».
"""
),
{

View file

@ -143,6 +143,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
# unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше
# сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов
# реально дали ноль.
# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich
# (65 прогонов/90д, единственные два писателя ключа на проде,
# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и
# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в
# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта
# (rows_fetched=rows_skipped=96974, last_id не двигается неделями),
# а не отказ; если бы 'rows_inserted' попал в этот список, сторож
# зачитывал бы этот здоровый ноль как измеренный провал и копил бы
# практически непрерываемый стрик (rosreestr_dkp_import не
# прерывается другим статусом — импорт либо 'done', либо не бежал).
# НЕ 'processed' по той же причине с другой стороны: это счётчик
# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже
# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14,
# 44% отказов замаскировались бы под measured-25) — сторож нулевого
# результата на нём молчал бы ровно там, где должен сработать, а на
# будущем опустении очереди домов (cian_houses_pending) создал бы
# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep
# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах —
# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08,
# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше
# ни total_seen/lots_fetched/unique_fetched не было, и
# _run_result_count всегда возвращал None (honest-run-status)).
# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650
# прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь
# (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched
@ -150,7 +172,12 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
# трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ
# (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их
# словарь, а честно признаёт, что мерить нечем — см. _run_result_count.
_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched")
_RESULT_COUNTER_KEYS = (
"total_seen",
"lots_fetched",
"unique_fetched",
"succeeded",
)
def _run_result_count(counters: Mapping[str, Any] | None) -> int | None:
@ -282,6 +309,63 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
return None
# honest-run-status (2026-08-15): доля отказов, которая обесценивает формально ненулевой
# сбор. Прод-факт avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,
# "blocked":1} — 89% попыток отказали, а mark_backfill_finished всё равно звал mark_done,
# потому что "produced != 0" (6 обогащено). Ни _sweep_run_did_nothing (нужны
# anchors_total/errors_count, у backfill'ов их нет), ни _phase_totally_failed (нужна пара
# "<phase>_attempted"/"<phase>_failed" — здесь голые "attempted"/"failed" без фазового
# префикса, `"attempted".endswith("_attempted")` не матчит) эту форму counters не ловят —
# обе проверки написаны под СВОИ формы, а не под backfill'овскую.
#
# Порог 'failed' — половина и больше отказов: сбор для практических целей провалился,
# даже если несколько записей всё же обогатились. Порог 'partial' НЕ заведён отдельным
# статусом scrape_runs.status — это потребовало бы миграции (DROP+ADD CHECK constraint,
# 051_scrape_runs_extend.sql) и обучило бы новому значению ещё 4 места (Literal-фильтр
# admin API, хардкод статусов фронта, оба IN-списка сторожей) — тот же класс "оборванной
# проводки", из-за которого заведён #2686/ban_kind. Вместо статуса — тот же диагноз, что и
# у ban_kind: causa в тексте `error`, терминальный статус один ('failed'). 0.15..0.5 —
# та же 'failed', но с другой формулировкой причины ("деградировал", не "провалился"), чтобы
# оператор видел разницу читая error, не только status.
FAILED_RATIO_FAILED_THRESHOLD = 0.5
FAILED_RATIO_DEGRADED_THRESHOLD = 0.15
# Минимум попыток, при котором доля вообще что-то значит — иначе 1 отказ из 2 (=0.5)
# палит статус на шуме единичного случая. То же рассуждение и то же число, что у
# _PHASE_MIN_ATTEMPTS (см. выше).
_FAILED_RATIO_MIN_ATTEMPTS = _PHASE_MIN_ATTEMPTS
def _failed_ratio_too_high(counters: Mapping[str, Any]) -> str | None:
"""Прогон, у которого доля отказов слишком велика, даже если что-то собрано.
Возвращает текст причины (для error) либо None. Читает ГОЛЫЕ ключи "attempted"/
"failed" (без фазового префикса) сейчас это словарь только у четырёх
detail-backfill'ов (avito/yandex/domclick/newbuilding_enrich), все идут через
mark_backfill_finished mark_done. `attempted < _FAILED_RATIO_MIN_ATTEMPTS` или
отсутствие любого из ключей None (нечем/не о чём судить счётчики либо не
заполнены, либо принадлежат другому источнику со своим словарём).
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) поэтому
'failed' без диагноза, как и у #2625/#2700/#2764.
"""
attempted = _pick_int(counters, "attempted")
failed = _pick_int(counters, "failed")
if attempted is None or failed is None or attempted < _FAILED_RATIO_MIN_ATTEMPTS:
return None
ratio = failed / max(attempted, 1)
if ratio >= FAILED_RATIO_FAILED_THRESHOLD:
verb = "провалился"
elif ratio >= FAILED_RATIO_DEGRADED_THRESHOLD:
verb = "деградировал"
else:
return None
return (
f"failed-ratio-honest-status: сбор {verb}{failed} из {attempted} попыток "
f"отказали (доля {ratio:.0%}); формально ненулевой результат этого не искупает. "
f"Причина НЕ установлена — статус 'failed' без диагноза"
)
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@ -292,13 +376,24 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
Приоритет ключей:
- total_seen _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched)
- new_count 'new_count' (если уже есть) иначе 'lots_inserted'
- total_seen _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched /
succeeded)
- new_count 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted'
(первый присутствующий). 'saved_inserted' full-load'ы (cian/avito/yandex,
CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала
new_count=0 у трёх подряд cian_full_load при реально сохранённых
saved_inserted=482/214/239 (honest-run-status) ключ 'new_count'/'lots_inserted'
у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ,
которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов;
здесь (для витринной колонки new_count) это безопасно в отличие от
_RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика.
Возвращает (total_seen, new_count); None для ключа, которого нет в counters
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
"""
return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted")
return _run_result_count(counters), _pick_int(
counters, "new_count", "lots_inserted", "saved_inserted", "rows_inserted"
)
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
@ -558,6 +653,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
#2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
ничего», этот про «одно направление работы мертво, а суммарный сбор это прячет».
honest-run-status: там же отказ называть успехом прогон с высокой долей отказов,
даже если собрано > 0 (см. _failed_ratio_too_high). Отличие от #2625/#2700: те два
смотрят на «всё или ничего» (все якоря / вся фаза), этот на ДОЛЮ отказов у
detail-backfill'ов, где ни один из первых двух признаков не матчит форму counters.
"""
did_nothing = _sweep_run_did_nothing(counters)
if did_nothing is not None:
@ -569,6 +669,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
logger.error("%s run_id=%d", phase_dead, run_id)
mark_failed(db, run_id, phase_dead, counters)
return
ratio_bad = _failed_ratio_too_high(counters)
if ratio_bad is not None:
logger.error("%s run_id=%d", ratio_bad, run_id)
mark_failed(db, run_id, ratio_bad, counters)
return
total_seen, new_count = _column_counts(counters)
row = db.execute(
text(

View file

@ -109,9 +109,16 @@ __all__ = [
# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ
# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный
# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан.
#
# #262 wave 2: avito_slug — Optional в CityLocation (не у каждого oblast-города
# подтверждён). Города без avito_slug пропускаем целиком — у них НЕТ avito_city_
# sweep schedule (262_), значит НЕТ и avito-листингов с их URL; паттерн для них
# был бы либо мёртвым, либо (что хуже) построен из city_slug вместо реального
# avito URL-сегмента и создал бы ложный LIKE-матч.
_OBLAST_AVITO_URL_PATTERNS = tuple(
"%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%"
for loc in CITY_LOCATIONS.values()
if loc.avito_slug is not None
)

View file

@ -6,8 +6,28 @@
Ключевые решения:
- Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой
инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka',
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем.
TTL=30. novostroyki (9659 активных первичных строк) не трогаем.
ИЗВЕСТНЫЙ ПРОБЕЛ (ревью TTL-CAP круг 2, 2026-08-15): этот скоуп уже, чем множество
реально протухших строк -- живой замер на проде даёт cian/novostroyki 9 483 активных
строки старше 60 суток, ни одна из них не деактивируется НИ ОДНОЙ джобой (внутри
скоупа cian/vtorichka и yandex/vtorichka таких строк 0). Потолок cap_mult (см.
CAP_MULT ниже) этот пробел не закрывает и закрыть не может -- он сжимает пул ВНУТРИ
скоупа джобы, а не расширяет сам скоуп. NULL-сегмент (тот же замер круга 2 давал
cian/NULL 211, yandex/NULL 523 строки старше 60 суток) закрыт отдельно ниже
(null_segment_only, миграция 266) -- novostroyki-часть пробела остаётся: расширение
скоупа туда отдельная задача (нужно сперва выяснить, поддерживает ли cian/yandex
full-coverage sweep для novostroyki СЕЙЧАС, иначе паушальный TTL повторит инцидент,
ради которого этот DECISION и принят) и намеренно НЕ входит в TTL-CAP.
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
- NULL-сегмент (легаси-строки до миграции 011 + жертвы бага в ON CONFLICT -- upsert
никогда не пишет listing_segment повторно, поэтому раз рождённая NULL-строка сама
себя не чинит даже при живой ежедневной досдаче) деактивируется ОТДЕЛЬНОЙ джобой per
source (null_segment_only=True, миграция 266): явный `listing_segment IS NULL`
предикат, а не ANY(:segments) -- этот оператор NULL никогда не матчит. Гейт
здоровья/пол переобхода для этой джобы выключены (min_confirmations=0,
revisit_floor_quantile=0) -- население нерепрезентативно мало (единицы подтверждений
в сутки против сотен-тысяч у обычного vtorichka-среза), калиброванный под vtorichka
порог держал бы джобу вечно skipped_unhealthy.
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
@ -127,6 +147,11 @@ DEFAULT_MIN_CONFIRMATIONS = 500
_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))"
# NULL-сегмент: `= ANY(...)` НИКОГДА не матчит NULL (SQL, не баг), поэтому
# для null_segment_only-режима нужен отдельный явный предикат IS NULL, а не элемент
# в :segments. См. _build_null_segment_sql ниже -- тот же принцип для самого UPDATE.
_CONFIRMATIONS_NULL_SEGMENT_FILTER = "\n AND listing_segment IS NULL"
# ── Пол TTL по измеренному циклу переобхода (#2659) ───────────────────────────
# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на
@ -188,9 +213,72 @@ _CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:seg
DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99
_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))"
_REVISIT_FLOOR_NULL_SEGMENT_FILTER = "\n AND l.listing_segment IS NULL"
def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any:
# ── Потолок эффективного TTL (положительная обратная связь пола, найдено 2026-08-15) ──
# У пола выше нет верхней границы: max(ttl_days, пол) может расти неограниченно.
# ЗАМЕР НА ПРОДЕ (уточнён 2026-08-15 после разбора): у yandex counters держали
# ttl_days_effective 75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 —
# пол реально разгонялся без верхней границы, и потолок закрывает именно это.
# ЧЕГО ПОТОЛОК НЕ ДЕЛАЕТ: он НЕ сжимает пул «активных». Замер показал 0
# деактивируемых строк на всех четырёх джобах и до, и после калибровки. Цифра
# «23 687 из 44 744 не подтверждались >7 суток» относится ко ВСЕМ источникам
# сразу, и две трети её — новостройки, которых оценщик не берёт. У avito
# просроченных ноль. Раздутый пул, влияющий на оценку, лежит в строках с ПУСТЫМ
# сегментом и чинится отдельной джобой, не этим потолком.
#
# МЕХАНИЗМ ПЕТЛИ: медленный обход поднимает пол (он же квантиль разрывов переобхода)
# -> высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться
# свежий обход -> пул «активных» раздувается «протухшими» строками -> следующий замер
# пола на том же раздутом пуле оказывается ещё выше. Без верхней границы это не
# самокорректирующийся пол, а положительная обратная связь.
#
# CAP_MULT = 2 -- эффективный TTL не может превысить удвоенный заданный оператором
# ttl_days. Пол по-прежнему может его поднять (ради #2659 -- см. комментарий выше:
# ложные снятия при неполном покрытии обхода), но не бесконечно. Почему именно 2, а
# не 3 или 1.5: вдвое — это ещё «мы искренне не уверены, что молчание значит
# снятие», не «источник вообще умер». Дальнейший рост пола сигнализирует не о
# медленном, но живом обходе, а о мёртвом источнике -- для ЭТОГО случая уже есть
# отдельный гейт по здоровью (min_confirmations) выше в этой же функции, который
# выключает деактивацию целиком, а не растягивает TTL до бесконечности. Калибровочная
# ручка, не догма -- при новом замере можно пересмотреть, как и revisit_floor_quantile.
#
# ПОЧЕМУ MULT, А НЕ ФИКСИРОВАННОЕ ЧИСЛО СУТОК -- И ГДЕ ЭТА ФОРМА ЛОМАЕТСЯ. Множитель
# от ttl_days даёт разный АБСОЛЮТНЫЙ потолок на разных источниках: cian/yandex
# (ttl=30) -> 60 суток, avito (ttl=10) -> 20 суток, domklik (ttl=14) -> 28 суток. Это
# ломается ровно там, где абсолютный хвост переобхода источника НЕ пропорционален его
# ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же
# потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже
# собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще
# заведён (см. комментарий выше). domklik (потолок 28 при хвосте 3.1) разрыва не
# имеет -- множитель 2 для него калиброван верно.
#
# YANDEX -- ТА ЖЕ ДЫРА, НАЙДЕНА ПОЗЖЕ (ревью круга 3, 2026-08-15). Строка выше до
# этой правки утверждала, что cian/yandex с потолком 60 тоже в порядке -- это было
# верно для cian (live-пол сейчас 31.1), но НЕ для yandex: ЖИВЫЕ полы из
# scrape_runs.counters (deactivate_stale_yandex, 2026-08-10..08-15) -- 75/75/75/39/
# 52/54, а прямой live-замер той же percentile_disc(0.99)-формулы сегодня даёт 79.2
# (n=1961 подтверждений за 3 суток). И то, и другое ВЫШЕ потолка 60 -- тот же
# false-kill класс, что у avito, статический p99=43.0 (_REVISIT_TAIL) для yandex
# устарел и вводит в заблуждение. cap_mult для yandex откалиброван отдельной
# миграцией (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90) --
# см. её комментарий про то, почему это НЕ меняет число деактивированных строк
# следующим прогоном (0 активных строк источника старше 39 суток на момент замера).
#
# ПОЭТОМУ cap_mult -- параметр функции (как revisit_floor_quantile, min_confirmations),
# не голая константа: default = CAP_MULT для источников, где 2x достаточно (cian,
# domklik), но расписание может переопределить через default_params (JSON-колонка
# scrape_schedules, ключ "cap_mult") для источника с непропорционально длинным
# хвостом -- см. миграции для avito (cap_mult=6, потолок 60, с запасом выше
# статического p99=42.1 и живого прод-пика 52, замеренного 2026-08-10..12) и yandex
# (cap_mult=3, потолок 90, с запасом выше живого пола 79.2, замеренного 2026-08-15).
CAP_MULT = 2
def _build_revisit_floor_sql(
staleness_column: str, *, with_segments: bool, null_segment_only: bool = False
) -> Any:
"""Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива.
Пара «предыдущее наблюдение (снимок) текущее наблюдение (listings)» даёт
@ -198,10 +286,20 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A
Только строки, у которых свежесть реально сдвинулась, то есть выжившие,
а не «мы к ним не приходили».
null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments)
(ANY никогда не матчит NULL). На практике для null_segment_only-джобы этот запрос
не строится вовсе (revisit_floor_quantile=0 -- см. модульный докстринг), но вариант
нужен для корректности, если порог когда-нибудь включат.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
Значения param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type).
"""
segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else ""
if null_segment_only:
segment_filter = _REVISIT_FLOOR_NULL_SEGMENT_FILTER
elif with_segments:
segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER
else:
segment_filter = ""
return text(
f"""
SELECT percentile_disc(CAST(:revisit_quantile AS double precision))
@ -229,14 +327,25 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A
)
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
def _build_confirmations_sql(
staleness_column: str, *, with_segments: bool, null_segment_only: bool = False
) -> Any:
"""SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments).
Для null_segment_only-джобы min_confirmations=0 по умолчанию (см. модульный
докстринг), так что на практике этот путь не строится -- оставлен для корректности.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
Значения (:listing_source, :health_window_days, :segments) param-binding,
psycopg v3 safe (CAST(... AS ...), никаких :param::type).
"""
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else ""
if null_segment_only:
segment_filter = _CONFIRMATIONS_NULL_SEGMENT_FILTER
elif with_segments:
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER
else:
segment_filter = ""
return text(
f"""
SELECT count(*)
@ -292,6 +401,33 @@ def _build_segments_sql(staleness_column: str) -> Any:
)
def _build_null_segment_sql(staleness_column: str) -> Any:
"""UPDATE строго по listing_segment IS NULL (null_segment_only=True).
НЕ переиспользует _build_segments_sql: `= ANY(CAST(:segments AS text[]))` никогда
не матчит NULL (SQL-семантика, не баг -- та же ловушка задокументирована выше у
novostroyki-гарда), поэтому NULL-сегмент не выразить через список segments и нужен
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе.
"""
return text(
f"""
WITH stale AS (
UPDATE listings
SET is_active = false
WHERE source = :listing_source
AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment IS NULL
RETURNING id, price_rub
)
{_STALE_SNAPSHOT_TAIL}
"""
)
# Дефолтные (last_seen_at) варианты SQL -- сохранены как модульные константы для
# обратной совместимости (тесты читают .text, product_handlers/scheduler не менялись).
_DEACTIVATE_SQL_ALL_SEGMENTS = _build_all_segments_sql("last_seen_at")
@ -312,6 +448,8 @@ def deactivate_stale_listings(
min_confirmations: int = 0,
health_window_days: int = _HEALTH_WINDOW_DAYS,
revisit_floor_quantile: float = 0.0,
null_segment_only: bool = False,
cap_mult: float = CAP_MULT,
) -> dict[str, int]:
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
@ -321,6 +459,7 @@ def deactivate_stale_listings(
ttl_days: количество дней TTL; объявления старше этого порога деактивируются.
segments: если задан -- деактивировать только объявления с указанными
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
Несовместимо с null_segment_only=True (см. ниже).
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
{"last_seen_at", "scraped_at"} иначе ValueError ДО любого SQL. Дефолт
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
@ -334,9 +473,25 @@ def deactivate_stale_listings(
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659).
Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой;
эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так
эффективный TTL = min(max(ttl_days, этот пол), ttl_days * cap_mult) --
пол поднимает TTL, но не выше потолка. 0 -> пол выключен (так
вызывают старые тесты и совместимая обёртка), рабочее значение
DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше.
null_segment_only: True -> WHERE фильтрует `listing_segment IS NULL` вместо
ANY(:segments). Требует segments=None (иначе ValueError -- смешивать
бессмысленно, это два непересекающихся среза). Для этого среза гейт/пол
обычно держат выключенными (min_confirmations=0, revisit_floor_quantile=0,
см. миграцию 266 и модульный докстринг) -- население слишком мало для
откалиброванных под полноценный vtorichka-свип порогов.
cap_mult: множитель потолка эффективного TTL (см. комментарий у модульной
константы CAP_MULT). Дефолт -- сама CAP_MULT=2, но параметр, а НЕ голая
константа: источник с непропорционально длинным хвостом переобхода
относительно своего ttl_days (avito: p99=42.1 при ttl=10 -> дефолтный
потолок 20 режет ниже хвоста) может переопределить его через
default_params расписания (ключ "cap_mult"), не трогая остальные
источники. Итоговый потолок = ttl_days * cap_mult. Применяется и к
null_segment_only-джобе, но там гейт/пол выключены (см. выше), так что
на практике не участвует.
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
@ -345,14 +500,58 @@ def deactivate_stale_listings(
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял
TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}.
TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол
упёрся в потолок cap_mult: дополнительно {"ttl_floor_capped": 1,
"ttl_days_floor_raw": N} -- N это то, во что пол поднял бы TTL БЕЗ потолка.
Raises:
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
никакой интерполяции пользовательского ввода в запрос).
ValueError: если staleness_column не входит в whitelist, ИЛИ ttl_days <= 0
(проверка ДО SQL, никакой интерполяции пользовательского ввода в запрос;
ttl_days<=0 в WHERE-условии last_seen_at < NOW() - INTERVAL 'N days'
матчит практически весь активный пул -- без явного guard'а потолок
(ttl_days * cap_mult <= 0) к тому же перебивал бы пол в формуле min(),
снимая защиту, которую max(ttl_days, floor) давал раньше), ИЛИ cap_mult < 1
(тот же класс дыры, но со стороны потолка, а не пола: cap_mult приходит из
jsonb default_params расписания -- ЕДИНСТВЕННЫЙ запланированный способ его
задать, т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода. cap_mult=0
даёт capped=0 -> effective_ttl_days=0 -> UPDATE снимает практически весь
активный пул источника; cap_mult<1 (например 0.5) опускает потолок НИЖЕ
заданного оператором ttl_days -- прямое нарушение инварианта «потолок не
может понизить TTL ниже настроенного», который проверяет
test_cap_never_lowers_ttl_below_configured_value), ИЛИ ttl_days/cap_mult --
bool (найдено ревью круга 3, 2026-08-15: `cap_mult < 1` пропускает `True` --
`bool` наследует `int`, `True < 1` ложно, а `ttl_days * True` == `ttl_days`,
то есть потолок = сам ttl_days и пол молча отключается, никакого ValueError.
jsonb `true`/`false` вместо числа -- ровно та опечатка в расписании, ради
которой оба guard'а вообще написаны, поэтому bool отклоняется явной
type-проверкой ДО числового сравнения для обоих параметров), ЛИБО если
заданы одновременно null_segment_only=True и segments (взаимоисключающие
срезы -- IS NULL и ANY(:segments) не композируются).
"""
counters: dict[str, int] = {"deactivated": 0}
try:
# bool -- подкласс int в Python, поэтому `True < 1` (False) и `False <= 0`
# (True) НЕ ловят опечатку `"ttl_days": true` / `"cap_mult": true` в jsonb:
# `ttl_days * True` == `ttl_days`, `cap_mult=True` даёт потолок == ttl_days и
# молча отключает пол (см. Raises выше). Проверка типа -- ДО числового
# сравнения, иначе bool проскакивает мимо него необнаруженным.
if isinstance(ttl_days, bool):
raise ValueError(f"ttl_days must be a number, not bool: {ttl_days!r}")
if ttl_days <= 0:
raise ValueError(f"ttl_days must be positive, got {ttl_days!r}")
# Тот же класс дыры, что и ttl_days<=0 выше, только со стороны потолка:
# cap_mult < 1 может опустить потолок (ttl_days * cap_mult) НИЖЕ заданного
# ttl_days, а cap_mult <= 0 -- сделать капнутый потолок <= 0 и победить пол
# в min() молча (ровно та дыра, ради которой заведён guard выше). Единственный
# запланированный способ задать cap_mult -- вписать его руками в jsonb
# default_params расписания (см. миграцию для avito), т.е. именно там опечатка
# 0 / 0.5 вместо 6 -- реальный риск, а не гипотетика.
if isinstance(cap_mult, bool):
raise ValueError(f"cap_mult must be a number, not bool: {cap_mult!r}")
if cap_mult < 1:
raise ValueError(f"cap_mult must be >= 1, got {cap_mult!r}")
# Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки
# интерполируется f-string'ом. Значения по-прежнему идут через param-binding.
# Внутри try -> невалидная колонка финализирует run как failed (mark_failed),
@ -362,6 +561,11 @@ def deactivate_stale_listings(
f"invalid staleness_column={staleness_column!r}; "
f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}"
)
# null_segment_only + segments одновременно -- неоднозначный запрос:
# IS NULL и ANY(:segments) -- разные, непересекающиеся предикаты, а не
# композиция. Явный ValueError лучше молчаливого выбора одного из двух.
if null_segment_only and segments is not None:
raise ValueError("null_segment_only=True несовместимо с заданным segments")
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
# на практике (вернуть «живость» может только повторный сбор), поэтому
@ -375,7 +579,11 @@ def deactivate_stale_listings(
health_params["segments"] = segments
confirmations = (
db.execute(
_build_confirmations_sql(staleness_column, with_segments=segments is not None),
_build_confirmations_sql(
staleness_column,
with_segments=segments is not None,
null_segment_only=null_segment_only,
),
health_params,
).scalar()
or 0
@ -390,19 +598,23 @@ def deactivate_stale_listings(
logger.warning(
"deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — "
"подтверждений за %d сут %d < порога %d "
"(segments=%r, staleness_column=%s); ни одна строка не тронута",
"(segments=%r, null_segment_only=%s, staleness_column=%s); "
"ни одна строка не тронута",
listing_source,
run_id,
health_window_days,
confirmations,
min_confirmations,
segments,
null_segment_only,
staleness_column,
)
return counters
# Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же
# срезу. Поднимает порог, никогда не опускает: max(), а не замена.
# срезу. Поднимает порог (max), но не выше потолка cap_mult * ttl_days (min) —
# см. комментарий у CAP_MULT про петлю с положительной обратной связью и про
# то, почему cap_mult -- параметр, а не голая константа.
effective_ttl_days = ttl_days
if revisit_floor_quantile > 0:
floor_params: dict[str, Any] = {
@ -413,16 +625,50 @@ def deactivate_stale_listings(
if segments is not None:
floor_params["segments"] = segments
floor_days = db.execute(
_build_revisit_floor_sql(staleness_column, with_segments=segments is not None),
_build_revisit_floor_sql(
staleness_column,
with_segments=segments is not None,
null_segment_only=null_segment_only,
),
floor_params,
).scalar()
# NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках).
# Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего.
if floor_days is not None:
counters["revisit_floor_days"] = ceil(float(floor_days))
effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
# Пол поднимает TTL (max), потолок cap_mult его не пускает выше
# ttl_days * cap_mult (min) — без этого пол растёт без ограничения
# (см. комментарий у CAP_MULT). capped_ttl_days может быть float,
# если cap_mult переопределён нецелым значением из default_params —
# effective_ttl_days приводим к int (UPDATE ждёт целые сутки).
raw_effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
capped_ttl_days = ttl_days * cap_mult
effective_ttl_days = int(min(raw_effective_ttl_days, capped_ttl_days))
counters["ttl_days_effective"] = effective_ttl_days
if effective_ttl_days > ttl_days:
if raw_effective_ttl_days > capped_ttl_days:
# Пол упёрся в потолок -- оба числа в counters (не только в логе),
# чтобы это было видно в витрине прогонов, а не только в логах.
# 1, а не True -- counters типизирован dict[str, int] (тот же
# идиом, что skipped_unhealthy выше).
counters["ttl_floor_capped"] = 1
counters["ttl_days_floor_raw"] = raw_effective_ttl_days
logger.warning(
"deactivate_stale source=%s run_id=%d TTL пол упёрся в потолок "
"cap_mult=%s: пол поднял бы TTL до %d сут, потолок ограничивает "
"заданные %d сут значением %d (квантиль %.3f, segments=%r) — "
"растущий без ограничения пол это петля с положительной обратной "
"связью, см. комментарий у CAP_MULT",
listing_source,
run_id,
cap_mult,
raw_effective_ttl_days,
ttl_days,
effective_ttl_days,
revisit_floor_quantile,
segments,
)
elif effective_ttl_days > ttl_days:
logger.warning(
"deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: "
"свип за %d сут доказал живой строку, молчавшую %d сут "
@ -439,12 +685,21 @@ def deactivate_stale_listings(
ttl_days,
)
# segments is None -> все сегменты (поведение avito). segments=[...] -> только
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
# а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник.
if segments is not None:
# null_segment_only -> IS NULL, отдельный явный предикат (ANY(:segments)
# никогда не матчит NULL). segments is None -> все сегменты (поведение avito).
# segments=[...] -> только перечисленные сегменты. Используем `is not None`
# (НЕ truthy): пустой список [] означает "ни один сегмент" (= ANY(ARRAY[])
# ничего не матчит, деактивирует 0), а НЕ "все сегменты" — иначе случайный []
# стёр бы весь источник.
if null_segment_only:
params: dict[str, Any] = {
"listing_source": listing_source,
"ttl_days": effective_ttl_days,
"run_id": run_id,
}
result = db.execute(_build_null_segment_sql(staleness_column), params)
elif segments is not None:
params = {
"listing_source": listing_source,
"ttl_days": effective_ttl_days,
"segments": segments,
@ -465,13 +720,15 @@ def deactivate_stale_listings(
runs_mod.mark_done(db, run_id, counters)
logger.info(
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
"(ttl_days=%d эффективный, задан %d, segments=%r, staleness_column=%s)",
"(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, "
"staleness_column=%s)",
listing_source,
run_id,
counters["deactivated"],
effective_ttl_days,
ttl_days,
segments,
null_segment_only,
staleness_column,
)
return counters

View file

@ -17,8 +17,9 @@ Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detai
wiring into the production scheduled orchestrator (previously only reachable manually).
Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher
per run (async context manager, source="domclick" -- dedicated residential proxy pool,
see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via
per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула;
выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800),
на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via
domclick_session.load_session(db) and threaded into every fetch_detail() call.
NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere):
@ -40,9 +41,15 @@ Exception triad differs from Avito:
Статус такого прогона 'banned' (#2674, см. runs.mark_backfill_finished):
блок это external constraint, не наш баг, но и НЕ успех раньше здесь стоял
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
No IP-rotation/cooldown recovery step exists here
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
aborted run simply retries the remaining backlog next window.
No IP-rotation/cooldown recovery step exists here -- an aborted run simply
retries the remaining backlog next window.
УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated
residential proxy, not a rotating pool». Это перестало быть правдой на миграции
253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть
отсутствие ротации больше НЕ следует из «ротировать нечего» это просто
непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой
комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по
длительности до блока 111-332 с, получает свой бюджет).
ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch

View file

@ -1,56 +1,68 @@
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода).
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846).
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
публикации так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток
(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по
построению: `period_month` метка ПЕРВОГО числа месяца, поэтому на закрытии месяца
возрасту уже 30; плюс собственный лаг публикации источника. За 31 сутки прямых
наблюдений монитора (07-13 08-12, scrape_runs.counters) возраст лежал в 46..76 и
НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени ноль бит.
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
застой сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor)
писал ERROR с самого начала расходилась только эта джоба.
Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор
мерил не источник, а нашу же пилу. Миграция 212 (такт 28 7) обещала потолок
возраста 46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне
загрузки шестидневной давности (08-06, errors=0, upserted=639) источник просто не
опубликовал июль. Двенадцать суток подряд (08-01 08-12) монитор писал ERROR при
исправной загрузке. Потолок 53 держится, только если источник публикует строго
помесячно; он не публикует.
ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов
монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка это была ПИЛА нашего
собственного такта загрузки:
13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65
17.07 alert=0 age 46 latest=июнь (день загрузки)
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же
считается от ПЕРВОГО числа покрытого месяца пол ~46 в момент загрузки, потолок
46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать
такое до ERROR без починки такта значило бы завести ежедневное ложное событие на
две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ
такт: потолок возраста пол+7 53 при пороге 60, тревога снова означает
«источник/загрузка встали», а не «мы давно не ходили».
ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду
нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН
максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» это вопрос
«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода:
Порог алерта (документирование выбора):
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
Монитор: age > sber_index_max_age_days + lag_allowance.
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) запас на ИНХЕРЕНТНЫЙ лаг
публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month лейбл
ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
~46..53 до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
рабочего диапазона если такт загрузки будут менять, пересчитай потолок
(пол + interval_days) и сверь с 60.
последний полный прогон свежий наш max == max источника источник не публиковал,
молчание ПРАВИЛЬНОЕ (возраст = лаг источника);
последний полный прогон старый мы не забрали тревога про ЗАГРУЗЧИК.
Задача синхронная (DB-only, один SELECT max(period_month)) запускается
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
ЛОВУШКА: `status='done'` НЕ означает успех прогон id=37 (2026-05-31) имеет
{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий
3 табло × 3 региона прошли: errors счётчик по всему прогону).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) ERROR-записи
достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего
оценивать).
ПОРОГ не круглое число, а такт самой загрузки: `scrape_schedules.default_params
.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик
запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с
тактом порог не может: поменяли такт порог поехал следом. Тревога после
MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети)
поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14
суток; на прод-истории такое состояние ДОСТИЖИМО разрывы между полными прогонами
были 14.8 и 20 суток (05-3106-15 и 07-1708-06).
ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из
estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у
dinamika-tsen-obyavlenii 2026-05 (на 2026-08-12). max() по таблице маскирует
отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же
серию список импортируется из estimator, дублировать его тут нельзя.
ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а
загрузка останется исправной монитор промолчит: по нашим данным «источник не
публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт
публикации источника ретроспективно невосстановим его затёр апсерт
(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at
не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период»,
т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
имеет смысл после 3 наблюдённых публикаций (ориентир ноябрь 2026).
ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
Задача синхронная (DB-only) запускается kit-scheduler'ом через
product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает
ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed
только если у оценщика вообще нет серии (нечего оценивать).
"""
from __future__ import annotations
@ -62,153 +74,239 @@ from datetime import UTC, date, datetime
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.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_LAG_ALLOWANCE_DAYS",
"DEFAULT_PULL_INTERVAL_DAYS",
"MISSED_PULL_CYCLES",
"SBER_FRESHNESS_PULL_SOURCE",
"SberFreshnessVerdict",
"check_sber_freshness",
"evaluate_sber_freshness",
]
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
DEFAULT_LAG_ALLOWANCE_DAYS = 25
# Джоба-загрузчик, чей такт и успешность мы и мониторим.
SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull"
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
# sber_price_index для областного уровня.
SBER_MONITOR_CITY = "Свердловская область"
# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг
# окна (поглощаем), 2 = загрузка встала (алерт).
MISSED_PULL_CYCLES = 2
_LATEST_SBER_PERIOD_SQL = text("""
# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7).
DEFAULT_PULL_INTERVAL_DAYS = 7
_LATEST_PERIOD_SQL = text("""
SELECT max(period_month) AS latest
FROM sber_price_index
WHERE city = CAST(:city AS text)
AND dashboard = CAST(:dash AS text)
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
-- estimator._load_sber_index_series.
AND (segment IS NULL OR segment ILIKE '%вторичн%')
""")
# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно:
# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без
# CAST(... AS int): counters других источников планировщик может отфильтровать позже
# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел
# оператор > численный.
_LAST_COMPLETE_PULL_SQL = text("""
SELECT max(finished_at) AS last_pull
FROM scrape_runs
WHERE source = CAST(:src AS text)
AND counters @> CAST('{"errors": 0}' AS jsonb)
AND counters -> 'upserted' > CAST('0' AS jsonb)
""")
# Такт загрузки — из той же строки, по которой планировщик считает next_run_at.
_PULL_INTERVAL_SQL = text("""
SELECT default_params ->> 'interval_days' AS interval_days
FROM scrape_schedules
WHERE source = CAST(:src AS text)
""")
@dataclass(frozen=True)
class SberFreshnessVerdict:
"""Вердикт свежести СберИндекса по max(period_month)."""
"""Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта."""
latest_period: date
age_days: int
age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги
pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было
max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки
stale: bool
def evaluate_sber_freshness(
latest_period: date,
now: datetime,
max_age_days: int,
*,
last_complete_pull_at: datetime | None,
pull_interval_days: int,
) -> SberFreshnessVerdict:
"""Чистая логика: устарел ли latest период СберИндекса.
"""Чистая логика: отстала ли загрузка от собственного такта.
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
`max_age_days` ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
stale = полных прогонов не было ВООБЩЕ, либо последний старше
MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в
вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш
max(period_month) равен максимуму источника по построению, и его возраст это
лаг ПУБЛИКАЦИИ, на который мы повлиять не можем.
"""
age_days = (now.date() - latest_period).days
stale = age_days > max_age_days
max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days
if last_complete_pull_at is None:
return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True)
pull_lag_days = (now - last_complete_pull_at).days
return SberFreshnessVerdict(
latest_period=latest_period,
age_days=age_days,
stale=stale,
pull_lag_days=pull_lag_days,
max_pull_lag_days=max_pull_lag_days,
stale=pull_lag_days > max_pull_lag_days,
)
def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None:
"""Табло, которое возьмёт оценщик, и его latest период.
Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
"""
for dash in SBER_COEFF_DASHBOARDS:
row = db.execute(
_LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash}
).first()
latest = row.latest if row is not None else None
if latest is not None:
return dash, latest
return None
def _pull_interval_days(db: Session) -> int:
"""Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS)."""
row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first()
raw = row.interval_days if row is not None else None
try:
return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS
except (TypeError, ValueError):
logger.warning(
"sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d",
raw,
DEFAULT_PULL_INTERVAL_DAYS,
)
return DEFAULT_PULL_INTERVAL_DAYS
def check_sber_freshness(
db: Session,
run_id: int,
params: dict | None = None, # type: ignore[type-arg]
now: datetime | None = None,
) -> dict[str, int]:
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
"""Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании.
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
и финализирует run.
Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона
sber_index_pull, такт загрузки из scrape_schedules. Вердикт чистой функцией.
Params (default_params jsonb):
lag_allowance_days: int запас сверх sber_index_max_age_days (default 25).
`now` инъектируется в тестах (frozen); в проде None datetime.now(UTC).
`params` больше ничего не настраивает: порог берётся из такта самой загрузки
(унаследованный default_params.lag_allowance_days=25 монитора игнорируется
он кодировал мёртвый календарный порог). `now` инъектируется в тестах.
Returns counters {latest_year, latest_month, age_days, alert}.
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
Returns counters {latest_year, latest_month, age_days, pull_lag_days,
max_pull_lag_days, alert}.
mark_failed только если у оценщика нет серии вообще (нечего оценивать);
при алерте прогон помечается done (это монитор, не сбой джобы).
"""
params = params or {}
now = now or datetime.now(UTC)
counters: dict[str, int] = {
"latest_year": 0,
"latest_month": 0,
"age_days": 0,
"pull_lag_days": -1,
"max_pull_lag_days": 0,
"alert": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
latest: date | None = row.latest if row is not None else None
if latest is None:
found = _load_estimator_dashboard(db)
if found is None:
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
logger.error(
"sber freshness: sber_price_index пуст/недоступен для region=%s "
"(вторичка) — оценить свежесть нельзя",
SBER_MONITOR_CITY,
"sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
"для region=%s (вторичка); оценить нечего",
list(SBER_COEFF_DASHBOARDS),
SBER_TIME_ADJUST_REGION,
)
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
return counters
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
max_age_days = settings.sber_index_max_age_days + lag_days
verdict = evaluate_sber_freshness(latest, now, max_age_days)
dashboard, latest = found
last_pull_row = db.execute(
_LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
).first()
last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None
verdict = evaluate_sber_freshness(
latest,
now,
last_complete_pull_at=last_complete_pull_at,
pull_interval_days=_pull_interval_days(db),
)
counters = {
"latest_year": latest.year,
"latest_month": latest.month,
"age_days": verdict.age_days,
"pull_lag_days": verdict.pull_lag_days,
"max_pull_lag_days": verdict.max_pull_lag_days,
"alert": int(verdict.stale),
}
if verdict.stale:
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
logger.error(
"sber freshness: max(period_month)=%s устарел на %d дней "
"(> порога %d = sber_index_max_age_days %d + lag %d); "
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
"проверь sber_index_pull и доступность новых периодов источника",
"sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон "
"%s (%s суток назад, порог %d = %d такта × %d суток; "
"status='done' с errors>0 за успех НЕ считается). "
"Наш max(period_month)=%s (табло %s) мог разойтись с источником — "
"проверь sber_index_pull: планировщик, сеть, /api/sowa 404",
last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ",
verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "",
verdict.max_pull_lag_days,
MISSED_PULL_CYCLES,
verdict.max_pull_lag_days // MISSED_PULL_CYCLES,
latest,
verdict.age_days,
max_age_days,
settings.sber_index_max_age_days,
lag_days,
dashboard,
)
else:
logger.info(
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
"region=%s — алерта нет",
"sber freshness: загрузка в такте — последний полный прогон %d суток назад "
"(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен "
"максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, "
"не наше отставание — алерта нет",
verdict.pull_lag_days,
verdict.max_pull_lag_days,
latest,
dashboard,
verdict.age_days,
max_age_days,
SBER_MONITOR_CITY,
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
"check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d "
"pull_lag_days=%d age_days=%d",
run_id,
latest,
dashboard,
counters["alert"],
counters["pull_lag_days"],
counters["age_days"],
)
return counters

View file

@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
такие URL регуляркой ДО сети это не капча, а предрешённый parseNone. Идут они
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
Но «не по тому URL» «нечего обогащать» (разобрано 2026-08-12, см. комментарий
у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по
собранному из него каноническому URL страница отдаётся и парсится. Поэтому в
очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены:
url_from_offer_id сколько ждёт починки адреса, unenrichable_pending сколько
не адресуемо вообще (ни offer-URL, ни числового source_id).
Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
@ -51,6 +57,8 @@ from app.services.proxy_egress import resolve_proxy_url
logger = logging.getLogger(__name__)
__all__ = [
"CANONICAL_URL_SQL",
"OFFER_ID_PATTERN",
"OFFER_URL_PATTERN",
"YandexDetailBackfillResult",
"run_yandex_detail_backfill",
@ -63,8 +71,7 @@ __all__ = [
#
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
#
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
@ -72,6 +79,39 @@ __all__ = [
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
OFFER_URL_PATTERN = "/offer/[0-9]+"
# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ────────────────
# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на
# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде:
#
# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная;
# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк,
# вставленных после самой свежей его строки, id 2583989), и выйти из него
# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое
# множество и обязано быть константой — вопрос был не «почему не растёт», а
# «правда ли они непригодны».
#
# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId
# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из
# него собирается — это инвариант #2235 (`_canonical_source_url` в
# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
#
# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ
# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной
# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес,
# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки.
#
# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой
# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией:
# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят
# на сайты застройщиков вместо Яндекса.
OFFER_ID_PATTERN = "^[0-9]+$"
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
@dataclass
class YandexDetailBackfillResult:
@ -80,6 +120,11 @@ class YandexDetailBackfillResult:
attempted: int = 0
enriched: int = 0
failed: int = 0
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
# прогону; замер на месте = очередь снова читается не тем признаком.
url_from_offer_id: int = 0
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
unenrichable_pending: int = 0
duration_sec: float = field(default=0.0)
@ -88,6 +133,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"failed": self.failed,
"url_from_offer_id": self.url_from_offer_id,
"unenrichable_pending": self.unenrichable_pending,
"duration_sec": int(self.duration_sec),
}
@ -132,51 +178,86 @@ async def run_yandex_detail_backfill(
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
# (см. комментарий у константы): в очередь не берём то, что заведомо
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
# В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо
# сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес
# собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире
# этого условия — гарантированный parse→None пачкой и обрыв по брейкеру.
snapshot = (
db.execute(
text(
"""
SELECT id, source_url
f"""
SELECT id,
CASE
WHEN source_url ~ CAST(:offer_url_pattern AS text)
THEN source_url
ELSE {CANONICAL_URL_SQL}
END AS source_url
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url ~ CAST(:offer_url_pattern AS text)
AND (
(
source_url IS NOT NULL
AND source_url ~ CAST(:offer_url_pattern AS text)
)
OR source_id ~ CAST(:offer_id_pattern AS text)
)
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
# f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля,
# не пользовательский ввод. Всё изменяемое — bind-параметры.
),
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
{
"batch_size": batch_size,
"offer_url_pattern": OFFER_URL_PATTERN,
"offer_id_pattern": OFFER_ID_PATTERN,
},
)
.mappings()
.all()
)
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
counters.unenrichable_pending = int(
db.execute(
text(
"""
SELECT count(*)
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url !~ CAST(:offer_url_pattern AS text)
"""
),
{"offer_url_pattern": OFFER_URL_PATTERN},
).scalar_one()
)
if counters.unenrichable_pending:
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
# разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
# «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
pending = db.execute(
text(
"""
SELECT
count(*) FILTER (
WHERE source_id ~ CAST(:offer_id_pattern AS text)
) AS url_from_offer_id,
count(*) FILTER (
WHERE source_id IS NULL
OR source_id !~ CAST(:offer_id_pattern AS text)
) AS unenrichable_pending
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND (
source_url IS NULL
OR source_url !~ CAST(:offer_url_pattern AS text)
)
"""
),
{"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN},
).one()
counters.url_from_offer_id = int(pending.url_from_offer_id)
counters.unenrichable_pending = int(pending.unenrichable_pending)
if counters.url_from_offer_id:
logger.info(
"yandex_detail_backfill: run_id=%d%d объявлений вне очереди: "
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
"до сети",
"yandex_detail_backfill: run_id=%dу %d объявлений сохранённый "
"source_url не ведёт на карточку Яндекса; адресуем их по offerId из "
"source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)",
run_id,
counters.url_from_offer_id,
)
if counters.unenrichable_pending:
logger.warning(
"yandex_detail_backfill: run_id=%d%d объявлений вне очереди: нет ни "
"offer-URL (%s), ни числового source_id — адресовать их нечем",
run_id,
counters.unenrichable_pending,
OFFER_URL_PATTERN,

View file

@ -58,12 +58,17 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_pii_event,
)
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
"""Композиция PII-scrub (form-данные) + Telegram bot-токен redaction
(#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые
`include_local_variables=False` ниже и этот хук закрывают вместе:
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
независимыми векторами, которые `include_local_variables=False` ниже и
этот хук закрывают вместе:
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
locals (`self._base`/`url` в `TelegramClient._request`) в traceback
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
@ -72,8 +77,16 @@ if settings.glitchtip_dsn:
перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор
belt-and-suspenders на случай #1 (если include_local_variables
случайно вернут) И на span data.
Платёжный body-wipe belt-and-suspenders: этот процесс не держит ASGI-
приложения (нет `request` в event сегодня), но тот же обработчик передан
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
"""
scrubbed = scrub_pii_event(event, hint)
scrubbed = scrub_payment_request_body(event, hint)
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint)
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint)
@ -86,6 +99,7 @@ if settings.glitchtip_dsn:
send_default_pii=False,
include_local_variables=False,
before_send=_before_send,
before_send_transaction=_before_send,
integrations=[
HttpxIntegration(),
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),

View file

@ -0,0 +1,150 @@
-- 257_listings_backfill_yandex_source_url.sql
-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838).
--
-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`,
-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235,
-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки,
-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared`
-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку
-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки.
-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/`
-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются
-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты.
-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция.
--
-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»):
--
-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк
-- ------------------------------------------------------- -----
-- всего 3535
-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535
-- из них source_id NULL/нечисловой (нечем адресовать) 0
-- из них source_url IS NULL 0
-- из них is_active 3522
--
-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356,
-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167,
-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114,
-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост.
--
-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем):
-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк,
-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет.
-- Множество может только уменьшаться (удаление строк), не расти.
--
-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь.
-- scraper_kit/providers/yandex/serp.py::_canonical_source_url:
-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex»
-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL
-- "'https://realty.yandex.ru/offer/' || source_id || '/'"
-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно;
-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление
-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою
-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py).
-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+')
-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки.
--
-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО:
-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0;
-- * два кандидата с одинаковым новым URL внутри самого множества: 0
-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql);
-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0.
--
-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже):
-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0
-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0
-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0
--
-- было → стало (10 строк, взяты по id DESC):
-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/
-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/
-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/
-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/
-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/
-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/
-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/
-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/
-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/
-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/
-- (префикс «стало» везде один: https://realty.yandex.ru/offer/<source_id>/)
-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же
-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK.
--
-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено).
-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был
-- raw_payload->>'address') здесь НЕ работает:
-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там
-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type,
-- site_name — адреса нет ни под одним именем;
-- * listings.house_url / newbuilding_url у всех 3535 = NULL;
-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE
-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить
-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не
-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его
-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки,
-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика
-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе
-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес).
-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат:
--
-- UPDATE listings l
-- SET source_url = b.old_source_url
-- FROM yandex_source_url_backfill_257 b
-- WHERE l.id = b.listing_id
-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/';
--
-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал
-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая
-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто
-- удалить — на приложение она не влияет, читателей у неё нет.
--
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py —
-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там
-- COALESCE в апсерте так же намеренно не трогали);
-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по
-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая
-- история того, что отдал gate-API;
-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай»
-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт);
-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка.
--
-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql
-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула).
-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL →
-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT
-- DO NOTHING делают повтор безопасным и при частичном откате.
-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py
-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и
-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед
-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 (
listing_id bigint PRIMARY KEY,
old_source_url text NOT NULL,
changed_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE yandex_source_url_backfill_257 IS
'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). '
'Только для отката; читателей в приложении нет, удаляется без последствий.';
INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url)
SELECT id, source_url
FROM listings
WHERE source = 'yandex'
AND source_url !~ '/offer/[0-9]+'
AND source_id ~ '^[0-9]+$'
ON CONFLICT (listing_id) DO NOTHING;
UPDATE listings
SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/'
WHERE source = 'yandex'
AND source_url !~ '/offer/[0-9]+'
AND source_id ~ '^[0-9]+$';
COMMIT;

View file

@ -0,0 +1,41 @@
-- 258_houses_imv_transient_attempts.sql
-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
--
-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
-- 'pending'), и за всю историю (41 прогон, 26.0611.08) ни один не был запущен
-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из
-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All
-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой
-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе
-- 108 из 108 POST /fetch-json = 200).
--
-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически
-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из
-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт
-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд.
--
-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error',
-- прежние разрезы по imv_status/imv_error_reason работают как работали, а
-- «застряли окончательно» — это
-- SELECT count(*) FROM houses
-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3;
--
-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по
-- imv_status IN ('pending','transient_error') с сортировкой по
-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на
-- выборке в тысячи строк.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE houses
ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0;
COMMENT ON COLUMN houses.imv_transient_attempts IS
'Сколько раз подряд домовая IMV-оценка падала в transient_error. '
'Растёт только на transient_error, обнуляется успехом. '
'>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).';
COMMIT;

View file

@ -0,0 +1,145 @@
-- 259_data_quality_drop_pct_cadastr.sql
-- Purpose (#2674, третий показатель того же класса): убрать v_data_quality.pct_cadastr.
--
-- 214 убрала outliers_flagged, 216 — price_disagreements_count по одному доводу: ноль,
-- гарантированный устройством системы, читается как «проверили — чисто», хотя честно он
-- означает «мы это не считаем». pct_cadastr — третий такой же, поэтому и действие то же:
-- не переключать источник, а снять показатель.
--
-- ── ЧИСЛА С ПРОДА (2026-08-13, точный count) ────────────────────────────────
-- v_data_quality.pct_cadastr .................... 0.000000000000000000000000
-- знаменатель витрины (listings_active) ......... 45 198 (в listings всего 99 304)
-- listings.cadastral_number IS NOT NULL ......... 0 из 99 304 (и 0 из 45 198 активных)
-- deals.cadastral_number ........................ 0 из 96 974
-- houses.cadastral_number (DaData) .............. 2 648 из 9 468 ← ДРУГОЙ объект
-- listings.building_cadastral_number ............ 30 970 из 99 304 ← ДРУГОЙ объект
--
-- ── ЭТО НЕ ДЕФЕКТ ИЗМЕРИТЕЛЯ (контроль на здоровом образце в тех же данных) ──
-- Тот же CTE active_listings и тот же шаблон `count(*) WHERE <col> IS NOT NULL * 100.0
-- / NULLIF(count(*), 0)` в соседних строках витрины даёт 95.61% (pct_geocoded), 39.82%
-- (pct_description), 65.10% (pct_year_built). Ровно 0% — про колонку, а не про арифметику.
--
-- ── ПОЧЕМУ НОЛЬ СТРУКТУРНЫЙ ─────────────────────────────────────────────────
-- listings.cadastral_number — кадастр КВАРТИРЫ. Единственное место в коде, которое его
-- вообще читает, — providers/cian/serp.py:886 (`offer.get("cadastralNumber")`); в парсерах
-- avito/yandex/domclick/n1 слов cadastr/kadastr нет ни разу, то есть для ЧЕТЫРЁХ площадок
-- из пяти ноль гарантирован НАШИМ кодом и о предметной области не говорит ничего. Пусто
-- при этом везде, где мы этот номер храним (три таблицы выше) — то же уже записано в
-- app/services/matching/houses.py: «площадки кадастр не отдают».
--
-- ── ПОЧЕМУ НЕЛЬЗЯ «ПОЧИНИТЬ ОДНОЙ СТРОКОЙ», ПЕРЕКЛЮЧИВ НА СОСЕДНЮЮ КОЛОНКУ ──
-- Напрашивается считать по listings.building_cadastral_number (31.19% всего, 29.47% у
-- активных). Под подписью «доля объявлений с кадастром» это НОВАЯ ложь вместо старой:
-- * это кадастр ЗДАНИЯ, и в listings у него РОВНО ОДИН писатель — наш ночной KNN ≤50 м
-- по локальному зеркалу ЕГРН (tasks/cadastral_geo_match.py:161; проверено `git grep`
-- по origin/main: других INSERT/UPDATE этой колонки нет). Он не «тот же кадастр из
-- другого места», а наша производная;
-- * #2674 замерил ключ как неинъективный (656 из 3 260 значений накрывают >1 здание ГАР,
-- 20.1%; 751 из 2 864 зданий получают >1 значение, 26.2%) и прямо запретил считать его
-- идентичностью здания;
-- * разброс по площадкам среди активных геокодированных (cian 33.8%, yandex 20.3%,
-- avito 42.0%, domclick 49.7%) — про точность НАШИХ координат и охват зеркала по ЕКБ,
-- а не про качество объявления.
-- Переименовать подпись мало: честное имя было бы «доля объявлений, которым ночной KNN
-- подобрал здание в 50 м» — это другой показатель, и заводить его надо отдельно и
-- осознанно, а не под видом починки этого. Авторитетный кадастр здания у нас есть —
-- houses.cadastral_number из DaData (2 648/9 468 домов), но он про ДОМА, а витрина считает
-- ОБЪЯВЛЕНИЯ; подставить его в эту строку — снова назвать одно другим.
--
-- ── ЦЕНА ПРАВКИ ────────────────────────────────────────────────────────────
-- Читателей у витрины в коде нет (grep по /app/app в живом backend-контейнере пуст;
-- /api/v1/admin/scraper/data-quality считает свои метрики сам и кадастр не показывает
-- вовсе) — это ручной psql-снимок. Зависимых объектов у view тоже нет (pg_depend по
-- 'v_data_quality'::regclass, прод 13.08: 0 строк), поэтому CASCADE не нужен и не должен
-- появиться: в этом продукте `DROP ... CASCADE` уже терял гранты FDW-пользователю (C3).
--
-- ── ПОРЯДОК И БЛОКИРОВКА ───────────────────────────────────────────────────
-- CREATE OR REPLACE VIEW колонку УДАЛИТЬ не может → DROP VIEW → CREATE VIEW (тот же
-- порядок, что 214/216). DROP VIEW берёт ACCESS EXCLUSIVE, поэтому `SET LOCAL
-- lock_timeout` (см. scripts/check-migration-lock-timeout.py). В отличие от 222, которая
-- обошлась CREATE OR REPLACE, здесь COMMENT ON VIEW надо выставить ЗАНОВО: DROP уносит
-- комментарий вместе с объектом.
--
-- Тело SELECT скопировано из 222_db_audit_cleanup.sql (последний DDL; сверено с живым
-- pg_get_viewdef на проде 13.08 — совпадает) минус строка pct_cadastr. Из CTE убран
-- ставший ненужным cadastral_number: 222 завела явный список колонок ровно затем, чтобы
-- view не держал column-level зависимость на то, чего не показывает.
--
-- Dependencies: 216_dead_code_sweep.sql (текст COMMENT ON VIEW), 222_db_audit_cleanup.sql
-- (последний DDL v_data_quality).
-- Apply after: 258_houses_imv_transient_attempts.sql
-- Идемпотентно: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT — повторный прогон даёт тот
-- же результат.
BEGIN;
-- Ждём лок не дольше 5 s: сам DROP мгновенный, но ждущий ACCESS EXCLUSIVE встаёт в
-- очередь ПЕРЕД новыми запросами (#2791/#2792).
SET LOCAL lock_timeout = '5s';
DROP VIEW IF EXISTS v_data_quality;
-- DDL идентичен 222, минус строка pct_cadastr и минус cadastral_number в CTE.
CREATE VIEW v_data_quality AS
WITH active_listings AS (
SELECT id, lat, description, house_id_fk, is_active
FROM listings
WHERE is_active = true
)
SELECT
(SELECT count(*) FROM houses) AS houses_total,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
) sub) AS houses_2plus_sources,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
) sub) AS houses_3plus_sources,
(SELECT count(*) FROM active_listings) AS listings_active,
(SELECT count(*) FROM (
SELECT listing_id FROM listing_sources
WHERE listing_id IN (SELECT id FROM active_listings)
GROUP BY listing_id HAVING count(*) >= 2
) sub) AS listings_dedup_2sources,
(SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
(SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
(SELECT count(*) FROM active_listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.year_built IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago;
-- Текст 216 + абзац про pct_cadastr. Выставляем заново, потому что DROP VIEW выше унёс
-- прежний комментарий вместе с объектом.
COMMENT ON VIEW v_data_quality IS
'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): '
'/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. '
'#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один '
'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как '
'«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен '
'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), '
'ноль в нём — честный ответ, а не мнимое благополучие. '
'#2674 (мигр. 259): pct_cadastr убран по тому же доводу — считал '
'listings.cadastral_number (кадастр КВАРТИРЫ), а его не отдаёт ни одна площадка: '
'0 из 99 304 объявлений, 0 из 96 974 deals, единственный читающий его парсер — '
'cian/serp.py. Показатель НЕ переведён на listings.building_cadastral_number: та '
'колонка — кадастр ЗДАНИЯ и на 100% производная нашего ночного KNN ≤50 м '
'(tasks/cadastral_geo_match.py), неинъективного как ключ здания (#2674: 20.1% '
'значений накрывают >1 здание ГАР); под подписью «доля объявлений с кадастром» она '
'мерила бы покрытие нашего геокодера, а не качество объявлений.';
COMMIT;

View file

@ -0,0 +1,179 @@
-- 260_houses_drop_has_panorama.sql
-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области.
--
-- Dependencies: 031_houses_alter_yandex.sql (завела колонку),
-- 154_market_contract_views.sql (внесла её в публичный контракт
-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант
-- gendesign_reader на этот view).
-- Apply after: 258_houses_imv_transient_attempts.sql
-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama),
-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов
-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок
-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке
-- yandex_valuation — молча проглоченный, но с WARNING в логах.
--
-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ──────────────────────────────────────
-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса.
-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.0512.08.2026:
-- страниц ............................................................. 1536
-- has_panorama = true .................................................... 0
-- has_panorama = false ................................................ 1536
-- ключ отсутствует ....................................................... 0
-- houses: 9468 строк, has_panorama непустых 12, из них true 0.
--
-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом
-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же
-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200:
-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель,
-- 2,50 м потолки, 46 объектов
-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт.
-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта
-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama /
-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную
-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице:
-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт ·
-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») —
-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме.
--
-- Ноль был механически гарантирован самим кодом и о предметной области не говорил
-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится
-- удалением, а не починкой разбора.
--
-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только
-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не
-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной
-- истории её тоже не было.
--
-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ────────────────────────────────
-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a
-- column later is backward compatible, renaming/removing one is not». Это осознанное
-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не
-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч.
-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель
-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и
-- никогда не станет ничем другим, — обещать данные, которых не существует.
--
-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся:
-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что
-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный
-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что
-- появится позже, без единого слова в логе.
--
-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE).
-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он
-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки
-- внешний ETL получил бы permission denied на следующем же прогоне.
--
-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ──────────────────────────
-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не
-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в
-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий
-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать
-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут
-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено
-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание =
-- честный красный деплой через 5 секунд, миграция не помечается применённой.
--
-- IDEMPOTENCY / SAFETY:
-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP —
-- безопасный re-run.
-- - Без CASCADE.
-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean),
-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false.
--
-- Критерий приёмки (записан ДО применения):
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
-- 2. information_schema.columns по houses: has_panorama отсутствует.
-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на
-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк.
-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на
-- market.v_houses.
BEGIN;
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
-- и в .claude/rules/sql.md § lock_timeout.
SET LOCAL lock_timeout = '5s';
DROP VIEW IF EXISTS market.v_houses;
ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama;
-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна
-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *.
CREATE VIEW market.v_houses AS
SELECT
id,
source,
ext_house_id,
url,
slug,
address,
full_address,
short_address,
lat,
lon,
geom,
year_built,
house_type,
house_class,
material_walls,
material_floors,
series_name,
total_floors,
total_units,
entrances,
flat_count,
is_emergency,
passenger_elevators,
cargo_elevators,
has_concierge,
closed_yard,
has_playground,
hot_water,
heat_supply_type,
gas_supply_type,
overlap_type,
parking_type,
infrastructure_summary,
infrastructure_walk_distance,
developer_name,
developer_key,
management_company_id,
rating,
reviews_count,
rating_score,
rating_string,
transport_accessibility_rate,
advantages,
banks,
builders,
houses_by_turn,
corpus_count,
commission_year,
commission_month,
total_area_ha,
cadastral_number,
house_fias_id,
yandex_jk_id,
yandex_jk_slug,
cian_internal_house_id,
cian_zhk_url,
raw_payload,
first_seen_at,
last_scraped_at
FROM public.houses;
COMMENT ON VIEW market.v_houses IS
'Stable public contract over public.houses (#2130). Explicit column list is the '
'stability promise — do not SELECT * against the base table from external '
'consumers. raw_payload is included because it is read today by gendesign ETL '
'#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at '
'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama '
'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно '
'(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).';
-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155.
GRANT SELECT ON market.v_houses TO gendesign_reader;
COMMIT;

View file

@ -0,0 +1,270 @@
-- 261_listings_search_mv_drop_placeholder_columns.sql
-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv:
-- distance_to_metro_m, last_price_change, photos_count.
--
-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов),
-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст
-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 —
-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql
-- (суточный REFRESH ... CONCURRENTLY).
-- Apply after: 260_houses_drop_has_panorama.sql
-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому
-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен.
--
-- ── ЧТО ЗА НОЛЬ ────────────────────────────────────────────────────────────
-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении
-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ:
-- имена зарезервировали в 050, реализацию не подключили никогда.
--
-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк):
-- null_frac = 1.0 у 5 колонок: cadastral_number, district,
-- distance_to_metro_m, last_price_change, photos_count.
-- Сносим три. После применения колонок с null_frac = 1.0 останется 2
-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают,
-- см. 216/search_query.py; district — вынесен решением владельца, ниже).
--
-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти:
-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main`
-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним).
-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки
-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории
-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный
-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет.
--
-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет
-- services/search_query.py:138 и объявляет schemas/search_response.py:44
-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее
-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857.
-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`).
--
-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ───────────────────────────────────────
-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW
-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает.
-- Единственный путь — пересоздание, как в 094.
--
-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite
-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и
-- деплой честно покраснеет; CASCADE снёс бы её молча.
--
-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ─────────────
-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после
-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную).
-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не
-- предположено:
-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного
-- стороннего grantee; column-level грантов нет; pg_default_acl пуст.
-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не
-- доказывает: information_schema не показывает материализованные представления
-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко
-- проглядеть.)
-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history —
-- на витрину ему не давали.
-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием
-- файла и его применением на проде может пройти неделя, и ручной слепок к тому
-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому
-- врать не может.
--
-- ── ИНДЕКСЫ ────────────────────────────────────────────────────────────────
-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один).
-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный
-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py,
-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с
-- «cannot refresh materialized view concurrently ... no unique index».
--
-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ─────────────────────────────────────────
-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели
-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026):
-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm
-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s
-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в
-- 9-17 s, но он делает вдвое больше работы (строит + сливает).
-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset
-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и
-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день.
-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным
-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен.
--
-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под
-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в
-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой
-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY →
-- честный красный деплой через 5 s, миграция не помечается применённой, повторный
-- деплой пройдёт.
--
-- IDEMPOTENCY / SAFETY:
-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому
-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо
-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094).
-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources.
-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же
-- способом. Восстанавливать нечего — значений не существовало.
--
-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения):
-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в
-- _schema_migrations (а не «деплой зелёный»).
-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change /
-- photos_count отсутствуют; district на месте, тип text.
-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'.
-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx.
-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец
-- и никого больше).
-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк.
-- 7. Следующий ночной refresh_search_matview завершается status='done'
-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс).
-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит
-- удалённых — их там и не было).
BEGIN;
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке.
SET LOCAL lock_timeout = '5s';
-- ── 1. Снимок ACL ДО сноса ─────────────────────────────────────────────────
-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст.
-- Владельца исключаем: CREATE вернёт его права сам.
-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут
-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном
-- на одноразовой БД, а не рассуждением.
CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS
SELECT
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee,
a.privilege_type,
a.is_grantable,
NULL::text AS column_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
CROSS JOIN LATERAL aclexplode(c.relacl) AS a
WHERE n.nspname = 'public'
AND c.relname = 'listings_search_mv'
AND c.relkind = 'm'
AND a.grantee <> c.relowner
UNION ALL
SELECT
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END,
a.privilege_type,
a.is_grantable,
quote_ident(att.attname)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped
CROSS JOIN LATERAL aclexplode(att.attacl) AS a
WHERE n.nspname = 'public'
AND c.relname = 'listings_search_mv'
AND c.relkind = 'm'
AND a.grantee <> c.relowner;
-- ── 2. Пересоздание витрины без трёх заглушек ──────────────────────────────
DROP MATERIALIZED VIEW IF EXISTS listings_search_mv;
CREATE MATERIALIZED VIEW listings_search_mv AS
SELECT
l.id AS listing_id,
l.source,
l.source_url,
l.address,
l.geom,
l.lat,
l.lon AS lng,
l.rooms,
l.area_m2 AS total_area,
l.floor,
l.total_floors,
l.price_rub,
l.price_per_m2,
l.cadastral_number,
l.is_active,
l.scraped_at,
-- House denorm
h.id AS house_id,
h.year_built,
h.house_class,
h.developer_name,
h.rating AS house_rating,
h.reviews_count AS house_ratings_count,
-- Cross-source aggregates
(SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count,
(SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources,
(SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito,
(SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian,
(SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex,
-- Price percentile within house
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2)
FROM listings ll
WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2,
-- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API
-- (schemas/search_response.py), снос — ломающее изменение контракта, решение
-- владельца (#2857). Соседние distance_to_metro_m / last_price_change /
-- photos_count сняты здесь: у них не было ни одного читателя.
NULL::text AS district,
-- Trigram-ready columns
l.address AS address_trgm,
-- Aggregated tsv (description + address + developer_name)
to_tsvector('russian',
coalesce(l.description, '') || ' ' ||
coalesce(l.address, '') || ' ' ||
coalesce(h.developer_name, '')
) AS tsv
FROM listings l
LEFT JOIN houses h ON h.id = l.house_id_fk
WHERE l.is_active = true
AND COALESCE(l.canonical, true) = true;
-- ── 3. Те же 6 индексов (050/094) ──────────────────────────────────────────
-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку.
CREATE UNIQUE INDEX listings_search_mv_id_idx
ON listings_search_mv (listing_id);
CREATE INDEX listings_search_mv_geom_idx
ON listings_search_mv USING GIST (geom);
CREATE INDEX listings_search_mv_filters_idx
ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC);
CREATE INDEX listings_search_mv_address_trgm_idx
ON listings_search_mv USING GIN (address_trgm gin_trgm_ops);
CREATE INDEX listings_search_mv_tsv_idx
ON listings_search_mv USING GIN (tsv);
CREATE INDEX listings_search_mv_sources_idx
ON listings_search_mv (has_avito, has_cian, has_yandex);
-- ── 4. Возврат грантов, снятых в п.1 ───────────────────────────────────────
-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит
-- из системного каталога, поэтому подставляется как есть.
-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT
-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя,
-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку.
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP
EXECUTE format(
'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s',
r.privilege_type,
CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END,
r.grantee,
CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END
);
RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %',
r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee;
END LOOP;
END
$$;
-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ────────────
-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий
-- приёмки по pg_stats нечем проверить.
ANALYZE listings_search_mv;
COMMENT ON MATERIALIZED VIEW listings_search_mv IS
'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки '
'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в '
'определении, ноль читателей во всём репозитории. district оставлен намеренно: '
'он объявлен в schemas/search_response.py, его снос — ломающее изменение '
'контракта API и решение владельца. Единственный читатель витрины — '
'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён '
'по той же причине, что и по market.v_houses.';
COMMIT;

View file

@ -0,0 +1,951 @@
-- 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — WAVE 2:
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для оставшихся 40 городов
-- области (wave 1 — 179_scrape_schedules_seed_oblast_city_sweeps.sql, 5 городов:
-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov). Объявления
-- по области сейчас 3229 против 20111 по ЕКБ — wave 2 заводит оставшийся охват
-- Свердловской обл. Domclick (BFF, city_id-based) — отдельный rollout, сюда НЕ входит.
--
-- Координаты городов (lat/lon/название) — проверены на проде (геокодер + независимая
-- сверка медианой координат сделок Росреестра по городу, exclusion в радиусе 12км от
-- ЕКБ). CITY_ANCHORS-записи для всех 40 slug'ов — тот же PR,
-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py.
--
-- БИСЕРТЬ ИСКЛЮЧЕНА ЦЕЛИКОМ (не 41, а 40 городов): у Циана её нет вообще — поиск на
-- любой запрос ("Бисерть", "пгт Бисерть") отдаёт Сысерть id=176028. Это пгт, а не
-- город области — не заводится ни в CITY_ANCHORS, ни здесь.
--
-- ═══ ГЛАВНОЕ ОТЛИЧИЕ ОТ ПЕРВОЙ ВЕРСИИ ЭТОГО ФАЙЛА ═══
-- Первая версия (до ревью) заводила 41 город × 3 источника = 123 строки для ВСЕХ
-- источников сразу, планируя добыть provider-идентификаторы (avito_slug/cian_region_id/
-- yandex_rgid) ПОСЛЕ. Это оказалось бы РОВНО тем самым багом, о котором предупреждала её
-- же шапка: без подтверждённого идентификатора run_avito_city_sweep/run_yandex_city_sweep
-- падают на ЕКБ-дефолт (region_id/rgid Екатеринбурга) — развёртка "включена", но реально
-- собирает ЕКБ под меткой чужого города, порча данных под видом покрытия.
--
-- Идентификаторы теперь ДОБЫТЫ И ВАЛИДИРОВАНЫ (см. CITY_LOCATIONS-коммент в pipeline.py:
-- cian_id — api.cian.ru/geo-suggest/v1/suggest; yandex_rgid — realty.yandex.ru/gate/
-- region_suggest/suggest; avito_slug — живой GET avito.ru/<slug>/kvartiry; все три метода
-- валидированы 5/5 на wave-1 городах с уже известными значениями). Но НЕ у каждого города
-- подтверждены ВСЕ ТРИ идентификатора. Правило этой миграции: **строка заводится ТОЛЬКО
-- там, где идентификатор подтверждён**. Развёртка, которая молча соберёт Екатеринбург,
-- хуже отсутствующей — недостающие источники НЕ заводим вовсе (а не заводим с заглушкой/
-- fallback).
--
-- Дополнительный defensive guard в коде (тот же PR, pipeline.py): если КОГДА-ЛИБО
-- run_avito_city_sweep/run_yandex_city_sweep будет вызван с city_slug, у которого в
-- CITY_LOCATIONS известный город, но конкретный provider-идентификатор всё ещё None —
-- функция явно падает `ValueError` (НЕ молчаливый ЕКБ-дефолт). При штатной эксплуатации
-- этой миграции (schedule заводится только при подтверждённом идентификаторе) этот
-- ValueError сработать не должен — он ловит будущий рассинхрон данных, не текущий.
--
-- ИТОГО 102 строки (не 123):
-- cian_city_sweep_* — 40 строк (cian_region_id подтверждён у ВСЕХ 40 городов).
-- yandex_city_sweep_* — 39 строк (ВСЕ, КРОМЕ mikhaylovsk — Михайловск Нижнесергинского
-- р-на ОТСУТСТВУЕТ в гео-базе Яндекс.Недвижимости вообще: единственный "Михайловск"
-- там — ставропольский, rgid 586221, подставлять чужой регион нельзя. Это
-- подтверждённое ОТСУТСТВИЕ данных у источника, не "не проверили" — довести
-- нечем, ждать нечего).
-- avito_city_sweep_* — 23 строки. avito_slug НЕ подтверждён для 17 городов:
-- revda, polevskoy, berezovskiy, zarechny, kachkanar, sredneuralsk, degtyarsk,
-- artemovskiy, kamyshlov, sukhoy_log, kushva, karpinsk, nizhnyaya_tura,
-- nizhnie_sergi, lesnoy, verkhoturye, mikhaylovsk.
-- Причина по каждому — либо чистый 404 на опробованных вариантах slug'а (omonym-
-- коллизия с городом в другом регионе — нужна avito-специфичная дизамбигуация,
-- которой в проверке не делали), либо 403/429 из-за исчерпания пула прокси во
-- время проверки (кандидат НЕ опровергнут, просто НЕ подтверждён — это единственная
-- категория из трёх, которую стоит ПЕРЕПРОВЕРИТЬ на свежем пуле и добрать отдельной
-- миграцией; остальные — city_rgid mikhaylovsk и omonym-404 avito — подтверждённое
-- отсутствие/коллизия, довести нечем).
--
-- !!! DORMANT BY DESIGN !!! Все 102 строки ship enabled = false. Оператор включает
-- ВРУЧНУЮ по одному городу за раз (как в wave 1), волнами после деплоя:
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep_revda';
-- Capability уже полностью wired — тот же механизм, что и wave 1 (pipeline.CITY_ANCHORS/
-- get_city_anchors, scheduler._job_{avito,cian,yandex}_city_sweep читают
-- default_params->>'city', wildcard-registry "*_city_sweep_*" в
-- scraper_kit.orchestration.scheduler._default_kit_handlers) — код скраперов/хендлеров
-- НЕ меняется (кроме defensive-guard в pipeline.py выше, не меняющего штатный путь).
--
-- default_params — за основу взяты прод-дефолты enabled-городов wave 1 (см. 179_ +
-- 206_), с тремя отличиями:
-- 1. radius_m = 3000 у avito/cian (было 1500 в 179_) — один anchor на город должен
-- покрыть город целиком; сама 179_ предупреждала, что 1500м мало для городов
-- крупнее одного круга. yandex — 25000 как есть (gate-API город скоупит city_rgid,
-- lat/lon/radius_m игнорирует целиком, см. run_yandex_city_sweep docstring —
-- radius_m там мёртвый default).
-- 2. detail_top_n = 0 у avito (было 20 в 179_) — Avito detail-страницы сейчас отдают
-- HTTP 439 firewall независимо от IP (issue #2827). Обречённые detail-запросы на
-- 23 подтверждённых города только приблизят бан общего прокси-пула зря — не тратим
-- их, пока #2827 не починен. cian detail_top_n = 10 — оставлен как в 179_.
-- 3. interval_days = 3 у всех трёх источников — тот же такт, на который migration 206_
-- перевела wave-1 15 job'ов после замера (daily избыточен, независимая проверка по
-- listings_snapshots показала ~0.02-0.15%/сутки волатильности цены).
--
-- window_start_hour/window_end_hour (UTC, 1-часовые окна): 24 часа в сутках, 102 новые
-- строки — полная уникальность окна на строку математически невозможна для cian/yandex
-- (40 и 39 > 16-18 свободных часов), возможна для avito (23 <= 18). Тот же round-robin
-- scheme, что в первой версии файла (координаты НЕ пересчитывались — просто отфильтрован
-- набор строк по подтверждённым идентификаторам, часы у оставшихся ГОРОДОВ не менялись):
-- окна исключают ПОЛНОСТЬЮ (а) EKB-окна (avito 6-7, cian 2-5, yandex 16-17) и (б) окна
-- wave-1 179_ (avito {0,1,5,7,8}, cian {9,10,11,12,13}, yandex {14,15,17,18,19}); внутри
-- оставшихся свободных часов round-robin по городам в исходном 41-городском TSV-порядке
-- (novouralsk..bisert, bisert выброшен целиком), затем строка эмитится, только если
-- источник подтверждён для этого города. Итоговый максимум коллизий ОДНОГО источника в
-- одном часе: avito <= 2, cian <= 3, yandex <= 3 (ниже, чем было бы при полных 41 —
-- меньше строк на источник). Разные провайдеры МОГУТ делить час — не ограничивалось (см.
-- 179_/206_ — proxy-pool уже не единственный узел).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 179_ (wave 1,
-- CITY_ANCHORS-механизм и wildcard resolve_handler — не переопределяются здесь).
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
-- по построению (40 городов × подтверждённые источники, ни один не пересекается с
-- wave-1 5 городами).
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
-- ── avito_city_sweep_<city> — ТОЛЬКО 23 города с подтверждённым avito_slug
-- (radius_m 3000, detail_top_n 0 — issue #2827, enrich_houses true,
-- pages_per_anchor 3, request_delay_sec 7, interval_days 3) ──────────────
(
'avito_city_sweep_novouralsk',
false,
2,
3,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novouralsk"}'::jsonb
),
(
'avito_city_sweep_asbest',
false,
9,
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "asbest"}'::jsonb
),
(
'avito_city_sweep_bogdanovich',
false,
10,
11,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "bogdanovich"}'::jsonb
),
(
'avito_city_sweep_irbit',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "irbit"}'::jsonb
),
(
'avito_city_sweep_krasnoufimsk',
false,
12,
13,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb
),
(
'avito_city_sweep_krasnoturinsk',
false,
16,
17,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb
),
(
'avito_city_sweep_severouralsk',
false,
17,
18,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "severouralsk"}'::jsonb
),
(
'avito_city_sweep_ivdel',
false,
18,
19,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "ivdel"}'::jsonb
),
(
'avito_city_sweep_tavda',
false,
19,
20,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "tavda"}'::jsonb
),
(
'avito_city_sweep_turinsk',
false,
20,
21,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "turinsk"}'::jsonb
),
(
'avito_city_sweep_sysert',
false,
21,
22,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "sysert"}'::jsonb
),
(
'avito_city_sweep_verkhnyaya_salda',
false,
2,
3,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb
),
(
'avito_city_sweep_nizhnyaya_salda',
false,
3,
4,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb
),
(
'avito_city_sweep_nevyansk',
false,
4,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nevyansk"}'::jsonb
),
(
'avito_city_sweep_alapaevsk',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "alapaevsk"}'::jsonb
),
(
'avito_city_sweep_krasnouralsk',
false,
14,
15,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb
),
(
'avito_city_sweep_verkhniy_tagil',
false,
17,
18,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb
),
(
'avito_city_sweep_rezh',
false,
20,
21,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "rezh"}'::jsonb
),
(
'avito_city_sweep_aramil',
false,
21,
22,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "aramil"}'::jsonb
),
(
'avito_city_sweep_volchansk',
false,
22,
23,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "volchansk"}'::jsonb
),
(
'avito_city_sweep_verkhnyaya_tura',
false,
23,
0,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb
),
(
'avito_city_sweep_talitsa',
false,
4,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "talitsa"}'::jsonb
),
(
'avito_city_sweep_novaya_lyalya',
false,
9,
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb
),
-- ── cian_city_sweep_<city> — ВСЕ 40 городов (cian_id подтверждён у всех)
-- (radius_m 3000, detail_top_n 10, enrich_houses true, pages_per_anchor 3,
-- request_delay_sec 5, interval_days 3) ─────────────────────────────────
(
'cian_city_sweep_novouralsk',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novouralsk"}'::jsonb
),
(
'cian_city_sweep_revda',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "revda"}'::jsonb
),
(
'cian_city_sweep_polevskoy',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "polevskoy"}'::jsonb
),
(
'cian_city_sweep_asbest',
false,
6,
7,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "asbest"}'::jsonb
),
(
'cian_city_sweep_bogdanovich',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "bogdanovich"}'::jsonb
),
(
'cian_city_sweep_irbit',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "irbit"}'::jsonb
),
(
'cian_city_sweep_krasnoufimsk',
false,
14,
15,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb
),
(
'cian_city_sweep_berezovskiy',
false,
15,
16,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "berezovskiy"}'::jsonb
),
(
'cian_city_sweep_zarechny',
false,
16,
17,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "zarechny"}'::jsonb
),
(
'cian_city_sweep_kachkanar',
false,
17,
18,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kachkanar"}'::jsonb
),
(
'cian_city_sweep_krasnoturinsk',
false,
18,
19,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb
),
(
'cian_city_sweep_severouralsk',
false,
19,
20,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "severouralsk"}'::jsonb
),
(
'cian_city_sweep_ivdel',
false,
20,
21,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "ivdel"}'::jsonb
),
(
'cian_city_sweep_tavda',
false,
21,
22,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "tavda"}'::jsonb
),
(
'cian_city_sweep_turinsk',
false,
22,
23,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "turinsk"}'::jsonb
),
(
'cian_city_sweep_sysert',
false,
23,
0,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sysert"}'::jsonb
),
(
'cian_city_sweep_sredneuralsk',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sredneuralsk"}'::jsonb
),
(
'cian_city_sweep_degtyarsk',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "degtyarsk"}'::jsonb
),
(
'cian_city_sweep_verkhnyaya_salda',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb
),
(
'cian_city_sweep_nizhnyaya_salda',
false,
6,
7,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb
),
(
'cian_city_sweep_nevyansk',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nevyansk"}'::jsonb
),
(
'cian_city_sweep_artemovskiy',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "artemovskiy"}'::jsonb
),
(
'cian_city_sweep_kamyshlov',
false,
14,
15,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kamyshlov"}'::jsonb
),
(
'cian_city_sweep_alapaevsk',
false,
15,
16,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "alapaevsk"}'::jsonb
),
(
'cian_city_sweep_sukhoy_log',
false,
16,
17,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sukhoy_log"}'::jsonb
),
(
'cian_city_sweep_kushva',
false,
17,
18,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kushva"}'::jsonb
),
(
'cian_city_sweep_krasnouralsk',
false,
18,
19,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnouralsk"}'::jsonb
),
(
'cian_city_sweep_karpinsk',
false,
19,
20,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "karpinsk"}'::jsonb
),
(
'cian_city_sweep_nizhnyaya_tura',
false,
20,
21,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb
),
(
'cian_city_sweep_verkhniy_tagil',
false,
21,
22,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb
),
(
'cian_city_sweep_nizhnie_sergi',
false,
22,
23,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb
),
(
'cian_city_sweep_lesnoy',
false,
23,
0,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "lesnoy"}'::jsonb
),
(
'cian_city_sweep_rezh',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "rezh"}'::jsonb
),
(
'cian_city_sweep_aramil',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "aramil"}'::jsonb
),
(
'cian_city_sweep_volchansk',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "volchansk"}'::jsonb
),
(
'cian_city_sweep_verkhnyaya_tura',
false,
6,
7,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb
),
(
'cian_city_sweep_mikhaylovsk',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "mikhaylovsk"}'::jsonb
),
(
'cian_city_sweep_verkhoturye',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhoturye"}'::jsonb
),
(
'cian_city_sweep_talitsa',
false,
14,
15,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "talitsa"}'::jsonb
),
(
'cian_city_sweep_novaya_lyalya',
false,
15,
16,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb
),
-- ── yandex_city_sweep_<city> — 39 городов (ВСЕ, КРОМЕ mikhaylovsk — города
-- нет в гео-базе Яндекса вообще) (radius_m 25000, pages_per_anchor 3,
-- request_delay_sec 9, interval_days 3) ───────────────────────────────
(
'yandex_city_sweep_novouralsk',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novouralsk"}'::jsonb
),
(
'yandex_city_sweep_revda',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "revda"}'::jsonb
),
(
'yandex_city_sweep_polevskoy',
false,
2,
3,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "polevskoy"}'::jsonb
),
(
'yandex_city_sweep_asbest',
false,
3,
4,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "asbest"}'::jsonb
),
(
'yandex_city_sweep_bogdanovich',
false,
4,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "bogdanovich"}'::jsonb
),
(
'yandex_city_sweep_irbit',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "irbit"}'::jsonb
),
(
'yandex_city_sweep_krasnoufimsk',
false,
6,
7,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb
),
(
'yandex_city_sweep_berezovskiy',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "berezovskiy"}'::jsonb
),
(
'yandex_city_sweep_zarechny',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "zarechny"}'::jsonb
),
(
'yandex_city_sweep_kachkanar',
false,
9,
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kachkanar"}'::jsonb
),
(
'yandex_city_sweep_krasnoturinsk',
false,
10,
11,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb
),
(
'yandex_city_sweep_severouralsk',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "severouralsk"}'::jsonb
),
(
'yandex_city_sweep_ivdel',
false,
12,
13,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "ivdel"}'::jsonb
),
(
'yandex_city_sweep_tavda',
false,
13,
14,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "tavda"}'::jsonb
),
(
'yandex_city_sweep_turinsk',
false,
20,
21,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "turinsk"}'::jsonb
),
(
'yandex_city_sweep_sysert',
false,
21,
22,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sysert"}'::jsonb
),
(
'yandex_city_sweep_sredneuralsk',
false,
22,
23,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sredneuralsk"}'::jsonb
),
(
'yandex_city_sweep_degtyarsk',
false,
23,
0,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "degtyarsk"}'::jsonb
),
(
'yandex_city_sweep_verkhnyaya_salda',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb
),
(
'yandex_city_sweep_nizhnyaya_salda',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb
),
(
'yandex_city_sweep_nevyansk',
false,
2,
3,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nevyansk"}'::jsonb
),
(
'yandex_city_sweep_artemovskiy',
false,
3,
4,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "artemovskiy"}'::jsonb
),
(
'yandex_city_sweep_kamyshlov',
false,
4,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kamyshlov"}'::jsonb
),
(
'yandex_city_sweep_alapaevsk',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "alapaevsk"}'::jsonb
),
(
'yandex_city_sweep_sukhoy_log',
false,
6,
7,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sukhoy_log"}'::jsonb
),
(
'yandex_city_sweep_kushva',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kushva"}'::jsonb
),
(
'yandex_city_sweep_krasnouralsk',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb
),
(
'yandex_city_sweep_karpinsk',
false,
9,
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "karpinsk"}'::jsonb
),
(
'yandex_city_sweep_nizhnyaya_tura',
false,
10,
11,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb
),
(
'yandex_city_sweep_verkhniy_tagil',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb
),
(
'yandex_city_sweep_nizhnie_sergi',
false,
12,
13,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb
),
(
'yandex_city_sweep_lesnoy',
false,
13,
14,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "lesnoy"}'::jsonb
),
(
'yandex_city_sweep_rezh',
false,
20,
21,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "rezh"}'::jsonb
),
(
'yandex_city_sweep_aramil',
false,
21,
22,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "aramil"}'::jsonb
),
(
'yandex_city_sweep_volchansk',
false,
22,
23,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "volchansk"}'::jsonb
),
(
'yandex_city_sweep_verkhnyaya_tura',
false,
23,
0,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb
),
(
'yandex_city_sweep_verkhoturye',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhoturye"}'::jsonb
),
(
'yandex_city_sweep_talitsa',
false,
2,
3,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "talitsa"}'::jsonb
),
(
'yandex_city_sweep_novaya_lyalya',
false,
3,
4,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). Источники перечислены в '
'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях '
'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: 102 wave-2 oblast '
'city-sweep source''ы (40 городов, только подтверждённые provider-id: '
'cian x40 / yandex x39 (без mikhaylovsk) / avito x23, #262 — все enabled=false, '
'defensive ValueError guard в pipeline.py против молчаливого ЕКБ-fallback).';
COMMIT;

View file

@ -0,0 +1,52 @@
-- 263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
-- Дописывает "newbuilding_only": false в default_params 40 cian-строк wave 2 (262_).
--
-- ПОЧЕМУ. Прогон первого включённого города области показал, что sweep отрабатывает
-- «успешно», но не сохраняет НИЧЕГО:
--
-- cian-sweep run_id=3884 anchor Новоуральск центр:
-- SERP fetched=84 nb_kept=0 dropped_secondary=84 ins=0 upd=0
-- cian-sweep run_id=3884 done: anchors=1/1 lots=84 (ins=0/upd=0) ... errors=0
--
-- 84 лота найдено и все 84 отброшено как вторичка, статус прогона при этом done.
--
-- Причина: scraper_kit.orchestration.scheduler (_job_cian_city_sweep) читает
-- newbuilding_only=bool(params.get("newbuilding_only", True))
-- то есть дефолт — True. Сид 179_ (wave 1) ключ проставляет явно (false), а 262_
-- (wave 2) его потерял. Мера оценивает ВТОРИЧКУ — estimator отбирает аналоги с
-- (listing_segment IS NULL OR listing_segment = 'vtorichka'), — поэтому режим
-- «только новостройки» для этих строк бессмыслен: сбор идёт, данные выбрасываются.
--
-- ЗАТРАГИВАЕТ ТОЛЬКО cian. У avito/yandex такого параметра нет ни в 179_, ни в 262_
-- (проверено сравнением default_params wave-1 и wave-2 на проде) — их не трогаем.
--
-- ПОБОЧНАЯ НАХОДКА: под гейт попадает 41 строка, а не 40. Лишняя —
-- `cian_city_sweep_verkhnyaya_pyshma` из wave 1, ВКЛЮЧЁННАЯ и работающая в проде:
-- 179_ проставил newbuilding_only не всем своим городам. Последствия на живых данных:
--
-- Верхняя Пышма (ключа нет): cian 184 активных → вторички 3, новостроек 181
-- Первоуральск (ключ есть): cian 336 активных → вторички 308
--
-- То есть по Верхней Пышме Циан давал оценщику 3 пригодных объявления вместо ~300 —
-- сбор шёл, статус зелёный, данные молча выбрасывались. Эта миграция чинит и её.
--
-- Идемпотентность: WHERE-гейт `NOT (default_params ? 'newbuilding_only')` — миграция
-- дописывает ключ только там, где его нет. Повторный прогон — no-op, и она никогда
-- не перезатрёт значение, выставленное позже вручную оператором.
--
-- ЗАВИСИМОСТИ: 262_ (сами строки), 052_scrape_schedules.sql (таблица).
BEGIN;
SET LOCAL lock_timeout = '5s';
UPDATE scrape_schedules
SET default_params = default_params || '{"newbuilding_only": false}'::jsonb,
updated_at = NOW()
WHERE source LIKE 'cian\_city\_sweep\_%'
AND default_params ? 'city'
AND NOT (default_params ? 'newbuilding_only');
COMMIT;

View file

@ -0,0 +1,83 @@
-- 264_deactivate_stale_avito_cap_mult.sql
-- Калибрует потолок эффективного TTL (cap_mult) для avito (#TTL-CAP, 2026-08-15).
--
-- ЗАЧЕМ. Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py)
-- поднимает эффективный TTL через max(ttl_days, пол) без верхней границы -- на проде
-- это оказалось петлёй с положительной обратной связью: медленный обход поднимает
-- пол, высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает
-- вернуться свежий обход, пул «активных» раздувается протухшими строками. ВАЖНАЯ
-- ОГОВОРКА (перепроверено 2026-08-15): цифра «23 687 из 44 744» -- это ВСЕ источники
-- вместе, и две трети её -- новостройки, которые оценщик не берёт вообще. У самого
-- avito просроченных строк НОЛЬ (8 663 активных, максимальный возраст 10 суток) --
-- его деактивация работает исправно. Этот потолок существует не ради сжатия пула
-- (он деактивирует 0 строк, замерено), а как защита от опечатки в расписании и от
-- будущего разгона пола. Потолок cap_mult ограничивает пол сверху: эффективный TTL не
-- может превысить ttl_days * cap_mult (код -- app/tasks/deactivate_stale_avito.py,
-- CAP_MULT).
--
-- ПОЧЕМУ ИМЕННО AVITO. Дефолт CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных
-- источниках (множитель от ttl_days), и ломается там, где хвост переобхода
-- источника НЕ пропорционален его ttl_days. Таблица ниже -- ЖИВЫЕ полы из
-- scrape_runs.counters (ttl_days_effective/revisit_floor_days по каждой job'е за
-- 2026-08-10..08-15, ПЕРЕСЧИТАНО ревью круга 3 2026-08-15 -- прежняя версия таблицы
-- брала статический p99 из _REVISIT_TAIL (40-суточный замер на более раннюю дату)
-- и по нему ошибочно утверждала «yandex 43.0 -> потолок 60, запас есть»; live-полы
-- показывают обратное, см. ниже), а не по статической константе:
-- источник/сегмент живой пол (6 прогонов) ttl_days потолок cap_mult=2
-- domklik vtorichka 23/24/25/skip/skip/skip 14 28 (запас есть)
-- cian vtorichka 34/34/37/27/27/32 30 60 (запас есть)
-- yandex vtorichka 75/75/75/39/52/54 30 60 (ХВОСТ ВЫШЕ)
-- avito все сегменты 52/52/52/7/8/9 10 20 (ХВОСТ ВЫШЕ)
-- У avito p99=42.1 суток (_REVISIT_TAIL) и живой пик 52 -- ВЫШЕ его же дефолтного
-- потолка 20: дефолтный cap_mult=2 может резать пол ниже собственного хвоста
-- обхода, то есть ровно тот false-kill, ради которого пол вообще заведён.
--
-- YANDEX -- ТА ЖЕ ДЫРА, что и у avito, но найдена ПОЗЖЕ (при первой версии этой
-- миграции статический p99=43.0 ошибочно считался достаточным запасом). Живой пол
-- yandex/vtorichka держится 39-75 суток шесть прогонов подряд, а прямой live-замер
-- 2026-08-15 (та же percentile_disc(0.99)-формула, что и в проде) даёт 79.2 суток
-- (n=1961 подтверждений за 3 суток) -- выше потолка 60 при дефолтном cap_mult=2.
-- Калибровка yandex вынесена в ОТДЕЛЬНУЮ миграцию
-- (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90), не сюда --
-- эта миграция специфична для avito по имени и назначению, смешивать источники в
-- одном файле хуже для git-истории калибровок. cian и domklik разрыва не имеют,
-- дефолт cap_mult=2 для них по-прежнему калиброван верно, эта миграция их не трогает.
--
-- ЧИСЛЕННЫЙ ЭФФЕКТ (обе миграции, 264+265, live-замер 2026-08-15): на пул активных
-- строк не влияет ни у одного из четырёх источников -- next-run deactivated=0 что до,
-- что после калибровки. У avito и cian живой пол (12/32 суток) уже ниже потолка --
-- калибровка cap_mult просто не участвует в min(). У yandex 0 активных строк старше
-- 39 суток вообще (весь "просроченный" хвост младше того возраста, где потолок
-- 60 vs 90 может разойтись), поэтому даже БЕЗ калибровки (дефолт cap_mult=2,
-- потолок 60 < живой пол 79.2) next-run deactivated тоже 0 -- калибровка убирает
-- будущий риск (потолок бы капал ttl_days_effective 79->60 в counters и резал бы
-- ниже собственного хвоста обхода, как только появятся строки в возрастной полосе
-- 60-90 суток), а не текущее число. domklik заблокирован гейтом здоровья
-- (confirmations 94 < min_confirmations 200) -- до потолка/пола дело не доходит.
--
-- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian (60, дефолт cap_mult=2),
-- с запасом выше и статического p99=42.1 (_REVISIT_TAIL, tests/test_deactivate_stale_revisit_floor.py),
-- и живого прод-пика: floor=52 три прогона подряд 2026-08-10..08-12
-- (scrape_runs.counters, status=done, confirmations 6934..7138, гейт здоровья
-- пропустил). Без этой калибровки в проде остаётся дефолт cap_mult=2 (потолок 20)
-- -- именно тот случай, для которого потолок и его собственная калибровочная ручка
-- заведены, но не применены к единственному источнику, ради которого ручка сделана.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же
-- приём -- UPDATE default_params через jsonb ?, min_confirmations).
-- ТОЛЬКО данные (UPDATE default_params), DDL нет.
-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где
-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором
-- значение. Снять/поднять потолок вручную: cap_mult в default_params
-- (deactivate_stale_avito), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1
-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL).
BEGIN;
UPDATE scrape_schedules
SET default_params = default_params || jsonb_build_object('cap_mult', 6),
updated_at = NOW()
WHERE source = 'deactivate_stale_avito'
AND NOT default_params ? 'cap_mult';
COMMIT;

View file

@ -0,0 +1,57 @@
-- 265_deactivate_stale_yandex_cap_mult.sql
-- Калибрует потолок эффективного TTL (cap_mult) для yandex (#TTL-CAP круг 3, 2026-08-15).
--
-- ЗАЧЕМ. Та же дыра, что закрыта для avito миграцией
-- 264_deactivate_stale_avito_cap_mult.sql (см. её комментарий про механизм петли),
-- но обнаружена на yandex позже: первая версия 264 утверждала, что дефолтный
-- CAP_MULT=2 (потолок 60 при ttl_days=30) для yandex "калиброван верно" на
-- основании статического p99=43.0 (_REVISIT_TAIL, замер на более раннюю дату).
--
-- ЖИВОЙ ЗАМЕР, из-за которого миграция существует. scrape_runs.counters
-- (deactivate_stale_yandex, 2026-08-10..08-15) держал ttl_days_effective 75/75/75/
-- 39/52/54 шесть прогонов подряд при deactivated=0 -- то есть пол ВСЕ ЭТИ ДНИ был
-- выше потолка 60. Прямой live-замер той же percentile_disc(0.99)-формулы, что и в
-- коде (app/tasks/deactivate_stale_avito.py, _build_revisit_floor_sql), 2026-08-15
-- даёт 79.2 суток (n=1961 подтверждений за окно 3 суток). Оба замера выше потолка
-- 60 -- ровно тот false-kill, ради которого пол #2659 вообще заведён: без калибровки
-- потолок капал бы ttl_days_effective yandex до 60 в counters уже сегодня и резал бы
-- ниже собственного хвоста обхода, как только в пуле появятся строки возрастом
-- 60-90 суток (сейчас таких 0 -- см. ЧИСЛЕННЫЙ ЭФФЕКТ ниже).
--
-- ПОЧЕМУ 3. Потолок 90 = 30 * 3 -- запас ~14% над живым пиком 79.2, той же
-- пропорции, что и у avito (потолок 60 против пика 52 -- запас ~15%, см. 264).
-- Меньший cap_mult=2 (потолок 60) уже сейчас ниже пика 79.2. Больший cap_mult
-- намеренно не берём -- дальнейший рост пола означает не "медленный, но живой
-- обход", а кандидата в mёртвый источник, для которого есть отдельный гейт
-- здоровья (min_confirmations), а не растягивание потолка до бесконечности (см.
-- комментарий у CAP_MULT в deactivate_stale_avito.py).
--
-- ЧИСЛЕННЫЙ ЭФФЕКТ (live-замер 2026-08-15): 0 активных строк yandex/vtorichka
-- старше 39 суток вообще (запрос: count(*) FROM listings WHERE source='yandex' AND
-- listing_segment='vtorichka' AND is_active=true AND last_seen_at < NOW() -
-- INTERVAL 'N days', N=39/52/54/60/75/79 -- везде 0). Next-run deactivated=0 что
-- при дефолтном cap_mult=2 (потолок 60, капает пол), что при cap_mult=3 из этой
-- миграции (потолок 90, не капает) -- эта миграция убирает БУДУЩИЙ риск
-- false-kill при появлении строк в полосе 60-90 суток, а не текущее число
-- деактиваций. Ветка #TTL-CAP не сжимает пул ни у одного из четырёх источников --
-- см. 264 для остальных трёх.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же
-- приём -- UPDATE default_params через jsonb ?, min_confirmations), 264 (тот же
-- приём для avito, cap_mult -- параметр deactivate_stale_listings).
-- ТОЛЬКО данные (UPDATE default_params), DDL нет.
-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где
-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором
-- значение. Снять/поднять потолок вручную: cap_mult в default_params
-- (deactivate_stale_yandex), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1
-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL).
BEGIN;
UPDATE scrape_schedules
SET default_params = default_params || jsonb_build_object('cap_mult', 3),
updated_at = NOW()
WHERE source = 'deactivate_stale_yandex'
AND NOT default_params ? 'cap_mult';
COMMIT;

View file

@ -0,0 +1,109 @@
-- 266_seed_deactivate_stale_null_segment_yandex_cian.sql
-- Деактивация протухших yandex/cian объявлений с ПУСТЫМ listing_segment.
--
-- Замер на проде 2026-08-15 (is_active=true, listing_segment IS NULL):
-- source | активных | старше 30 сут | макс возраст
-- yandex | 544 | 533 | 86.3 сут
-- cian | 224 | 211 | 86.3 сут
-- 97% / 94% этих строк протухли, вплоть до 86 суток. При этом estimator их
-- ИСПОЛЬЗУЕТ как comps без freshness-фильтра (Tier A "тот же дом" / Tier C
-- micro-radius в app/services/estimator.py фильтруют только is_active=true,
-- без scraped_at-фильтра свежести — в отличие от Tier S/H, у которых он есть).
--
-- ПОЧЕМУ NULL, А НЕ ANY(:segments). deactivate_stale_yandex / deactivate_stale_cian
-- (миграция 115) уже деактивируют segments=['vtorichka'] — пустой сегмент они НЕ видят:
-- `listing_segment = ANY(CAST(:segments AS text[]))` в SQL никогда не матчит NULL
-- (задокументировано в 115 у novostroyki-гарда). Нужен отдельный явный предикат
-- IS NULL — app/tasks/deactivate_stale_avito.py получил kwarg null_segment_only=True,
-- строящий `... AND listing_segment IS NULL` вместо ANY(:segments).
--
-- ПОЧЕМУ ОТДЕЛЬНАЯ ДЖОБА, А НЕ РАСШИРЕНИЕ deactivate_stale_yandex/_cian. Гейт
-- здоровья сбора (#2659, migration 219) и пол переобхода (#2659) откалиброваны под
-- полноценный vtorichka-свип (сотни-тысячи подтверждений в сутки, см. 219). У
-- NULL-сегмента подтверждений на 2-3 порядка меньше (замер того же дня: 9 cian +
-- 4 yandex строк с last_seen_at < 7 суток) — с общим min_confirmations джоба
-- вечно давала бы skipped_unhealthy и никогда не деактивировала бы ни строки.
-- Отдельная джоба с собственными (выключенными) порогами не трогает работающие
-- deactivate_stale_yandex/_cian и их пол/гейт.
--
-- НЕ ЗАТРАГИВАЕТ novostroyki: null_segment_only-предикат — строго `IS NULL`, ни
-- 'novostroyki', ни 'vtorichka' в него не попадают ни при каких условиях (в отличие
-- от паушального TTL по всему source, который снёс бы все ~22,5к первичных строк).
--
-- TTL=60 суток — консервативный, обоснование числом:
-- Строки этого среза по определению не переобходятся систематически (иначе у них
-- был бы сегмент — свежий обход cian/yandex SERP всегда вычисляет listing_segment
-- детерминированно, см. providers/cian/serp.py:955-958, providers/yandex/serp.py:177).
-- Значит «пол переобхода» (revisit_floor, #2659) здесь измерять нечем: он квантиль
-- разрывов НАБЛЮДАЕМОГО повторного обхода, а для строки вне скоупа обхода такого
-- ряда нет — вычислять его было бы фикцией. Поэтому revisit_floor_quantile=0 явно
-- (выключен), а весь запас закладываем в сам TTL:
-- deactivate_stale_avito.py документирует измеренные p99 разрывов переобхода
-- vtorichka (тот же тип строк, тот же source, разница только в сегменте):
-- cian/vtorichka p99 = 26.6 сут
-- yandex/vtorichka p99 = 43.0 сут
-- TTL=60 даёт запас 2.26x над cian p99 и 1.4x над yandex p99 — комфортный отступ
-- без специального замера под null-сегмент (население слишком мало для устойчивого
-- перцентиля). При этом бимодальность выборки (замер 2026-08-15: gt30d/gt45d/gt60d
-- почти не меняются — 211/211/211 cian, 533/525/523 yandex) означает, что более
-- консервативный TTL стоит ПОЧТИ НИЧЕГО в охвате: первый прогон снимет 734 из 768
-- строк (95.6%) вместо 744 при TTL=30 — разница 10 строк, зато вдвое больший
-- защитный запас над измеренным хвостом обхода.
--
-- min_confirmations=0, revisit_floor_quantile=0 — оба гейта ВЫКЛЮЧЕНЫ явно (не через
-- умолчание product_handlers.py, которое иначе подставило бы DEFAULT_MIN_CONFIRMATIONS
-- = 500 и DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 — оба откалиброваны под другую шкалу
-- популяции и держали бы эту джобу в вечном skipped_unhealthy, см. выше).
--
-- Schedule window 07:00-08:00 UTC — тот же слот, что и deactivate_stale_yandex/_cian
-- (migration 115) и deactivate_stale_domklik/_n1 (migration 160): после ночных sweep'ов
-- (02:00-05:00 UTC), так что реально переобойдённые строки не деактивируются.
--
-- next_run_at bootstrapped на завтра 07:00 UTC — тот же паттерн, что 090/115/160,
-- чтобы не сработать сразу на деплое.
--
-- Идемпотентно: ON CONFLICT (source) DO NOTHING — безопасно при повторном применении.
--
-- Dependencies:
-- 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- listings.listing_segment (011_listings_alter.sql).
-- 115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql (соседние джобы, тот же слот).
-- app/tasks/deactivate_stale_avito.py — null_segment_only kwarg.
-- app/services/product_handlers.py — _job_deactivate_stale читает null_segment_only
-- из default_params и пробрасывает в deactivate_stale_listings.
--
-- Deploy order: применять ПОСЛЕ деплоя backend-кода (null_segment_only kwarg), иначе
-- первый прогон свалится с TypeError на неизвестный параметр default_params.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'deactivate_stale_yandex_null_segment',
true, -- SAFE: pure internal DB UPDATE, no ext calls
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"listing_source":"yandex","ttl_days":60,"null_segment_only":true,'
'"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb
),
(
'deactivate_stale_cian_null_segment',
true, -- SAFE: pure internal DB UPDATE, no ext calls
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"listing_source":"cian","ttl_days":60,"null_segment_only":true,'
'"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -1,247 +0,0 @@
# _manifest_applied.txt — CONTRACT (issue #2216)
#
# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/,
# которые на момент коммита уже применены/забейслайнены на проде.
# Прод трекает миграции по bare-filename в public._schema_migrations —
# переименование или удаление применённого файла => повторный прогон на
# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки.
#
# ПРАВИЛА (enforced tests/test_migrations_manifest.py):
# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied).
# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей).
# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован).
#
# Комментарии (# ...) и пустые строки тест игнорирует.
001_trade_in_estimates.sql
002_core_tables.sql
003_seed_deals.sql
004_extend_trade_in_estimates.sql
005_geocode_tracking.sql
007_estimate_photos.sql
008_crm_fields.sql
009_houses.sql
010_houses_alter.sql
011_listings_alter.sql
012_sellers.sql
013_listings_alter_seller.sql
014_house_reviews.sql
015_scrape_runs.sql
016_listings_snapshots.sql
017_house_placement_history.sql
018_avito_imv_evaluations.sql
019_listings_alter_cian.sql
020_houses_alter_cian.sql
021_management_companies.sql
022_agents_table.sql
023_offer_price_history.sql
024_houses_price_dynamics.sql
025_house_reliability_checks.sql
026_external_valuations.sql
027_cian_session_cookies.sql
028_matching_tables.sql
029_extend_matching_valuation_dynamics.sql
030_avito_imv_cache_key_unique.sql
031_houses_alter_yandex.sql
032_yandex_history.sql
033_listings_alter_yandex.sql
034_trade_in_estimates_geom.sql
035_drop_duplicate_indexes.sql
040_houses_extend.sql
041_house_sources_noop.sql
042_listing_sources_price_divergence_idx.sql
043_house_reviews_extend.sql
044_external_valuations_link.sql
045_house_placement_history_extend.sql
046_views.sql
047_cian_history_sanitize.sql
050_search_optimization.sql
051_scrape_runs_extend.sql
052_scrape_schedules.sql
053_scraper_settings.sql
054_scraper_settings_global.sql
060_postgres_fdw_extension.sql
061_drop_legacy_cad_buildings.sql
062_clean_avito_addresses.sql
063_backfill_houses_and_link_listings.sql
064_house_imv_phase_c.sql
065_trade_in_estimates_floor_optional.sql
066_address_mismatch_audit.sql
067_v_street_sales_vs_listings.sql
068_drop_v_street_sales_vs_listings.sql
069_trade_in_estimates_dadata_fields.sql
070_houses_dadata_enrichment.sql
071_houses_cian_zhk_url.sql
072_scrape_schedules_seed_cian_rosreestr.sql
073_normalize_repair_state.sql
075_backfill_repair_state_from_description.sql
076_account_estimate_quota.sql
077_dedup_hash_plain_key_backfill.sql
078_scrape_schedules_seed_yandex_sweep.sql
079_listing_source_history.sql
080_asking_to_sold_ratios.sql
081_trade_in_estimates_expected_sold.sql
082_scrape_schedules_seed_ratio_refresh.sql
083_trade_in_estimates_created_by.sql
084_brand_praktika_fill.sql
084_scrape_schedules_seed_n1_sweep.sql
085_quarter_price_index_fdw.sql
086_deals_address_trgm_index.sql
087_fdw_server_options.sql
088_scrape_schedules_seed_search_matview_refresh.sql
089_listings_geo_precision.sql
090_scrape_schedules_seed_deactivate_stale_avito.sql
091_scrape_schedules_seed_yandex_address_backfill.sql
092_sber_price_index.sql
093_scrape_schedules_seed_sber_index_pull.sql
094_cadastral_unify.sql
095_dead_schema.sql
096_scrape_schedules_seed_rosreestr_quarter_poll.sql
097_index_hygiene.sql
098_asking_to_sold_ratios_tiered.sql
099_brand_praktika_logo_wordmark.sql
100_enable_deactivate_stale_avito.sql
101_gendesign_reader_role.sql
102_grant_listings_gendesign_reader.sql
103_scrape_schedules_seed_newbuilding_enrich.sql
104_index_hygiene_geom_dedup.sql
105_market_schema_yandex_enrichment.sql
106_scrape_schedules_seed_yandex_newbuilding_sweep.sql
107_scrape_schedules_seed_cian_city_sweep.sql
108_clean_avito_addresses_v2.sql
108_merge_duplicate_houses.sql
109_asking_to_sold_ratio_segment_filter.sql
110_scrape_schedules_seed_geocode_missing_listings.sql
111_listings_avito_detail_fields.sql
112_scrape_schedules_seed_avito_detail_backfill.sql
113_deactivate_ghost_duplicate_listings.sql
113_yandex_detail_backfill.sql
114_disable_n1_sweep.sql
115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql
116_offer_price_history_change_trigger.sql
117_listings_last_seen_deactivate_index.sql
118_enable_cian_city_sweep.sql
119_yandex_city_sweep_center_combos.sql
120_restore_partial_active_indexes.sql
121_remove_brand_praktika.sql
121_yandex_rich_fields.sql
122_enable_domclick_city_sweep.sql
123_avito_newbuilding_sweep_schedule.sql
124_cad_buildings_local.sql
124_deglue_avito_addresses.sql
125_scrape_schedules_seed_cadastral_geo_match.sql
126_scrape_schedules_seed_cian_full_load.sql
127_scrape_schedules_seed_avito_full_load.sql
128_listings_card_hash.sql
129_avito_full_load_incremental_split.sql
130_backfill_listings_house_id_fk.sql
130_ekb_geoportal_buildings.sql
131_fix_diff_percent_overflow.sql
132_scrape_schedules_seed_house_imv.sql
133_listings_uq_source_source_id.sql
134_listings_geom_geography_gist.sql
135_scrape_schedules_seed_house_dedup_merge.sql
136_backfill_listings_house_id_fk_source_identity.sql
137_listings_addr_norm_trgm.sql
138_domclick_bff_rewrite_schedule.sql
139_premium_houses.sql
140_yandex_house_type_backfill.sql
141_cian_promote_house_type.sql
142_premium_buildings_curated.sql
143_building_sale_share_schema.sql
144_gar_canon_addr_match.sql
145_building_sale_share_plausible_denom.sql
146_sale_share_45d_and_zhkh_denom.sql
147_canon_strip_geo_prefixes.sql
148_dedup_apartments_in_sale_share.sql
149_zhkh_priority_denominator.sql
150_sale_share_listing_geo_filter.sql
151_clean_bare_street_aliases.sql
152_sale_share_floors_guard.sql
153_sale_share_listings_floors_plausibility.sql
154_market_contract_views.sql
155_reader_grants_to_contract_views.sql
156_revoke_raw_from_reader.sql
157_scrape_proxies.sql
158_seed_proxy_healthcheck_schedule.sql
159_houses_fias_idx.sql
160_seed_deactivate_stale_domklik_n1.sql
161_backfill_scraped_at_active_recent.sql
162_seed_deals_freshness_monitor.sql
163_disable_deactivate_stale_domklik.sql
164_yandex_url_canonicalize_active_dups.sql
165_remove_n1_source.sql
166_purge_listings_phones.sql
167_drop_client_pii.sql
168_fdw_osm_poi_ekb.sql
169_osm_poi_ekb_local.sql
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
171_scrape_schedules_seed_geoportal_coords_backfill.sql
172_trade_in_leads.sql
173_scrape_proxies_add_domclick_affinity.sql
174_domclick_session_cookies.sql
175_scrape_schedules_seed_domclick_detail_backfill.sql
176_domrf_kapremont.sql
177_deals_city_region.sql
178_deal_city_price_bands.sql
179_scrape_schedules_seed_oblast_city_sweeps.sql
180_seed_sber_freshness_monitor.sql
181_clamp_bad_listing_dates.sql
182_trade_in_leads_consent_proof.sql
183_reenable_deactivate_stale_domklik.sql
184_user_events.sql
185_account_quota_overrides.sql
186_tg_support.sql
187_web_support_chat.sql
188_tg_support_chat_id_scope.sql
189_account_estimate_usage_nonnegative.sql
190_sale_share_price_bucket_signature.sql
191_account_quota_unlimited_flag.sql
192_tradein_users_auth.sql
193_tradein_users_seed.sql
194_deal_city_price_bands_tiers.sql
195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
196_listings_city.sql
197_backfill_listings_city_from_url.sql
198_scrape_proxy_rotations.sql
199_scrape_proxies_asocks_rotate_url.sql
200_region_code_foreign_cities.sql
201_purge_dead_mobileproxy_proxies.sql
202_listing_source_snapshot_budget_sec.sql
203_purge_geocode_cache_house_letter.sql
204_cian_oblast_sweeps_secondary.sql
205_sales_vs_listings_city_filter.sql
206_scrape_schedules_cut_wasteful_load.sql
207_backfill_yandex_cian_city_geo_cleanup.sql
208_reenable_domclick_detail_backfill.sql
209_scrape_proxies_disabled_reason.sql
210_scrape_proxy_source_bans.sql
211_sales_vs_listings_segment_guard.sql
212_sber_index_pull_weekly.sql
213_listings_snapshots_status_vocab.sql
214_drop_dead_run_metrics.sql
215_avito_full_load_window_matches_cadence.sql
216_dead_code_sweep.sql
#
# 2026-08-06: список догнан до факта прода. Проверка перед правкой —
# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было
# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено).
# Обратного расхождения нет: ни одной строки, которой не было бы на проде.
#
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
#
# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
# правки они уже слиты в main и применены на проде (см. _schema_migrations),
# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
# 222/225 по прецеденту 233_payments.sql.
222_db_audit_cleanup.sql
225_listing_source_snapshots_run_id_idx.sql
233_payments.sql
234_scrape_runs_ban_kind_unknown.sql
240_trade_in_estimates_retain_until.sql
250_drop_duplicate_expires_at_index.sql
251_listings_drop_ceiling_height.sql
254_listings_backfill_avito_rating_glued_address.sql

View file

@ -8,7 +8,7 @@
},
"low": {
"coverage_pct": 82.09,
"mape_pct": 13.2,
"mape_pct": 12.67,
"n": 276,
"n_covered": 220
},
@ -26,22 +26,22 @@
],
"expected_sold": {
"overall": {
"mape_pct": 13.18,
"median_bias_pct": -3.71,
"mape_pct": 12.63,
"median_bias_pct": -3.74,
"n": 269,
"n_no_analogs": 0,
"p25_pct": -16.11,
"p75_pct": 8.92
"p25_pct": -15.17,
"p75_pct": 8.67
},
"per_rooms": {
"0": {
"label": "студия",
"mape_pct": 19.38,
"median_bias_pct": 18.1,
"mape_pct": 16.96,
"median_bias_pct": 16.96,
"n": 35,
"n_no_analogs": 0,
"p25_pct": 1.5,
"p75_pct": 34.32
"p75_pct": 38.82
},
"1": {
"label": "1к",
@ -50,50 +50,50 @@
"n": 93,
"n_no_analogs": 0,
"p25_pct": -14.43,
"p75_pct": 6.98
"p75_pct": 8.15
},
"2": {
"label": "2к",
"mape_pct": 18.26,
"median_bias_pct": -12.2,
"mape_pct": 17.39,
"median_bias_pct": -11.71,
"n": 74,
"n_no_analogs": 0,
"p25_pct": -24.35,
"p25_pct": -23.07,
"p75_pct": -0.36
},
"3": {
"label": "3к",
"mape_pct": 9.34,
"median_bias_pct": -3.08,
"mape_pct": 7.79,
"median_bias_pct": -4.05,
"n": 43,
"n_no_analogs": 0,
"p25_pct": -10.26,
"p75_pct": 4.68
"p75_pct": 3.82
},
"4": {
"label": "4+",
"mape_pct": 16.3,
"median_bias_pct": 3.34,
"mape_pct": 16.38,
"median_bias_pct": -1.32,
"n": 24,
"n_no_analogs": 0,
"p25_pct": -14.91,
"p75_pct": 15.11
"p75_pct": 15.41
}
},
"per_segment": {
"бизнес": {
"mape_pct": 14.65,
"mape_pct": 13.65,
"median_bias_pct": -10.54,
"n": 46,
"p25_pct": -22.93,
"p25_pct": -27.15,
"p75_pct": -1.31
},
"комфорт": {
"mape_pct": 11.8,
"median_bias_pct": -4.61,
"mape_pct": 10.14,
"median_bias_pct": -5.01,
"n": 101,
"p25_pct": -17.07,
"p75_pct": 6.04
"p25_pct": -15.49,
"p75_pct": 4.37
},
"премиум": {
"mape_pct": 68.92,
@ -103,11 +103,11 @@
"p75_pct": -68.92
},
"эконом": {
"mape_pct": 13.71,
"median_bias_pct": 2.54,
"mape_pct": 14.2,
"median_bias_pct": 3.33,
"n": 115,
"p25_pct": -9.8,
"p75_pct": 25.5
"p25_pct": -8.69,
"p75_pct": 27.3
},
"элит": {
"mape_pct": 33.2,

View file

@ -113,9 +113,17 @@ class TestYandexHousePriority:
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
def test_house_has_panorama_yandex_valuation_only(self) -> None:
out = resolve_house_field("has_panorama", {"yandex_valuation": True})
assert out is True
def test_has_panorama_removed_from_house_priority(self) -> None:
"""#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259).
В отличие от ceiling_height выше, это правило было ИСПОЛНИМО колонка
существовала, единственный источник её писал. Разрешать было нечего:
yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде),
потому что слова «панорам» на странице оценки нет вовсе.
"""
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
assert "has_panorama" not in HOUSE_FIELD_PRIORITY
def test_house_yandex_total_listings_yandex_valuation_only(self) -> None:
out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42})
@ -217,7 +225,6 @@ class TestYandexListingPriority:
"corpus_count",
"total_area_ha",
"commission_month",
"has_panorama",
"yandex_total_listings",
]
for key in yandex_keys:

View file

@ -781,12 +781,19 @@ def _mock_enrich_transport(
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен).
#dadata-403-noise: это статичная конфигурация аккаунта (не транзиентный сбой) —
логируется на WARNING (не ERROR), чтобы ERROR продолжал значить «настоящий сбой»
(раньше logger.error на КАЖДЫЙ пользовательский запрос, 164 события в проде).
"""
from app.services import dadata
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
@ -794,6 +801,32 @@ async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
assert "Стандартизация" in text or "выключена" in text
# Не должны обвинять токен при feature-disabled.
assert "auth/secret rejected" not in text
# НЕ ERROR — статичная причина, не сбой (#dadata-403-noise).
assert not any(rec.levelno >= _logging.ERROR for rec in caplog.records)
async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog) -> None:
"""Второй (и далее) 403 CLEAN-disabled за один процесс → DEBUG, не повторный WARNING.
#dadata-403-noise: без троттлинга WARNING на каждый /estimate так же шумит логи,
как раньше шумел ERROR цель фикса теряется наполовину.
"""
from app.services import dadata
dadata._clean_disabled_warned = False
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.DEBUG, logger="app.services.dadata"):
first = await dadata.clean_address("Екатеринбург, Малышева 4")
caplog.clear()
second = await dadata.clean_address("Екатеринбург, Ленина 10")
assert first is None
assert second is None
# Второй вызов — НИ ОДНОГО WARNING/ERROR (только DEBUG или тише).
assert not any(rec.levelno >= _logging.WARNING for rec in caplog.records)
assert dadata._clean_disabled_warned is True
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:

View file

@ -1,4 +1,5 @@
"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616).
"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616,
ban-history ранжирование доп. #2825 от 2026-08-13).
Покрытие БЕЗ live-сети/БД: FakeSession эмулирует ДВА запроса над scrape_proxies +
scrape_proxy_source_bans основной SELECT кандидата (`_pick_candidate`) и, только
@ -6,11 +7,16 @@ scrape_proxy_source_bans — основной SELECT кандидата (`_pick_
"пул пуст" от "пул не пуст, все отсеяны".
- выбирается небанненный прокси;
- забаненный ДЛЯ ИСТОЧНИКА не выбирается;
- забаненный ДЛЯ ИСТОЧНИКА (АКТИВНО, banned_until > now()) не выбирается;
- забаненный для ДРУГОГО источника выбирается (суть #2600 п.2: Авито банит IP,
Яндекс через тот же IP ходит чисто);
- при нескольких кандидатах меньший consecutive_fails выигрывает;
- при равном consecutive_fails более свежий last_ok_at выигрывает;
Яндекс через тот же IP ходит чисто) включая случай, когда у него накопилась
ИСТОРИЯ банов по другому source: на ранжирование ДЛЯ ТЕКУЩЕГО source это не влияет;
- узел с историей банов (даже истёкшей) по ЭТОМУ source уступает чистому узлу без
истории, даже когда у чистого узла хуже consecutive_fails/last_ok_at;
- при равной истории (ban_count) работает прежний tie-break: меньший
consecutive_fails, затем более свежий last_ok_at;
- активный бан (banned_until > now()) по-прежнему полностью исключает узел, вне
зависимости от ban_count;
- пул ПУСТ (0 строк вообще) легитимный fallback на settings.scraper_proxy_url,
logger.WARNING с текстом «пуст»;
- пул пуст И SCRAPER_PROXY_URL не задан None (прямое подключение), WARNING;
@ -59,6 +65,14 @@ class FakeSession:
for b in self.bans
)
def _ban_count(self, pid: int, source: str) -> int:
"""COALESCE(b.ban_count, 0) семантика LEFT JOIN — история учитывается ДАЖЕ
если сама строка бана уже истекла (banned_until <= now(), ещё не спурженная)."""
for b in self.bans:
if b["proxy_id"] == pid and b["source"] == source:
return int(b.get("ban_count", 1))
return 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt)
p = params or {}
@ -67,7 +81,7 @@ class FakeSession:
max_fails = p["max_fails"]
source = p["source"]
if "pool_total" in sql: # _diagnose_no_candidate aggregate
if "pool_total" in sql: # _diagnose_no_candidate aggregate (активные баны, без истории)
unhealthy = sum(
1 for r in self.rows if not r["enabled"] or r["consecutive_fails"] >= max_fails
)
@ -88,7 +102,9 @@ class FakeSession:
]
)
# _pick_candidate primary SELECT
# _pick_candidate primary SELECT — LEFT JOIN на bans по source: активный бан
# по-прежнему исключает узел (WHERE), а ban_count (в т.ч. от истёкшего бана)
# ранжирует прошедших фильтр: сначала без истории (0), затем по возрастанию.
cands = [
r
for r in self.rows
@ -98,12 +114,15 @@ class FakeSession:
]
cands.sort(
key=lambda r: (
self._ban_count(r["id"], source),
r["consecutive_fails"],
-(r["last_ok_at"] or datetime.min.replace(tzinfo=UTC)).timestamp(),
r["id"],
)
)
return _FakeResult([dict(r) for r in cands[:1]])
return _FakeResult(
[{**r, "ban_count": self._ban_count(r["id"], source)} for r in cands[:1]]
)
def _proxy(
@ -207,6 +226,155 @@ def test_tiebreak_fresher_last_ok_at_wins_on_equal_fails() -> None:
assert result == "http://u:p@fresh.local:8080"
def test_clean_node_beats_node_with_expired_ban_history_for_source() -> None:
"""Замер на проде 2026-08-10: asocks-residential-1 отдавал 403 и cian, и avito, но
после истечения TTL всплывал первым, потому что consecutive_fails=0 у ОБОИХ узлов
и решал только свежий healthcheck. История (ban_count) ДОЛЖНА перевешивать даже
когда у банившегося узла лучше consecutive_fails/last_ok_at."""
now = datetime.now(UTC)
db = FakeSession(
[
_proxy(
1,
consecutive_fails=0,
last_ok_at=now, # свежее всех — раньше выиграл бы по старому правилу
url="http://u:p@chronic.local:8080",
),
_proxy(
2,
consecutive_fails=1,
last_ok_at=now - timedelta(hours=3),
url="http://u:p@clean.local:8080",
),
],
bans=[
{
"proxy_id": 1,
"source": "avito",
"banned_until": now - timedelta(hours=1), # ИСТЁК, но ban_count остаётся
"ban_count": 4,
}
],
)
result = resolve_proxy_url(db, "avito")
assert result == "http://u:p@clean.local:8080"
def test_no_history_node_beats_node_with_ban_count_one() -> None:
"""Узел БЕЗ ЕДИНОЙ строки истории (ban_count трактуется как 0) выигрывает у узла с
ban_count=1, даже при равном consecutive_fails/last_ok_at."""
now = datetime.now(UTC)
db = FakeSession(
[
_proxy(1, consecutive_fails=0, last_ok_at=now, url="http://u:p@once-banned.local:8080"),
_proxy(
2, consecutive_fails=0, last_ok_at=now, url="http://u:p@never-banned.local:8080"
),
],
bans=[
{
"proxy_id": 1,
"source": "cian",
"banned_until": now - timedelta(hours=2),
"ban_count": 1,
}
],
)
result = resolve_proxy_url(db, "cian")
assert result == "http://u:p@never-banned.local:8080"
def test_equal_ban_history_falls_back_to_prior_tiebreak() -> None:
"""При РАВНОМ ban_count у обоих узлов -- прежний порядок tie-break (consecutive_fails,
затем last_ok_at) без изменений."""
now = datetime.now(UTC)
db = FakeSession(
[
_proxy(
1,
consecutive_fails=2,
last_ok_at=now,
url="http://u:p@flaky-history.local:8080",
),
_proxy(
2,
consecutive_fails=0,
last_ok_at=now - timedelta(hours=1),
url="http://u:p@solid-history.local:8080",
),
],
bans=[
{
"proxy_id": 1,
"source": "yandex",
"banned_until": now - timedelta(hours=5),
"ban_count": 2,
},
{
"proxy_id": 2,
"source": "yandex",
"banned_until": now - timedelta(hours=5),
"ban_count": 2,
},
],
)
result = resolve_proxy_url(db, "yandex")
# Равный ban_count=2 у обоих -- решает consecutive_fails (0 < 2).
assert result == "http://u:p@solid-history.local:8080"
def test_ban_history_on_other_source_does_not_affect_ranking() -> None:
"""Высокий ban_count по source=cian у узла НЕ влияет на его ранжирование для
source=avito -- история строго per-source, ровно как активный бан (#2600 п.2)."""
now = datetime.now(UTC)
db = FakeSession(
[
_proxy(
1,
consecutive_fails=0,
last_ok_at=now,
url="http://u:p@cian-history-only.local:8080",
),
_proxy(
2,
consecutive_fails=0,
last_ok_at=now - timedelta(hours=2),
url="http://u:p@clean-everywhere.local:8080",
),
],
bans=[
{
"proxy_id": 1,
"source": "cian", # ДРУГОЙ source, не avito
"banned_until": now - timedelta(hours=1),
"ban_count": 9,
}
],
)
result = resolve_proxy_url(db, "avito")
# Для avito у узла 1 ban_count=0 (истории по avito нет) -- выигрывает по last_ok_at.
assert result == "http://u:p@cian-history-only.local:8080"
def test_active_ban_still_excludes_regardless_of_ban_count() -> None:
"""Активный бан по-прежнему полный фильтр -- ban_count=1 (низкий) не спасает узел
с АКТИВНЫМ баном от исключения."""
now = datetime.now(UTC)
db = FakeSession(
[_proxy(1, url="http://u:p@actively-banned.local:8080")],
bans=[
{
"proxy_id": 1,
"source": "avito",
"banned_until": now + timedelta(hours=6),
"ban_count": 1,
}
],
)
with pytest.raises(ProxyPoolExhaustedError):
resolve_proxy_url(db, "avito")
def test_empty_pool_falls_back_to_env_with_warning(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:

View file

@ -59,3 +59,37 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
# Гейт номеров миграций (#2683) сверяется с origin/main и точкой ветвления. Где
# git-эталона нет — прогон внутри prod-образа, экспорт исходников без .git —
# проверять не с чем, и тест это ГОВОРИТ вслух вместо тихого зелёного.
# В CI пропуска не бывает: при CI/GITHUB_ACTIONS та же ветка делает pytest.fail
# (отсутствие эталона в пайплайне — сломанный гейт, а не «нечего проверять»),
# а ci-tradein.yml/deploy-tradein.yml берут checkout с fetch-depth: 0 — при нём
# checkout сам приносит refs/remotes/origin/*, отдельный git fetch не нужен и
# из job-контейнера всё равно не проходит (run 6977, connection refused).
tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
# по-настоящему (Postgres + схема из data/sql) — там прогон и был красным на
# origin/main и зелёным на ветке. Статическая половина файла
# (test_retry_queue_is_not_bound_to_only_status) БД не требует и идёт в обоих лэйнах.
tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
# MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `live_session` fixture
# (self-skip через `_live_db_available()`, живёт только при реальном Postgres DSN).
# Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка
# физически не попадают в когорту (не только SQL-текст, который проверяется отдельным
# статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах).
tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live
# MAJOR-2 поведенческий пин (повторная проверка #2894) — та же `live_session` fixture.
# Ловит мутацию «убрать FILTER у percentile_cont, оставив у count(*)», которую
# текстовый тест test_max_age_outlier_days_passed_to_sql пропускал (подстрока
# `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне
# (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip.
tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live

View file

@ -15,6 +15,7 @@ import json
import os
import re
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")
@ -25,6 +26,9 @@ sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.tasks.yandex_detail_backfill import ( # noqa: E402
CANONICAL_URL_SQL,
OFFER_ID_PATTERN,
OFFER_URL_PATTERN,
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
@ -55,13 +59,21 @@ def _make_snapshot(n: int) -> list[dict]:
]
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
.scalar_one() размер отброшенной (непарсимой) части очереди."""
def _mock_db(
snapshot: list[dict],
unenrichable: int = 0,
url_from_offer_id: int = 0,
) -> MagicMock:
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() —
остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из
source_id / адресовать нечем)."""
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.scalar_one.return_value = unenrichable
sel.one.return_value = SimpleNamespace(
url_from_offer_id=url_from_offer_id,
unenrichable_pending=unenrichable,
)
db.execute.return_value = sel
return db
@ -510,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
assert result.unenrichable_pending == 3535
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535
# ---------------------------------------------------------------------------
# «Непригодно» — ярлык, а не диагноз (2026-08-12)
# ---------------------------------------------------------------------------
def _render_canonical_sql(offer_id: str) -> str:
"""Считает CANONICAL_URL_SQL как строку: '||' — конкатенация, source_id — значение."""
parts = [p.strip() for p in CANONICAL_URL_SQL.split("||")]
return "".join(offer_id if p == "source_id" else p.strip("'") for p in parts)
def test_recovered_url_equals_producer_canonical_form() -> None:
"""Адрес, вычисленный из source_id, — тот же, что пишет продюсер, и парсер его примет.
Шесть прогонов подряд unenrichable_pending равнялся ровно 3535 не потому, что
счётчик застыл (SELECT живой), а потому что множество замкнуто: продюсер после
#2235 таких строк больше не создаёт, а выйти оттуда нельзя — source_url пишется
только при вставке. Ярлык «непригодны» был неверен: у всех есть offerId, и по
собранному из него URL страница парсится (прод-проба 2026-08-12, 6/6).
Сторож держит ровно это: формула восстановления в SQL не должна разъехаться с
`_canonical_source_url` продюсера, а результат пройти гейт парсера.
"""
from scraper_kit.providers.yandex.serp import _canonical_source_url
offer_id = "7416316697684470413" # реальный source_id прод-строки с macroserver.ru
recovered = _render_canonical_sql(offer_id)
assert re.match(OFFER_ID_PATTERN, offer_id)
assert re.search(OFFER_URL_PATTERN, recovered), recovered
for stored_url, is_offer_url in _PROD_QUEUE_HEAD:
if is_offer_url:
continue # у этих сохранённый адрес уже канонический, чинить нечего
assert _canonical_source_url(stored_url, offer_id) == recovered, stored_url
@pytest.mark.asyncio
async def test_pending_counter_split_by_reason() -> None:
"""Остаток очереди делится по ПРИЧИНЕ, и восстановимое не зовётся непригодным.
Одно число на две разные судьбы («адрес чиним» и «адресовать нечем») читается
как «тут делать нечего» так 3535 квартир простояли неделю вне обогащения.
"""
db = _mock_db([], unenrichable=0, url_from_offer_id=3535)
runs = MagicMock()
session_cls, _session = _make_session_ctx([])
with (
patch(_ASYNC_SESSION, session_cls),
patch(_RUNS, runs),
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
):
result = await run_yandex_detail_backfill(
db, run_id=43, params={"batch_size": 10, "budget_sec": 60}
)
assert result.url_from_offer_id == 3535
assert result.unenrichable_pending == 0
counters = runs.mark_done.call_args.args[2]
assert counters["url_from_offer_id"] == 3535
assert counters["unenrichable_pending"] == 0
# Снапшот-SELECT берёт такие строки в работу по вычисленному адресу, а не
# выбрасывает: без этой ветки они не попадут в очередь никогда.
snapshot_sql = str(db.execute.call_args_list[0].args[0])
assert "OR source_id ~ CAST(:offer_id_pattern AS text)" in snapshot_sql
assert CANONICAL_URL_SQL in snapshot_sql

View file

@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None:
assert "DROP COLUMN IF EXISTS is_outlier" in sql
_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
def _latest_v_data_quality() -> tuple[str, str]:
"""(текст последней миграции, создающей v_data_quality; тело её SELECT).
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
деплой применяет файлы отсортированными, последний по имени последний в проде.
"""
creators = sorted(
p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8"))
)
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
sql = creators[-1].read_text(encoding="utf-8")
hit = _VIEW_MARKER.search(sql)
assert hit is not None
return sql, sql[hit.end() :].split(";")[0]
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
"""Действующее определение v_data_quality (последняя миграция, которая его
создаёт) не упоминает is_outlier.
"""Действующее определение v_data_quality не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
показатель, который не мог быть ненулевым, потому что колонку не писал никто.
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
деплой применяет файлы отсортированными, последний по имени последний в проде.
"""
marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8")))
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
latest = creators[-1].read_text(encoding="utf-8")
hit = marker.search(latest)
assert hit is not None
body = latest[hit.end() :].split(";")[0]
_, body = _latest_v_data_quality()
assert "outliers_flagged" not in body
assert "is_outlier" not in body
def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None:
"""Тот же класс, третий случай: pct_cadastr (мигр. 259).
Считался по listings.cadastral_number кадастру КВАРТИРЫ, которого не отдаёт ни
одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому
показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61%
читался как измеренное качество данных.
Red на origin/main: последний DDL там 222_db_audit_cleanup.sql, в нём строка
`... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте.
Замена источника на listings.building_cadastral_number НЕ починка: та колонка
про ЗДАНИЕ и целиком производная нашего ночного KNN 50 м, который #2674
замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление
в этой витрине.
"""
sql, body = _latest_v_data_quality()
assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality"
assert "cadastral_number" not in body, (
"в витрину подставили другой кадастр — под подписью «доля объявлений с "
"кадастром» это новая ложь вместо старой (см. шапку 259)"
)
# DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана
# выставить его заново, иначе объяснение «почему показателя нет» молча теряется.
if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql):
assert "COMMENT ON VIEW v_data_quality" in sql

View file

@ -0,0 +1,226 @@
"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры.
Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient`
(«требует ремонта» 6%, «евро» +10%), а домовой якорь из `house_imv_evaluations`
запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15`
и сам blend клали два разных базиса на одну шкалу и, поскольку blend
однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало
половину его 6% обратно наверх.
Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни
разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог
пересекали 19 9 из них «требует ремонта», и на них старый код давал медиану
на 3.39.8 % выше базис-согласованной (суммарно +4.13 млн ).
"""
import os
from typing import Any
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import _fetch_house_imv_anchor
# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки
# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и
# упали на ЦИФРАХ (красный по существу), а не на collection error.
# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽.
_PPM2 = 150_000.0
_AREA = 40.0
_BASE_MEDIAN = int(_PPM2 * _AREA)
def _make_listing(price_per_m2: float) -> dict[str, Any]:
return {
"id": int(price_per_m2),
"source": "avito",
"address": "ЕКБ, ул. Учителей, 18",
"price_rub": int(price_per_m2 * _AREA),
"price_per_m2": price_per_m2,
"area_m2": _AREA,
"rooms": 1,
"floor": 4,
"total_floors": 16,
"lat": 56.838,
"lon": 60.595,
"listing_date": None,
"days_on_market": None,
"source_url": f"https://example.test/{int(price_per_m2)}",
"photo_urls": [],
"distance_m": 10.0,
}
_ANALOGS = [
_make_listing(135_000.0),
_make_listing(140_000.0),
_make_listing(145_000.0),
_make_listing(_PPM2),
_make_listing(155_000.0),
_make_listing(160_000.0),
_make_listing(165_000.0),
]
def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any:
"""estimate_quality со всеми I/O застабленными; домовой якорь форсирован."""
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult
payload = TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=_AREA,
rooms=1,
floor=4,
total_floors=16,
repair_state=repair_state,
)
geo = GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
provider="nominatim",
)
async def _run() -> Any:
with (
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(_ANALOGS), False, "S"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
):
return await estimate_quality(payload, MagicMock())
return anyio.run(_run)
def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]:
return {
"recommended_price": recommended,
"lower_price": int(recommended * 0.97),
"higher_price": int(recommended * 1.05),
"market_count": 500,
"rooms": 1,
"area_m2": _AREA,
"renovation_type": renovation,
}
# ── чистая функция ───────────────────────────────────────────────────────────
def test_repair_factor_is_ratio_of_the_same_coefficients() -> None:
"""Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое."""
from app.services.estimator import _anchor_repair_factor
# строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94)
assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94
# строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00)
assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05
# строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10)
assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94
def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None:
"""Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать».
Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном
ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в
1.0 при дрейфе вокабуляра тихо вернул бы дефект поэтому базис, а не no-op.
"""
from app.services.estimator import _anchor_repair_factor
assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94
assert _anchor_repair_factor(None, "needs_repair") == 0.94
# Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента.
assert _anchor_repair_factor(None, None) == 1.0
assert _anchor_repair_factor("cosmetic", None) == 1.0
assert _anchor_repair_factor(None, "standard") == 1.0
# ── SQL-граница: без колонки правка молча выродится в no-op ──────────────────
def test_anchor_query_selects_renovation_type() -> None:
"""`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0.
Проверка строковая намеренно это единственная точка, где видно, доедет ли
колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону
границы получает dict уже от вызывающего и такую регрессию не увидит.
"""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA)
sql = str(db.execute.call_args[0][0])
assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0]
# ── сквозь estimate_quality: цена, которую видит пользователь ────────────────
def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
"""«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт.
median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000
якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000.
Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000
< 6 486 000 медиана остаётся 5 640 000 (8.6 % к старому поведению).
"""
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 "")
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
"""Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен.
median = 6 000 000 (coef 1.0). Старый порог 6 900 000 якорь 7 000 000 его
перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro'
стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 медиана не двигается.
"""
est = _run_estimate(_anchor(7_000_000, "euro"), None)
assert est.median_price_rub == _BASE_MEDIAN
def test_blend_still_fires_and_reports_the_rebased_anchor() -> None:
"""Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится.
median = 5 640 000, якорь 'cosmetic' 10 000 000 в базисе клиента 9 400 000
> 6 486 000 blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении
ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы
с карточкой, где показана сырая оценка Avito).
"""
est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair")
assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2
explanation = est.confidence_explanation or ""
assert "9.4 млн ₽" in explanation
assert "приведённой к состоянию ремонта квартиры" in explanation
# Карточка Avito остаётся сырой — чужое число мы не правим.
assert est.avito_imv is not None
assert est.avito_imv.recommended_price == 10_000_000

View file

@ -0,0 +1,86 @@
"""#2687: распознанный QRATOR-блок домкликового свипа — это 'platform', не 'unknown'.
Ветка `if counters.blocked:` в `run_domclick_city_sweep` входится ТОЛЬКО когда
скрейпер поднял `DomClickBlockedError` (площадка отдала QRATOR block-страницу). Это
ровно определение `BAN_KIND_PLATFORM`. До правки свип звал `mark_banned` без
`ban_kind`, и все его баны падали в дефолт 'unknown' (#2764) — диагноз был
УСТАНОВЛЕН (блок распознан), но не передавался, и мониторинг не мог отличить
«площадка отбила» (platform) от «тракт/прокси» (infra).
Прод 2026-08-12: domclick_city_sweep 3751/3675/3594 все `banned` с `blocked=1`,
`ban_kind='unknown'`. На старом коде тест ниже падает (ban_kind='unknown').
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
PFX = "scraper_kit.orchestration.pipeline"
class _BanKindRecorder:
"""Ловит ban_kind, переданный в mark_banned (остальное — no-op)."""
def __init__(self) -> None:
self.ban_kind: str | None = None
self.marked: list[str] = []
def is_cancelled(self, db: Any, run_id: int) -> bool:
return False
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
return None
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
self.marked.append("done")
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.marked.append("failed")
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
self.marked.append("banned")
self.ban_kind = kw.get("ban_kind")
async def _drive_blocked() -> _BanKindRecorder:
recorder = _BanKindRecorder()
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)])
scraper.blocked = True # распознанный QRATOR-блок
scraper.geo_filtered = 0
scraper.fetch_errors = 1
scraper.buckets_completed = 2
scraper.buckets_total = 6
with (
patch(f"{PFX}.DomClickScraper", return_value=scraper),
patch(f"{PFX}.save_listings", MagicMock(return_value=(39, 0))),
patch(f"{PFX}.runs", recorder),
):
await run_domclick_city_sweep(
MagicMock(),
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
run_id=1,
city_id=4,
pages=1,
request_delay_sec=0.0,
)
return recorder
async def test_qrator_block_marks_platform_ban_kind() -> None:
"""Блок распознан → ban_kind='platform'. На старом коде было 'unknown' (дефолт)."""
recorder = await _drive_blocked()
assert recorder.marked[-1] == "banned"
assert recorder.ban_kind == "platform"

View file

@ -153,28 +153,38 @@ async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.Monke
@pytest.mark.parametrize(
("source", "must_contain"),
("source", "must_contain", "path_suffix"),
[
("avito", "www.avito.ru"),
("cian", "ekb.cian.ru"),
("yandex", "realty.yandex.ru"),
("avito", "www.avito.ru", "/robots.txt"),
("cian", "ekb.cian.ru", "/robots.txt"),
("yandex", "realty.yandex.ru", "/robots.txt"),
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
("domclick", "bff-search-web.domclick.ru"),
#
# #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*,
# robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4
# в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по
# нему зелёная ровно тогда, когда свип получает блок.
("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"),
],
)
async def test_probe_asks_the_working_host_of_each_source(
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str
) -> None:
seen: dict[str, Any] = {}
# Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» —
# 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа.
_body = (
'{"result":{"snippetsCount":678}}' if source == "domclick" else "<pre>User-agent: *</pre>"
)
class _Resp:
status_code = 200
text = '{"html": "<pre>User-agent: *</pre>"}'
text = "{}"
@staticmethod
def json() -> dict[str, str]:
return {"html": "<pre>User-agent: *</pre>"}
return {"html": _body}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
@ -196,7 +206,10 @@ async def test_probe_asks_the_working_host_of_each_source(
assert ok is True
assert must_contain in seen["payload"]["url"]
assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
# #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это
# боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть
# нагрузка на площадку остаётся минимальной, а защита — той же, что у работы.
assert path_suffix in seen["payload"]["url"]
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
assert seen["payload"]["source"] == "generic"
@ -233,7 +246,10 @@ async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.Monkey
assert ok is False
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
assert fail_kind == "page"
assert "not robots.txt" in detail
# #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от
# площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика).
# Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили.
assert "no marker 'User-agent'" in detail
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
@ -454,3 +470,72 @@ async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPat
lease = acquire(db, "domclick") # type: ignore[arg-type]
assert lease is not None
release(db, lease.id) # type: ignore[arg-type]
# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ──
@pytest.mark.parametrize(
("api_body", "expect_ok"),
[
# Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом.
# На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True,
# то есть «пара здорова» ровно там, где свип получает блок. Красный на main.
("<html><title>Ошибка</title>qrator captcha</html>", False),
# Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность
# ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай
# и провалила бы этот, то есть тест не может позеленеть от глухого отказа.
# На origin/main этот случай тоже красный, но по другой причине — там проба
# спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА.
('{"result":{"snippetsCount":678}}', True),
],
)
async def test_domclick_probe_sees_the_block_that_robots_txt_hides(
monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool
) -> None:
"""Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок.
Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` 05:02 свип
`QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и
5 банов на все четыре площадки при ежедневном блоке Домклика.
"""
seen: dict[str, Any] = {}
class _Resp:
status_code = 200
text = "{}"
def __init__(self, body: str) -> None:
self._body = body
def json(self) -> dict[str, str]:
return {"html": self._body}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
async def __aenter__(self) -> _Client:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
seen["payload"] = json
asked = str(json["url"])
# Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт.
if asked.endswith("/robots.txt"):
return _Resp("<pre>User-agent: *</pre>")
return _Resp(api_body)
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
ok, fail_kind, _detail = await bf.probe_proxy_via_browser(
"http://tradein-browser:3000", "http://u:p@node:8080", source="domclick"
)
assert ok is expect_ok
if not expect_ok:
# Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy".
assert fail_kind == "page"
# Спрашивали именно боевой путь, а не robots.txt того же хоста.
assert "/api/offers/" in seen["payload"]["url"]

View file

@ -0,0 +1,145 @@
"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674).
`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо
в определении: district, distance_to_metro_m, last_price_change, photos_count.
Это не потеря данных и не оборванный писатель имена зарезервировали, реализацию
не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты)
и они сняты миграцией 261; district оставлен намеренно он объявлен в
schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее
изменение контракта (решение владельца, вынесено отдельно в #2857).
Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её
актуального определения (самый старший NN среди файлов, создающих витрину) и
смотрит на состав списка. Переформатирование SQL, перестановка строк или смена
`NULL::int` на `NULL::integer` тест не трогают; возврат колонки краснит.
Без БД и сети: миграции читаются как текст, разбираются в структуру.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
MV = "listings_search_mv"
# Сняты 261: ноль читателей во всём репозитории на момент сноса.
DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count")
def _strip_sql_comments(sql: str) -> str:
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
return re.sub(r"--[^\n]*", "", sql)
def _latest_definition() -> str:
"""Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид."""
creators = [
p
for p in SQL_DIR.glob("*.sql")
if re.search(
rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b",
_strip_sql_comments(p.read_text("utf-8")),
re.I,
)
]
assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда"
return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8")
def mv_columns() -> list[str]:
"""Имена колонок витрины в порядке объявления.
Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым
той же глубины, имя колонки последний идентификатор элемента (алиас после
`AS` либо хвост `l.foo`).
"""
sql = _strip_sql_comments(_latest_definition())
body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1]
depth, items, cur = 0, [], []
for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I):
t = token.group(0)
if t == "(":
depth += 1
elif t == ")":
depth -= 1
elif depth == 0 and t == ",":
items.append("".join(cur))
cur = []
continue
elif depth == 0 and t.upper() == "FROM":
break
cur.append(t)
items.append("".join(cur))
return [item.split()[-1].split(".")[-1] for item in items if item.split()]
def test_placeholder_columns_are_gone_from_the_matview() -> None:
"""Red => витрина снова обещает поля, которых не заполняет (#2857).
Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел
после возврата колонки сначала заведи писателя, потом колонку, а не наоборот.
"""
cols = mv_columns()
still_there = [c for c in DROPPED if c in cols]
assert not still_there, (
f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя "
"читается снаружи как «данные есть, просто у этого объекта пусто» — это "
"хуже мёртвого кода, потому что видно в контракте."
)
def test_district_is_deliberately_kept() -> None:
"""Red => district снесли заодно, а он в схеме ответа API.
schemas/search_response.py объявляет `district: str | None`, services/search_query.py
его тянет снос ломает контракт /api/v1/search. Это решение владельца (#2857),
а не побочный эффект уборки соседних заглушек. Убирать вместе со схемой ответа.
"""
assert "district" in mv_columns(), (
f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: "
"ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой."
)
def test_search_api_selects_only_columns_the_matview_has() -> None:
"""Настоящий инвариант: то, что просит API, витрина обязана иметь.
Именно эта проверка отличает «список колонок» от «поиска подстроки»: она
краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх
известных именах.
"""
from app.schemas.search import SearchParams
from app.services.search_query import build_search_query
sql, _ = build_search_query(SearchParams())
selected = [
c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip()
]
missing = [c for c in selected if c not in mv_columns()]
assert not missing, (
f"services/search_query.py просит у {MV} колонки, которых в её определении нет: "
f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из "
"schemas/search_response.py."
)
def test_unique_index_for_concurrent_refresh_survives_recreation() -> None:
"""Red => ночной REFRESH ... CONCURRENTLY упадёт.
app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание
refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает
«cannot refresh materialized view concurrently ... no unique index» а витрина,
которую пересоздали и забыли проиндексировать, молчит до самой ночи.
"""
sql = _strip_sql_comments(_latest_definition())
assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), (
f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — "
"REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен."
)

View file

@ -0,0 +1,295 @@
"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона.
#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket
heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое),
но единственным входом оставил админку. У avito full-load админского эндпоинта нет
вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799
на origin/main). То есть боевой путь возобновления не существовал ни одного дня.
Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных
прогонах с ЖИВОЙ незабранной точкой avito_full_load 242, cian_full_load 134,
avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из
84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08.
Красный прогон на origin/main:
1. `test_scheduler_hands_checkpoint_to_pipeline` планировщик отдаёт в пайплайн
resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках);
2. `test_partial_bucket_is_not_complete` бакет с выпавшей страницей приезжает в
on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще);
3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` TypeError: `_on_bucket`
на main принимает два аргумента, признаку полноты некуда приехать.
Тесты ладдера (`_resume_decision`) на main падают с AttributeError функции нет.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import scheduler as sched
from scraper_kit.orchestration.pipeline import run_avito_full_load
from scraper_kit.providers.avito.serp import AvitoScraper
PFX = "scraper_kit.orchestration.pipeline"
# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона
# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание».
_PARAMS = {
"concurrency": 1,
"interval_days": 7,
"secondary_only": True,
"request_delay_sec": 7.0,
"price_cap_per_bucket": 1400,
}
def _candidate(**over: Any) -> SimpleNamespace:
"""Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде."""
base = {
"prev_id": 3547,
"prev_status": "cancelled",
"prev_counters": {
"unique_fetched": 5496,
"done_buckets": [f"room_1_komn:{i}:0" for i in range(35)],
},
"same_params": True,
"age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08
"interval_days": "7",
}
base.update(over)
return SimpleNamespace(**base)
class _FakeDb:
"""Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE."""
def __init__(self, row: Any) -> None:
self.row = row
self.written: list[dict[str, Any]] = []
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
if params and "counters" in params: # update_heartbeat пишет вердикт
self.written.append(params)
return MagicMock()
return MagicMock(fetchone=lambda: self.row)
def commit(self) -> None:
pass
# ── 1. Главное: планировщик обязан отдать точку в пайплайн ───────────────────
@pytest.mark.parametrize(
("job", "pipeline_fn"),
[
(sched._job_avito_full_load, "run_avito_full_load"),
(sched._job_avito_full_load_exhaustive, "run_avito_full_load"),
(sched._job_cian_full_load, "run_cian_full_load"),
],
)
async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None:
"""Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля.
Падает на origin/main: планировщик передаёт литеральный None 433 корзины за 90
суток перебирались заново, включая 35 корзин прогона 3547.
"""
db = _FakeDb(_candidate())
captured: dict[str, Any] = {}
async def _spy(*_a: Any, **kw: Any) -> None:
captured.update(kw)
with patch.object(sched, pipeline_fn, _spy):
await job(db, 4000, dict(_PARAMS), MagicMock())
assert captured["resume_run_id"] == 3547
async def test_verdict_lands_in_counters_of_new_run() -> None:
"""Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах.
Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому
молчаливый отказ подхватить неотличим от отсутствия правки.
"""
db = _FakeDb(_candidate(prev_status="zombie"))
with patch.object(sched, "run_avito_full_load", AsyncMock()):
await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock())
assert db.written, "вердикт о подхвате не записан в counters нового прогона"
written = db.written[-1]["counters"]
assert '"resume_reason": "status_zombie"' in written
assert '"resume_candidate": 3547' in written
# ── 2. Ладдер отказов: у каждого нуля своя причина ───────────────────────────
@pytest.mark.parametrize(
("row", "reason"),
[
(None, "no_prev_run"),
(_candidate(prev_status="done"), "status_done"),
(_candidate(prev_status="zombie"), "status_zombie"),
(_candidate(same_params=False), "params_changed"),
(_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"),
(_candidate(age_h=200.0), "checkpoint_stale"),
(_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"),
],
)
def test_resume_refusals_are_named(row: Any, reason: str) -> None:
"""«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы."""
resume_id, verdict = sched._resume_decision(row)
assert resume_id is None
assert verdict["resume_reason"] == reason
assert verdict["resume_from"] is None
def test_resume_chain_is_bounded() -> None:
"""Цепочка возобновлений считается и упирается в потолок, а не тянется вечно.
Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход
обязан начаться раньше, чем сводка объявит источник просроченным.
"""
assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1
_id, first = sched._resume_decision(_candidate())
assert first["resume_chain"] == 1
_id2, second = sched._resume_decision(
_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1})
)
assert second["resume_chain"] == sched._MAX_RESUME_CHAIN
third_id, third = sched._resume_decision(
_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2})
)
assert third_id is None and third["resume_reason"] == "chain_limit"
def test_stale_threshold_follows_the_source_tick() -> None:
"""Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой.
cian ходит раз в 3 суток, avito раз в 7; одна и та же точка возрастом 100 ч для
первого просрочена, для второго свежая. Плюс сутки сетка запуска (см.
_resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе
точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) нет.
"""
assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None
assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547
assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547
assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None
# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ─────────────────
def _serp_config() -> SimpleNamespace:
return SimpleNamespace(
scraper_fetch_mode="curl_cffi",
browser_http_endpoint="http://browser.test/fetch",
scraper_proxy_url=None,
avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
scraper_skip_seen_today=False,
)
@pytest.mark.parametrize(
("page2_html", "expected_complete"),
[(None, False), ("<page2/>", True)],
)
async def test_partial_bucket_is_not_complete(
page2_html: str | None, expected_complete: bool
) -> None:
"""Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан».
Контрольная половина обязательна: реализация «всегда False» тоже прошла бы
одностороннюю проверку, но убила бы возобновление целиком.
Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты
в протоколе нет частичный бакет неотличим от целого и попадает в done_buckets.
"""
scraper = AvitoScraper(_serp_config())
scraper.request_delay_sec = 0.0
calls: list[tuple[str, bool]] = []
def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
calls.append((key, complete))
async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None:
return page2_html if page == 2 else f"<page{page}/>"
with (
patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page),
patch.object(
AvitoScraper,
"_parse_html",
lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")],
),
):
await scraper._paginate_leaf_bucket(
room_slug="kvartiry_1_komnatnye",
room_label="room_1_komn",
lo=0,
hi=3999999,
html="<page1/>",
max_pages=3,
seen={},
price_cap_per_bucket=1400,
max_pages_per_bucket=100,
concurrency=2,
secondary_only=False,
on_bucket=_on_bucket,
skip_buckets=None,
expected_total=3 * 50,
)
assert [c[1] for c in calls] == [expected_complete]
async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None:
"""Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал.
Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить
частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда.
"""
finals: list[dict[str, Any]] = []
class _Recorder:
def is_cancelled(self, *_a: Any, **_k: Any) -> bool:
return False
def update_heartbeat(self, *_a: Any, **_k: Any) -> None:
pass
def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None:
finals.append(dict(counters))
async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True)
on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False)
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_all_secondary = _fetch
with (
patch(f"{PFX}.AvitoScraper", return_value=scraper),
patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
patch(f"{PFX}.runs", _Recorder()),
):
counters = await run_avito_full_load(
MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock()
)
assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"]
assert finals[0]["partial_buckets"] == 1
assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены"

View file

@ -102,14 +102,32 @@ def test_harness_itself_drops_warnings() -> None:
class _FakeMonitorDB:
"""Session-мок мониторов свежести: один SELECT max(...)."""
"""Session-мок мониторов свежести.
def __init__(self, latest: date | None) -> None:
Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает
ещё время последнего ПОЛНОГО прогона загрузки и её такт именно они, а не
календарный возраст периода, решают, быть ли тревоге.
"""
def __init__(
self,
latest: date | None,
last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC),
interval_days: str = "7",
) -> None:
self._latest = latest
self._last_pull = last_pull
self._interval_days = interval_days
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
result = MagicMock()
result.first.return_value = MagicMock(latest=self._latest)
if "scrape_runs" in sql:
result.first.return_value = MagicMock(last_pull=self._last_pull)
elif "scrape_schedules" in sql:
result.first.return_value = MagicMock(interval_days=self._interval_days)
else:
result.first.return_value = MagicMock(latest=self._latest)
return result
def rollback(self) -> None:
@ -122,10 +140,13 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None:
monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None)
def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие."""
def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
"""Загрузка встала (полный прогон 20 суток назад при такте 7) → событие.
20 суток реальный разрыв прод-истории между полными прогонами 07-17 и 08-06.
"""
_patch_runs(monkeypatch, sber_mon)
db = _FakeMonitorDB(date(2026, 6, 1))
db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC))
with glitchtip_events() as events:
out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type]
@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
assert out["alert"] == 1
assert any(
"sber freshness" in t for t in event_texts(events)
), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает"
), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает"
def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
"""Свежие данные — ни одного события (иначе алерт-усталость)."""
def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет.
Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR:
возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость
от таких событий и делает настоящий отказ незаметным.
"""
_patch_runs(monkeypatch, sber_mon)
db = _FakeMonitorDB(date(2026, 6, 1))
db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон)
with glitchtip_events() as events:
out = sber_mon.check_sber_freshness(
db, # type: ignore[arg-type]
run_id=2,
params={},
now=datetime(2026, 6, 20, tzinfo=UTC),
now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC),
)
assert out["alert"] == 0
assert event_texts(events) == []
@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None
params={},
now=datetime(2026, 8, 6, tzinfo=UTC),
)
assert any("sber_price_index пуст" in t for t in event_texts(events))
assert any("у оценщика нет серии" in t for t in event_texts(events))
def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -5,7 +5,17 @@
1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30
(494 попытки 0 обогащено, 63 блока, 431 fail и все 30 'done').
Проверяем ровно ветвление mark_backfill_finished БД замокана.
Проверяем ровно ветвление mark_backfill_finished БД замокана (mark_done/mark_failed/
mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы
ниже с высокой долей отказов (attempted=50, failed=38 или 36 76%/72%), ожидающие
'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674:
"обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С
honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed'
через _failed_ratio_too_high (доля отказов >= 0.5) реальный терминальный статус
для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется
отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py
(test_prod_fact_avito_15_08_no_longer_done и соседние) не читай эти два кейса как
"76%/72% отказов = 'done' в проде".
"""
from __future__ import annotations
@ -59,9 +69,15 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st
({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"),
# Кандидатов не было — честная пустота, это успех.
({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"),
# Частичный прогон: обогатили хоть что-то → успех.
# Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ
# mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done
# замокан, поэтому статус остаётся 'done'; в реальном mark_done с
# honest-run-status (2026-08-15) это переквалифицируется в 'failed'
# (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля.
({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"),
# Блоки были, но прогон доработал и обогатил — не бан.
# Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает
# НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done
# переквалифицирует в 'failed', см. докстринг модуля выше.
({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"),
# Блок оборвал прогон, хотя часть успели обогатить — работа не доделана.
({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"),

View file

@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path():
patch("app.services.house_imv_backfill.save_imv_result") as mock_save,
):
mock_mappings = MagicMock()
mock_mappings.all.return_value = houses
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
mock_mappings.all.return_value = houses
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found():
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
):
mock_mappings = MagicMock()
mock_mappings.all.return_value = houses
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
mock_mappings.all.side_effect = [[], houses]
mock_db.execute.return_value.mappings.return_value = mock_mappings
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)

View file

@ -31,20 +31,41 @@ def test_ekb_anchors_count() -> None:
def test_resolve_city_name_known_oblast_slugs() -> None:
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя."""
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.
expected = {
#262 wave 2: CITY_LOCATIONS выросла с 5 (wave 1) до 45 (wave 1 + 40 wave-2
городов) вместо хардкода полного списка (дублировал бы CITY_DISPLAY_NAMES и
ломался при каждом новом городе) проверяем структурный инвариант: CITY_DISPLAY_
NAMES обязан покрывать РОВНО те же slug'и, что CITY_LOCATIONS (иначе oblast-город
бы тихо получил ЕКБ-дефолт вместо своего имени) + spot-check wave-1 (не тронуты
этим PR) и по одному wave-2 city из каждой tier-группы (все три id / только
cian+yandex / только cian).
"""
from scraper_kit.orchestration.pipeline import (
CITY_DISPLAY_NAMES,
CITY_LOCATIONS,
resolve_city_name,
)
wave1_expected = {
"nizhniy_tagil": "Нижний Тагил",
"kamensk_uralskiy": "Каменск-Уральский",
"pervouralsk": "Первоуральск",
"verkhnyaya_pyshma": "Верхняя Пышма",
"serov": "Серов",
}
# CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS
# (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени).
assert set(expected) == set(CITY_LOCATIONS)
for slug, name in expected.items():
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS)
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
for slug, name in wave1_expected.items():
assert resolve_city_name(slug) == name
# wave-2 spot-check: novouralsk (avito+cian+yandex), revda (cian+yandex, avito
# НЕ подтверждён), mikhaylovsk (только cian, yandex отсутствует у источника).
for slug, name in {
"novouralsk": "Новоуральск",
"revda": "Ревда",
"mikhaylovsk": "Михайловск",
}.items():
assert slug in CITY_LOCATIONS
assert resolve_city_name(slug) == name
@ -62,6 +83,56 @@ def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный
# ЕКБ-fallback) ───────────────────────────────────────────────────────────────────
async def test_run_avito_city_sweep_raises_on_known_city_without_avito_slug() -> None:
"""revda — известный CITY_LOCATIONS город, но avito_slug=None (не подтверждён).
run_avito_city_sweep обязан упасть ДО любого сетевого/DB похода иначе
`_city_seg()` молча взяла бы 'ekaterinburg' и sweep собрал бы ЕКБ под меткой
'revda'. Raise происходит в самом начале функции (до await) MagicMock() для
config/matcher/enrichment безопасен, до них не доходит."""
from scraper_kit.orchestration.pipeline import run_avito_city_sweep
with pytest.raises(ValueError, match="revda"):
await run_avito_city_sweep(
MagicMock(),
run_id=1,
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
city_slug="revda",
)
async def test_run_yandex_city_sweep_raises_on_known_city_without_yandex_rgid() -> None:
"""mikhaylovsk — известный CITY_LOCATIONS город, но yandex_rgid=None (город
отсутствует в гео-базе Яндекса вообще). run_yandex_city_sweep обязан упасть ДО
любого сетевого/DB похода иначе YandexRealtyScraper(city_rgid=None) молча
взял бы ЕКБ rgid."""
from scraper_kit.orchestration.pipeline import run_yandex_city_sweep
with pytest.raises(ValueError, match="mikhaylovsk"):
await run_yandex_city_sweep(
MagicMock(),
run_id=1,
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
city_slug="mikhaylovsk",
)
async def test_run_avito_city_sweep_does_not_raise_for_ekb_none_city_slug() -> None:
"""city_slug=None (ЕКБ-путь, back-compat) НЕ должен затронуть новый guard — guard
условие `_loc is not None`, а get_city_location(None) возвращает None."""
from scraper_kit.orchestration.pipeline import get_city_location
assert get_city_location(None) is None
# ── CitySweepCounters ───────────────────────────────────────────────────────

View file

@ -15,8 +15,19 @@ WHAT:
LeadForm.tsx (regex, no JSX parser needed -- there is exactly one <span>
in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for-
byte after whitespace normalisation (JSX text nodes wrap across source
lines; the DOM-rendered text collapses that to single spaces). If someone
edits ONE side without the other, this test fails.
lines; the DOM-rendered text collapses that to single spaces). The label
now wraps a `<Link>` ("Политикой обработки персональных данных" is a
clickable link to the actual policy document, RKN/owner requirement --
the extractor strips JSX tags AND `{" "}` expression-spacers, keeping only
the human-readable text, so the comparison stays a FLAT string on both
sides). If someone edits ONE side without the other, this test fails.
A second test (`test_consent_policy_version_matches_privacy_approval_date`)
guards the OTHER half of the same drift class found during triage: nothing
was checking that _CONSENT_POLICY_VERSION actually points at the privacy
policy edition it claims to (PRIVACY_APPROVAL in mera-public/content.ts).
Bumping the policy text without bumping the version tag (or vice versa)
would silently mislabel every lead's proof-of-consent snapshot.
NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT
in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet
@ -34,25 +45,71 @@ from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_REPO_ROOT = Path(__file__).resolve().parents[2]
_FRONTEND_LEAD_FORM = (
Path(__file__).resolve().parents[2]
/ "frontend"
/ "src"
/ "components"
/ "trade-in"
/ "v2"
/ "LeadForm.tsx"
_REPO_ROOT / "frontend" / "src" / "components" / "trade-in" / "v2" / "LeadForm.tsx"
)
_FRONTEND_LEGAL_CONTENT = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts"
# Родительный падеж месяцев, как их пишет владелец в content.ts ("13 августа 2026 г.").
_RU_MONTHS_GENITIVE = {
"января": 1,
"февраля": 2,
"марта": 3,
"апреля": 4,
"мая": 5,
"июня": 6,
"июля": 7,
"августа": 8,
"сентября": 9,
"октября": 10,
"ноября": 11,
"декабря": 12,
}
def _extract_span_text(tsx_source: str) -> str:
"""Pull the text content of the (single) <span>...</span> in LeadForm.tsx,
whitespace-normalised the same way a browser collapses JSX text-node
whitespace when rendering (multiple lines/indentation -> single spaces).
The span may contain nested JSX markup (e.g. a <Link> wrapping part of the
label, and a `{" "}` expression-spacer forcing a real space between a text
node and the link on the next source line -- plain JSX whitespace between
a text node and a tag on separate lines collapses to NOTHING, not a
space, so LeadForm.tsx needs that explicit spacer for correct rendering).
Both are stripped here so the comparison is against the flat, human-
readable text a user actually sees -- not the markup.
"""
match = re.search(r"<span>\s*(.*?)\s*</span>", tsx_source, re.DOTALL)
match = re.search(r"<span>(.*?)</span>", tsx_source, re.DOTALL)
assert match is not None, "no <span> found in LeadForm.tsx -- consent label markup changed"
return re.sub(r"\s+", " ", match.group(1)).strip()
inner = match.group(1)
inner = re.sub(r"\{\s*[\"']\s*[\"']\s*\}", " ", inner) # {" "} spacer -> real space
inner = re.sub(r"\{/\*.*?\*/\}", " ", inner, flags=re.DOTALL) # JSX comments
inner = re.sub(r"<[^>]+>", "", inner) # strip remaining JSX tags (e.g. <Link ...>, </Link>)
return re.sub(r"\s+", " ", inner).strip()
def _extract_privacy_approval_iso_date(content_ts_source: str) -> str:
"""Pull the "DD <month genitive> YYYY" date out of PRIVACY_APPROVAL in
mera-public/content.ts and return it as an ISO "YYYY-MM-DD" string.
PRIVACY_APPROVAL ("приказом директора № 1 от 13 августа 2026 г.") is the
order that approves the actual privacy-policy EDITION the consent
checkbox links to (/mera-public/privacy) -- it is the correct source of
truth for _CONSENT_POLICY_VERSION, as opposed to LEGAL_DOCS_REVISION
(which dates the offer + refund-policy documents, a different pair).
"""
match = re.search(r'PRIVACY_APPROVAL\s*=\s*"([^"]+)"', content_ts_source)
assert match is not None, "PRIVACY_APPROVAL constant not found in mera-public/content.ts"
date_match = re.search(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", match.group(1))
assert date_match is not None, f"no RU date found in PRIVACY_APPROVAL: {match.group(1)!r}"
day, month_name, year = date_match.groups()
month = _RU_MONTHS_GENITIVE.get(month_name)
assert month is not None, f"unknown RU month name in PRIVACY_APPROVAL: {month_name!r}"
return f"{year}-{month:02d}-{int(day):02d}"
def test_frontend_lead_form_exists() -> None:
@ -78,6 +135,29 @@ def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None:
)
def test_consent_policy_version_matches_privacy_approval_date() -> None:
"""Guards the other half of the same drift class as the test above:
_CONSENT_POLICY_VERSION must point at the privacy-policy EDITION it
claims to (PRIVACY_APPROVAL in mera-public/content.ts), not just be some
unrelated date bumped by hand. A silent mismatch here would mislabel
every lead's proof-of-consent snapshot with the wrong policy edition."""
from app.api.v1.lead import _CONSENT_POLICY_VERSION
assert _FRONTEND_LEGAL_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_LEGAL_CONTENT}"
expected_version = _extract_privacy_approval_iso_date(
_FRONTEND_LEGAL_CONTENT.read_text(encoding="utf-8")
)
assert _CONSENT_POLICY_VERSION == expected_version, (
"app/api/v1/lead.py._CONSENT_POLICY_VERSION does not match the privacy-policy "
"edition date derived from PRIVACY_APPROVAL in frontend/src/app/mera-public/"
"content.ts. Bump _CONSENT_POLICY_VERSION to the new edition date whenever "
"PRIVACY_APPROVAL changes (or vice versa).\n"
f" _CONSENT_POLICY_VERSION: {_CONSENT_POLICY_VERSION!r}\n"
f" PRIVACY_APPROVAL date: {expected_version!r}"
)
def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
"""Sanity check on the extraction helper itself, independent of the real file."""
sample = """
@ -87,3 +167,28 @@ def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
</span>
"""
assert _extract_span_text(sample) == "Line one Line two"
def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None:
"""Sanity check: a <Link> wrapping part of the label (plus the {" "}
spacer JSX needs to force a real space before it) must collapse to plain
text, exactly like a browser renders it -- this is the shape LeadForm.tsx
actually uses today for the policy-document link."""
sample = """
<span>
Согласен(-на) на обработку персональных данных в соответствии с{" "}
<Link href={PRIVACY_PATH} target="_blank" rel="noreferrer">
Политикой обработки персональных данных
</Link>
</span>
"""
assert _extract_span_text(sample) == (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Политикой обработки персональных данных"
)
def test_extract_privacy_approval_iso_date_helper() -> None:
"""Sanity check on the RU-date extraction helper, independent of the real file."""
sample = 'export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";'
assert _extract_privacy_approval_iso_date(sample) == "2026-08-13"

View file

@ -0,0 +1,665 @@
"""Tests for POST /api/v1/trade-in/coverage (issue #2894).
Бесплатная проба покрытия для публичного лэндинга «МЕРА» до оплаты человек
видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без
единой рублёвой цифры в ответе. Covers:
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
- пустая когорта (n=0) not_covered даже в поддерживаемом городе; threshold
принудительно 0 в этом случае (nit-fix, повторная проверка #2894)
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
- MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city',
price_rub > 0), не шире когорты платного эстиматора
- MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке,
выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану запинено ЖИВЫМ SQL
(см. test_max_age_outlier_excluded_from_median_live), не только подстрокой
- Повторная проверка #2894 (2026-08): город резолвится ИСКЛЮЧИТЕЛЬНО по
lat/lon (ближайший центроид), НЕ по моде `listings.city` (город
свип-контекста скрейпера, не адреса объявления) и НЕ по `city_hint`
(непроверенный клиентский вход) см. app.api.v1.trade_in._resolve_coverage_city
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
# psycopg v3 driver required; stub DATABASE_URL before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
# (trade_in.py imports generate_trade_in_pdf at module load).
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
# ── Helpers ───────────────────────────────────────────────────────────────────
@pytest.fixture()
def trade_in_app() -> FastAPI:
"""Minimal FastAPI app mounting only the trade-in router with DB overridden."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield MagicMock()
application.dependency_overrides[get_db] = _override_db
return application
def _row(
n_listings: int,
median_age_days: float | None,
n_with_age: int | None = None,
) -> dict:
"""Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone().
n_with_age по умолчанию = n_listings, если не задан явно (большинство старых
тестов не проверяют MAJOR-2 отдельно сохраняем их поведение).
Повторная проверка #2894: строка больше не несёт cohort_city — город
резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг).
"""
return {
"n_listings": n_listings,
"median_age_days": median_age_days,
"n_with_age": n_with_age if n_with_age is not None else n_listings,
}
def _db_mock_returning(row: dict | None) -> MagicMock:
"""DB session mock — coverage_probe reads db.execute(...).mappings().fetchone()."""
db = MagicMock()
mapping_result = MagicMock()
mapping_result.fetchone.return_value = row
execute_result = MagicMock()
execute_result.mappings.return_value = mapping_result
db.execute.return_value = execute_result
return db
def _override(app: FastAPI, db: MagicMock) -> None:
from app.core.db import get_db
app.dependency_overrides[get_db] = lambda: (yield db)
# Екатеринбург — совпадает (с точностью до сотен метров) с центроидом
# _CITY_CENTROIDS_DEG["Екатеринбург"], поэтому дефолтный payload детерминированно
# резолвится в зелёный город без доп. настройки координат в каждом тесте.
_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0}
# Координаты других городов из COVERAGE_GREEN/YELLOW_CITIES (те же значения, что
# _CITY_CENTROIDS_DEG в trade_in.py) — используются, когда тесту нужен НЕ ЕКБ.
_NIZHNY_TAGIL = {"lat": 57.9099, "lon": 59.9819}
_REVDA = {"lat": 56.7986, "lon": 59.9298}
_BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034}
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
# "город не определён", без совпадения ни с одним из 8 центроидов.
# Тавда: ближайший из центроидов (Каменск-Уральский) в 271 км, то есть точка
# заведомо вне 25-км радиуса любого поддержанного города — при этом всё ещё
# Свердловская область, как и задумано тестами ниже.
#
# Раньше здесь стояла точка 59.6047/60.1970 — окрестности Серова, в 21 км от
# его центра. Она работала как «далеко от всех» лишь потому, что Серов не был
# поддержан: город предлагался в дропдауне на сайте, но отсутствовал в списках
# покрытия, и его житель получал «этот адрес вне области». Серов добавлен
# 16.08.2026, и фикстура переехала туда, где действительно далеко.
_FAR_AWAY_CITY = {"lat": 58.0424, "lon": 65.2711}
# ── Response schema: NO price anywhere (issue #2894 hard rule) ────────────────
_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "", "cost")
def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
"""Regression guard: response schema must never grow a price-shaped field."""
from app.schemas.trade_in import CoverageProbeResponse
field_names = set(CoverageProbeResponse.model_fields.keys())
offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}"
def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
"""Same guard but on a live serialized response (belt-and-suspenders)."""
db = _db_mock_returning(_row(10, 21.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
assert resp.status_code == 200
data = resp.json()
offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
assert not offending, f"response body must not carry price fields: {offending} in {data}"
# ── Thresholds: green city ─────────────────────────────────────────────────────
def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
db = _db_mock_returning(_row(8, 15.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["n_listings"] == 8
assert data["threshold"] == 8
assert data["city"] == "Екатеринбург"
assert data["radius_m"] == 1000
assert data["median_listing_age_days"] == 15
assert data["n_with_age"] == 8
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
db = _db_mock_returning(_row(7, 10.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
data = resp.json()
assert data["status"] == "thin"
assert data["n_listings"] == 7
assert data["threshold"] == 8
# ── Thresholds: yellow city ─────────────────────────────────────────────────────
def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok. Город резолвится из lat/lon."""
db = _db_mock_returning(_row(12, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL})
data = resp.json()
assert data["status"] == "ok"
assert data["threshold"] == 12
assert data["city"] == "Нижний Тагил"
def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Ревда, n=11 (< порог 12) → thin."""
db = _db_mock_returning(_row(11, 40.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA})
data = resp.json()
assert data["status"] == "thin"
assert data["threshold"] == 12
# ── City outside all centroids → always not_covered ─────────────────────────────
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
"""Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings
(даже n=500)."""
db = _db_mock_returning(_row(500, 5.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
data = resp.json()
assert data["status"] == "not_covered"
assert data["threshold"] == 0
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
assert data["city"] == "" # город не определён — не эхуется сырой строкой
# ── Empty cohort ──────────────────────────────────────────────────────────────
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.
Nit-fix (повторная проверка #2894): threshold обязан быть 0, а не реальным
порогом города (8) при not_covered threshold "неприменим" по докстрингу
CoverageProbeResponse, независимо от ПРИЧИНЫ not_covered.
"""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
data = resp.json()
assert data["status"] == "not_covered"
assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None
assert data["n_with_age"] == 0
assert data["city"] == "Екатеринбург" # город резолвится по координатам всегда
assert data["threshold"] == 0 # nit: не 8, хотя город поддерживаемый
def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
"""DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но
coverage_probe обязан не падать, даже если mock/driver вернул пусто)."""
db = _db_mock_returning(None)
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "not_covered"
assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None
assert data["n_with_age"] == 0
assert data["threshold"] == 0
# ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ──
def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) -> None:
"""Точка в Берёзовском → city='Берёзовский' (а не 'Екатеринбург').
Регресс на прод-замер (повторная проверка #2894): в радиусе 1000м вокруг
Берёзовского 90/90 строк listings имеют city='Екатеринбург' (город
свип-контекста скрейпера, миграция 196) старая логика (мода когорты)
отдала бы 'Екатеринбург'. Ручка больше НЕ читает cohort city из SQL вовсе.
"""
db = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY})
data = resp.json()
assert data["city"] == "Берёзовский"
assert data["status"] == "ok"
assert data["threshold"] == 8
def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
"""Точка за пределами 25 км от всех центроидов → not_covered, city=""."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
data = resp.json()
assert data["status"] == "not_covered"
assert data["city"] == ""
assert data["threshold"] == 0
def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None:
"""city_hint — чисто информационное поле (повторная проверка #2894): точка в
Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский
(threshold=8, зелёный порог оба города зелёные, поэтому дополнительно
проверяем n=8 ok именно для Берёзовского, а не подмену клиентом города).
"""
db_with_hint = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db_with_hint)
client = TestClient(trade_in_app)
resp_with_hint = client.post(
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, **_BEREZOVSKY, "city_hint": "Екатеринбург"},
)
db_without_hint = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db_without_hint)
resp_without_hint = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY}
)
data_with, data_without = resp_with_hint.json(), resp_without_hint.json()
assert data_with["city"] == data_without["city"] == "Берёзовский"
assert data_with["threshold"] == data_without["threshold"] == 8
assert data_with["status"] == data_without["status"] == "ok"
# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ─────────────────
def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
"""n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null,
даже если SQL посчитал percentile "медиана" по 1-2 объявлениям не медиана."""
from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES
assert COVERAGE_MIN_AGE_SAMPLES == 5
db = _db_mock_returning(_row(20, 40.0, n_with_age=2))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
data = resp.json()
assert data["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром
assert data["n_with_age"] == 2
assert data["median_listing_age_days"] is None
def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None:
"""n_with_age=5 (== порог) → median_listing_age_days отдаётся."""
db = _db_mock_returning(_row(20, 40.0, n_with_age=5))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
data = resp.json()
assert data["n_with_age"] == 5
assert data["median_listing_age_days"] == 40
def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
"""COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые
объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python.
Слабая (текстовая) проверка подстрока встречается в SQL ДВАЖДЫ (count и
percentile_cont), поэтому `assert "..." in sql_text` одна ловит только
"убрали оба FILTER", не "убрали один из двух". Реальный поведенческий пин
test_max_age_outlier_excluded_from_median_live ниже (живой Postgres).
"""
from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS
assert COVERAGE_MAX_AGE_DAYS == 365
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
call_args = db.execute.call_args
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
assert params["max_age_days"] == 365
sql_text = str(call_args[0][0])
# count==2: и в count(*) FILTER, и в percentile_cont(...) FILTER — обе нужны,
# чтобы n_with_age и median_listing_age_days считались по ОДНОМУ и тому же
# предикату (иначе честный n_with_age маскирует нечестный медианный расчёт).
assert sql_text.count("days_on_market <= :max_age_days") == 2
# ── DB dedup / cap params passed through ────────────────────────────────────────
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
"""SQL params: radius=1000 (строго), area ±15%, rooms exact."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
assert db.execute.called
call_args = db.execute.call_args
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
assert params["radius"] == 1000
assert params["rooms"] == 2
assert params["area_min"] == pytest.approx(50.0 * 0.85)
assert params["area_max"] == pytest.approx(50.0 * 1.15)
assert params["fresh_days"] == 14
# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ────────
#
# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба
# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м)
# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid
# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит
# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL —
# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py
# для опционального real-Postgres-варианта аналогичной проверки в этом репо).
def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
"""Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932)."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text
def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None:
"""geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E)."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
"""price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916)."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "price_rub > 0" in sql_text
# ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ────────────────────
#
# Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что
# test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/
# DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test-
# заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+
# PostGIS-контейнер.
#
# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в
# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и
# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла),
# И повторно внутри тела единственного live-теста. Теперь доступность БД
# проверяется отдельной дешёвой функцией с явным закрытием соединения
# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой
# (`live_session`) с гарантированным close() в finally, а не ручным вызовом.
def _live_db_available() -> bool:
"""Дешёвая проверка доступности live-Postgres — соединение открывается и
СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session.
Используется только в `pytest.mark.skipif(...)`, который вычисляется на
этапе сбора тестов до фикстур.
"""
try:
from sqlalchemy import create_engine
from sqlalchemy import text as sa_text
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return False
engine = create_engine(dsn, future=True)
try:
with engine.connect() as conn:
conn.execute(sa_text("SELECT 1"))
return True
finally:
engine.dispose()
except Exception:
return False
@pytest.fixture()
def live_session(): # type: ignore[no-untyped-def]
"""Session для live-Postgres тестов — гарантированно закрывается после теста
(rollback + close + dispose в finally), в отличие от прежнего ручного вызова
`_live_session()` внутри тела каждого теста."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
engine = create_engine(dsn, future=True)
session_factory = sessionmaker(bind=engine, future=True)
session = session_factory()
try:
yield session
finally:
session.rollback()
session.close()
engine.dispose()
# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют
# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх
# чужой транзакции.
_LIVE_LAT, _LIVE_LON = 1.111, 2.222
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
def test_major1_cohort_excludes_novostroyki_and_city_precision_live(live_session) -> None: # type: ignore[no-untyped-def]
from sqlalchemy import text as sa_text
from app.api.v1.trade_in import coverage_probe
from app.schemas.trade_in import CoverageProbeInput
db = live_session
rows = [
# (source_url suffix, listing_segment, geo_precision, price_rub) — все
# остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW().
("ok-vtorichka", None, None, 5_000_000), # counted
("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded
("bad-city-precision", None, "city", 5_000_000), # excluded
("bad-zero-price", None, None, 0), # excluded
]
for suffix, segment, geo_precision, price in rows:
url = f"https://test.invalid/coverage-major1-{suffix}"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at,
listing_segment, geo_precision)
VALUES
('test', :url, :url, :url, 'test addr', :lat, :lon,
2, 50.0, :price, true, NOW(), :segment, :geo_precision)
"""
),
{
"url": url,
"lat": _LIVE_LAT,
"lon": _LIVE_LON,
"price": price,
"segment": segment,
"geo_precision": geo_precision,
},
)
result = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
# Только первая (ok-vtorichka) строка должна попадать в когорту —
# каждая следующая вставка не должна сдвигать счётчик.
assert result.n_listings == 1, (
f"predicate regression: n_listings={result.n_listings} after inserting "
f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} "
f"price={price}) — expected still 1 (only ok-vtorichka counted)"
)
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
def test_max_age_outlier_excluded_from_median_live(live_session) -> None: # type: ignore[no-untyped-def]
"""MAJOR-2 поведенческий пин (повторная проверка #2894).
Текстовый тест (test_max_age_outlier_days_passed_to_sql) проверял, что
подстрока `days_on_market <= :max_age_days` встречается в SQL но она там
ДВАЖДЫ (count и percentile_cont), и мутация «убрать FILTER у
percentile_cont, оставив у count» проходила зелёной: n_with_age (из count)
оставался честным, а percentile_cont без FILTER считал медиану по ВСЕМ
days_on_market, включая выбросы.
Вставляет когорту из 5 "нормальных" объявлений (days_on_market
4/6/8/10/12, честная медиана 8) и один выброс (days_on_market=4000,
> COVERAGE_MAX_AGE_DAYS=365). Проверяет, что после вставки выброса
n_with_age и median_listing_age_days НЕ меняются (выброс попадает только
в n_listings) с правильными двумя FILTER это так; без FILTER у
percentile_cont медиана сдвинулась бы 8 9 (percentile_cont(0.5) по
[4,6,8,10,12,4000] = среднее 3-го и 4-го отсортированных значений = 9).
"""
from sqlalchemy import text as sa_text
from app.api.v1.trade_in import coverage_probe
from app.schemas.trade_in import CoverageProbeInput
db = live_session
normal_ages = [4, 6, 8, 10, 12]
for i, age in enumerate(normal_ages):
url = f"https://test.invalid/coverage-major2-normal-{i}"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
VALUES
('test', :url, :url, :url, :addr, :lat, :lon,
2, 50.0, 5000000, true, NOW(), :age)
"""
),
{
"url": url,
"addr": f"test addr coverage-major2-{i}",
"lat": _LIVE_LAT,
"lon": _LIVE_LON,
"age": age,
},
)
result = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
assert result.n_listings == 5
assert result.n_with_age == 5
assert result.median_listing_age_days == 8
outlier_url = "https://test.invalid/coverage-major2-outlier"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
VALUES
('test', :url, :url, :url, 'test addr coverage-major2-outlier', :lat, :lon,
2, 50.0, 5000000, true, NOW(), 4000)
"""
),
{"url": outlier_url, "lat": _LIVE_LAT, "lon": _LIVE_LON},
)
result_with_outlier = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
assert result_with_outlier.n_listings == 6 # выброс всё же попадает в n_listings
assert result_with_outlier.n_with_age == 5, (
f"MAJOR-2 regression: outlier (days_on_market=4000 > MAX=365) leaked into "
f"n_with_age={result_with_outlier.n_with_age} — count(*) FILTER пропал/сломан"
)
assert result_with_outlier.median_listing_age_days == 8, (
f"MAJOR-2 regression: median_listing_age_days="
f"{result_with_outlier.median_listing_age_days} shifted by outlier — "
f"percentile_cont(...) FILTER пропал (мутация «убрать FILTER у "
f"percentile_cont, оставив у count»)"
)

View file

@ -424,3 +424,184 @@ def test_migration_160_is_transactional() -> None:
def test_migration_160_no_psycopg_trap() -> None:
sql = _MIGRATION_160.read_text("utf-8")
assert not re.search(r":\w+::", sql)
# ── null_segment_only (пустой listing_segment yandex/cian, никогда не переобходится) ──
def test_null_segment_sql_uses_is_null_not_any() -> None:
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
assert "listing_segment IS NULL" in sql
assert "ANY(CAST(:segments AS text[]))" not in sql
assert ":segments" not in sql
def test_null_segment_sql_filters_is_active_and_source() -> None:
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
assert "is_active = true" in sql
assert ":listing_source" in sql
assert "SET is_active = false" in sql
assert "DELETE" not in sql.upper()
def test_null_segment_sql_no_psycopg_trap() -> None:
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
assert not re.search(r":\w+::", sql)
assert "CAST(:ttl_days || ' days' AS interval)" in sql
def test_null_segment_only_and_segments_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""null_segment_only=True + segments заданы -- неоднозначный запрос, ValueError."""
failed: dict[str, Any] = {}
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(
task_mod.runs_mod,
"mark_failed",
lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err),
)
db = _FakeDB(rowcount=0)
with pytest.raises(ValueError, match="null_segment_only"):
task_mod.deactivate_stale_listings(
db,
run_id=20,
listing_source="cian",
ttl_days=60,
segments=["vtorichka"],
null_segment_only=True,
) # type: ignore[arg-type]
assert db.executed == []
assert failed["run_id"] == 20
def test_null_segment_only_deactivates_via_is_null(monkeypatch: pytest.MonkeyPatch) -> None:
marked: dict[str, Any] = {}
monkeypatch.setattr(
task_mod.runs_mod,
"mark_done",
lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)),
)
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
db = _FakeDB(rowcount=211)
out = task_mod.deactivate_stale_listings(
db,
run_id=21,
listing_source="cian",
ttl_days=60,
null_segment_only=True,
) # type: ignore[arg-type]
assert out == {"deactivated": 211}
assert db.committed is True
stmt, params = db.executed[0]
sql = _sql_text(stmt)
assert "listing_segment IS NULL" in sql
assert params is not None
assert "segments" not in params
assert params["listing_source"] == "cian"
assert params["ttl_days"] == 60
assert marked["counters"] == {"deactivated": 211}
def test_null_segment_only_confirmations_sql_uses_is_null() -> None:
sql = str(
task_mod._build_confirmations_sql(
"last_seen_at", with_segments=False, null_segment_only=True
).text
)
assert "listing_segment IS NULL" in sql
assert ":segments" not in sql
def test_null_segment_only_revisit_floor_sql_uses_is_null() -> None:
sql = str(
task_mod._build_revisit_floor_sql(
"last_seen_at", with_segments=False, null_segment_only=True
).text
)
assert "l.listing_segment IS NULL" in sql
assert ":segments" not in sql
def test_null_segment_only_default_is_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""Обратная совместимость: старые вызовы без null_segment_only ведут себя как раньше."""
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
db = _FakeDB(rowcount=3)
task_mod.deactivate_stale_listings(
db, run_id=22, listing_source="avito", ttl_days=10, segments=None
) # type: ignore[arg-type]
stmt, _params = db.executed[0]
sql = _sql_text(stmt)
assert "listing_segment IS NULL" not in sql
# ── Migration 266 (deactivate_stale_yandex_null_segment / _cian_null_segment) ───────
# Renumbered 264 -> 266 (collision with forgejo/main's 264_deactivate_stale_avito_cap_mult.sql
# / 265_deactivate_stale_yandex_cap_mult.sql, merged после того как эта ветка забрала 264).
_MIGRATION_266 = _SQL_DIR / "266_seed_deactivate_stale_null_segment_yandex_cian.sql"
def test_migration_266_exists() -> None:
assert _MIGRATION_266.is_file(), f"missing migration: {_MIGRATION_266}"
def test_migration_266_seeds_yandex_and_cian_null_segment() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert "'deactivate_stale_yandex_null_segment'" in sql
assert "'deactivate_stale_cian_null_segment'" in sql
def test_migration_266_null_segment_only_true() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert '"null_segment_only":true' in sql
def test_migration_266_ttl_60_days() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert '"ttl_days":60' in sql
def test_migration_266_gates_disabled() -> None:
"""min_confirmations/revisit_floor_quantile выключены явно -- население слишком
мало для порогов, откалиброванных под полноценный vtorichka-свип (см. файл)."""
sql = _MIGRATION_266.read_text("utf-8")
assert '"min_confirmations":0' in sql
assert '"revisit_floor_quantile":0' in sql
def test_migration_266_is_idempotent() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert "ON CONFLICT (source) DO NOTHING" in sql
def test_migration_266_is_transactional() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_266_enabled_true() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert "true" in sql
def test_migration_266_window_7_to_8_utc() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert re.search(r"\b7\b", sql), "window_start_hour 7 missing"
assert re.search(r"\b8\b", sql), "window_end_hour 8 missing"
def test_migration_266_no_psycopg_trap() -> None:
sql = _MIGRATION_266.read_text("utf-8")
assert not re.search(r":\w+::", sql)
def test_handler_wires_null_segment_only_from_schedule_params() -> None:
"""Читаем исходник файлом (как test_handler_wires_revisit_floor_from_schedule_params):
product_handlers тянет scraper_kit, которого в юнит-окружении может не быть."""
handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
src = handlers.read_text("utf-8")
job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
flat = " ".join(job.split())
assert 'params.get("null_segment_only", False)' in flat
assert "null_segment_only=null_segment_only" in job

View file

@ -126,13 +126,21 @@ def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[st
def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одно из 127 доказанно ложных снятий не должно повториться.
"""Ни одно из 127 доказанно ложных снятий (cian/yandex) не должно повториться.
Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть
строго выше этого возраста на КАЖДОМ прод-срезе иначе следующий прогон
снимет ту же строку снова.
строго выше этого возраста на cian/yandex-срезах иначе следующий прогон
снимет ту же строку снова. avito из этого цикла исключён намеренно: 127
доказанных ложных снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex,
у avito другой сценарий и своя проверка ниже
(test_avito_prod_floor_is_capped_by_calibrated_cap_mult) -- калибровка cap_mult=6
для avito (миграция 264_deactivate_stale_avito_cap_mult.sql) пиннится ТАМ, а не
здесь, чтобы не смешивать два разных замера под одним порогом
_FALSE_KILL_AGE_MAX, который к avito не относится.
"""
for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items():
if source == "avito":
continue
db = _FakeDB(floor_days=floor)
out = _run(
db,
@ -154,6 +162,108 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey
), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
def _read_cap_mult_from_migration(filename: str, *, source: str) -> int:
"""Читает cap_mult из UPDATE default_params миграции -- НЕ хардкодит дубль в тесте.
Найдено ревью круга 3 2026-08-15: раньше тест ниже принимал cap_mult=6 как
аргумент напрямую, захардкоженный прямо в теле теста. Мутация значения в
264_deactivate_stale_avito_cap_mult.sql (6 -> 2) НЕ трогала вход теста вовсе --
набор оставался зелёным при любом реальном значении в миграции, то есть
калибровка нигде не была пином, только упоминанием в комментарии. Здесь
значение читается ИЗ ФАЙЛА миграции regex'ом, а ожидаемый результат
(ttl_days_effective, ttl_floor_capped) остаётся зафиксированным числом в самом
тесте -- так дрейф калибровки в миграции ломает тест, как и задумано.
"""
migration = Path(__file__).resolve().parents[1] / "data" / "sql" / filename
src = migration.read_text("utf-8")
# Порядок в файле -- jsonb_build_object('cap_mult', N) в SET, ЗАТЕМ WHERE source
# = '<source>' ниже (см. 264/265_*.sql). DOTALL матчит перевод строки между ними;
# source в regex -- страховка от чтения не того UPDATE, если файл когда-нибудь
# станет мульти-source (сейчас в каждом файле ровно один UPDATE).
match = re.search(
r"jsonb_build_object\('cap_mult',\s*(\d+)\).*?WHERE\s+source\s*=\s*'"
+ re.escape(source)
+ r"'",
src,
re.DOTALL,
)
assert match is not None, (
f"{filename} сменил формат UPDATE default_params для source={source!r} -- "
"обнови regex в _read_cap_mult_from_migration"
)
return int(match.group(1))
def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пиннит калибровку cap_mult=6 для avito (миграция
264_deactivate_stale_avito_cap_mult.sql) на измеренном прод-поле _PROD_FLOORS
("avito/все сегменты" = 69.7, замер 2026-08-09).
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции (regex), не хардкодится
здесь: дрейф калибровки в 264_*.sql (например 6 -> 2) меняет вход, но НЕ
ожидаемый результат ниже (60/70) -- эти числа пинят калибровку саму по себе,
поэтому дрейф ломает тест, как и задумано (см. _read_cap_mult_from_migration).
С дефолтным cap_mult=2 потолок avito (20 сут) РЕЖЕТ ниже собственного хвоста
переобхода p99=42.1 (_REVISIT_TAIL) -- ровно тот false-kill, ради которого пол
заведён. С калиброванным cap_mult=6 потолок 60 сут -- выше и p99=42.1, и живого
прод-пика 52 (замер 08-10..08-12), и этого гипотетического замера 69.7 (капается
ровно на 60, не пропускается как есть).
"""
calibrated_cap_mult = _read_cap_mult_from_migration(
"264_deactivate_stale_avito_cap_mult.sql", source="deactivate_stale_avito"
)
source, segments, ttl_days, floor = _PROD_FLOORS["avito/все сегменты"]
db = _FakeDB(floor_days=floor)
out = _run(
db,
monkeypatch,
listing_source=source,
ttl_days=ttl_days,
segments=segments,
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
cap_mult=calibrated_cap_mult,
)
assert out["ttl_days_effective"] == 60, "cap_mult из миграции 264 обязан дать потолок 60"
assert out["ttl_floor_capped"] == 1
assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу"
def test_yandex_prod_floor_is_not_capped_by_calibrated_cap_mult(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пиннит калибровку cap_mult=3 для yandex (миграция
265_deactivate_stale_yandex_cap_mult.sql, найдено ревью круга 3 2026-08-15) на
измеренном прод-поле _PROD_FLOORS ("yandex/vtorichka" = 74.3).
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции 265 (тот же приём, что и у
avito выше): дрейф калибровки в 265_*.sql ломает тест.
С дефолтным cap_mult=2 потолок yandex (60 сут) РЕЖЕТ живой пол (75-79 сут,
scrape_runs.counters 08-10..08-15 и live-замер 08-15) -- та же дыра, что у
avito, найдена позже (первая версия 264 ошибочно считала yandex безопасным по
устаревшему статическому p99=43.0). С калиброванным cap_mult=3 потолок 90 сут
выше живого пика 79.2 -- пол 74.3 из этого теста НЕ капается, эффективный TTL
равен сырому полу (75, ceil(74.3)).
"""
calibrated_cap_mult = _read_cap_mult_from_migration(
"265_deactivate_stale_yandex_cap_mult.sql", source="deactivate_stale_yandex"
)
source, segments, ttl_days, floor = _PROD_FLOORS["yandex/vtorichka"]
db = _FakeDB(floor_days=floor)
out = _run(
db,
monkeypatch,
listing_source=source,
ttl_days=ttl_days,
segments=segments,
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
cap_mult=calibrated_cap_mult,
)
assert out["ttl_days_effective"] == 75, "ceil(74.3) == 75, потолок 90 не должен резать"
assert "ttl_floor_capped" not in out, "потолок 90 выше живого пола 74.3 -- капать нечего"
def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
"""Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше."""
assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX

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