Слияние main в fix/2683-manifest-drift
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Successful in 1m0s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m41s
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI Trade-In / backend-tests (pull_request) Successful in 5m31s
CI / backend-tests (pull_request) Successful in 17m12s

main принёс PR #2751 (ruff-шаг в ci-tradein.yml/backend-tests) параллельно с
fetch-depth: 0 из этой ветки в том же job'е — не противоречат друг другу,
слились автоматически.

Единственное ручное разрешение — _manifest_applied.txt (modify/delete):
main дописал файл, ветка его удаляет. Разрешение — удаление, это и есть
предмет PR: гейт номеров миграций берёт эталон из git (origin/main), а не
из ручного манифеста, который отставал и по построению не мог покраснеть
(#2683, живой инцидент 15.08 — коллизия 264_ между двумя независимыми ветками).
This commit is contained in:
bot-backend 2026-08-15 22:44:16 +03:00
commit 90133c4c2e
141 changed files with 15500 additions and 1101 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

@ -131,7 +131,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
@ -198,13 +198,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).
#
@ -236,7 +245,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

@ -142,7 +142,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 +233,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 +253,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

@ -232,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
@ -256,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
@ -272,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 за пределы контекста,
@ -284,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
@ -308,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
@ -326,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
@ -340,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]
@ -875,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

View file

@ -251,6 +251,31 @@ meraocenka.ru {
}
}
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
# обязаны резолвиться сами по себе, а не только длинным
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
# короткого /oferta нет.
#
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
# который в ней указан. Каноничность для поисковиков задана отдельно, через
# `alternates.canonical` на каждой из трёх страниц.
#
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
# путь проксируется» ради трёх страниц нельзя.
@meraLegalDocs path /oferta /refund /privacy
handle @meraLegalDocs {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle

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

@ -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}

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

@ -0,0 +1,102 @@
#!/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`
# такой разницы не делает, поэтому здесь он намеренно не используется.
#
# 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}"
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
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"})

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

View file

@ -5,6 +5,8 @@
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
# напечатаны внутри самих юридических документов и уходят эквайеру.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
@ -52,6 +54,15 @@ check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 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
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404

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,268 @@ 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 требование), не в БД.
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
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),
}
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

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
@ -66,17 +66,28 @@ 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,
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 ниже первый рубеж защиты)."""
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) +
RetryError fingerprint-стабилизация (glitchtip-noise) см.
app/tgbot_main.py._before_send (та же композиция без последнего шага,
тот бот geocoder не зовёт). PII/token тот же риск: теперь этот процесс
тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты. RetryError
этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse),
которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_pii_event(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]
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,
@ -210,6 +221,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"])

View file

@ -25,6 +25,7 @@ import re
from typing import Any
from sentry_sdk.types import Event
from tenacity import RetryError
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
@ -76,6 +77,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 +116,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 +151,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 +174,8 @@ 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
@ -174,3 +208,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,24 @@ 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_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise).
Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни
адресов за прогон) @retry-декорированные Nominatim-хелперы
(app/services/geocoder.py) на исчерпанных ретраях исторически плодили
по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__()
тащит нестабильный repr() Future). См. sentry_scrub docstring.
"""
scrubbed = scrub_pii_event(event, hint)
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -52,7 +69,7 @@ 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,
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
@ -1565,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:
@ -1593,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 {}
@ -1772,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:
@ -1783,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
@ -1835,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:
@ -1845,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(
@ -1855,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
@ -1877,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,
}
@ -3106,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 = (
@ -3124,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
@ -3173,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"})
# Диапазон расширяем даже если медиану не двигали.
@ -4735,6 +4762,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 ───────────────────────────────────────────────────────
@ -726,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.
@ -925,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]:
@ -1188,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()`).
@ -1310,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.
@ -1586,6 +1897,45 @@ async def _geocode_resolve(
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
@ -1654,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

@ -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,
),
)

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

@ -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

@ -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

@ -79,3 +79,17 @@ tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
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

@ -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

@ -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,656 @@
"""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 центроидов.
_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970}
# ── 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 км от всех 8 центроидов → 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

View file

@ -0,0 +1,443 @@
"""Потолок эффективного TTL деактивации (найдено на проде 2026-08-15).
Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) поднимает
эффективный TTL через max(ttl_days, пол) без верхней границы. На проде это оказалось
петлёй с положительной обратной связью: медленный обход поднимает пол, высокий пол
продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться свежий обход, пул
«активных» раздувается протухшими строками. У yandex ttl_days_effective держали
75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 -- это и есть разгон пола,
ради которого потолок написан. Цифру «23 687 из 44 744» из исходного разбора сюда НЕ
переносим: она про все источники сразу, две трети её -- новостройки вне выборки
оценщика, а у самого avito просроченных строк ноль (уточнено 2026-08-15).
Этот файл проверяет CAP_MULT -- потолок, не пускающий эффективный TTL выше
ttl_days * CAP_MULT, независимо от того, насколько высоко посчитанный пол.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks import deactivate_stale_avito as task_mod
# ── Фейковая сессия (тот же контракт, что в test_deactivate_stale_revisit_floor.py) ──
class _FakeResult:
def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
self.rowcount = rowcount
self._scalar = scalar_value
def scalar(self) -> Any:
return self._scalar
class _FakeDB:
"""Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount."""
def __init__(
self,
*,
floor_days: float | None,
confirmations: int = 10_000,
rowcount: int = 137,
) -> None:
self._floor = floor_days
self._confirmations = confirmations
self._rowcount = rowcount
self.executed: list[tuple[str, dict[str, Any] | None]] = []
self.committed = False
self.rolled_back = False
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt.text)
self.executed.append((sql, params))
if "percentile_disc" in sql:
return _FakeResult(scalar_value=self._floor)
if "SELECT count(*)" in sql:
return _FakeResult(scalar_value=self._confirmations)
return _FakeResult(rowcount=self._rowcount)
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
self.rolled_back = True
@property
def update_query(self) -> tuple[str, dict[str, Any] | None]:
return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None))
def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
return task_mod.deactivate_stale_listings(
db, # type: ignore[arg-type]
1,
listing_source=kwargs.pop("listing_source", "avito"),
ttl_days=kwargs.pop("ttl_days", 10),
**kwargs,
)
# ── Контракт из задачи ─────────────────────────────────────────────────────────
def test_high_floor_is_capped_at_double_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
"""revisit_floor=75, ttl_days=10 -> итог 20 (потолок 2x), НЕ 75."""
db = _FakeDB(floor_days=75.0)
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 20
assert out["ttl_floor_capped"] == 1
assert out["ttl_days_floor_raw"] == 75
_, update_params = db.update_query
assert update_params is not None
assert update_params["ttl_days"] == 20, "UPDATE обязан получить капнутый TTL, не сырой пол"
def test_low_floor_leaves_ttl_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
"""revisit_floor=5, ttl_days=10 -> итог 10 (пол ниже заданного TTL, max() его не поднимает)."""
db = _FakeDB(floor_days=5.0)
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 10
assert "ttl_floor_capped" not in out
assert "ttl_days_floor_raw" not in out
_, update_params = db.update_query
assert update_params is not None
assert update_params["ttl_days"] == 10
# ── Контракт потолка ────────────────────────────────────────────────────────────
def test_cap_mult_is_named_module_constant_equal_two() -> None:
assert task_mod.CAP_MULT == 2
def test_floor_between_ttl_and_cap_is_not_flagged_capped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пол поднял TTL, но не дотянулся до потолка -- capped-флаг НЕ выставляется."""
db = _FakeDB(floor_days=15.0)
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 15
assert "ttl_floor_capped" not in out
def test_floor_exactly_at_cap_boundary_is_not_flagged_capped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пол ровно на потолке (2x ttl) -- это ещё "поднят до потолка", не "срезан выше него".
Формула -- min(raw, cap): при raw == cap срезания не происходит (raw > cap ложно),
капнутый флаг предназначен сигналить именно "потолок реально что-то отрезал".
"""
db = _FakeDB(floor_days=20.0)
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 20
assert "ttl_floor_capped" not in out
def test_cap_logs_warning_containing_both_numbers(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""WARNING при срезании содержит и сырой пол, и капнутый результат -- не только counters."""
db = _FakeDB(floor_days=75.0)
with caplog.at_level("WARNING", logger=task_mod.logger.name):
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
messages = " ".join(r.getMessage() for r in caplog.records)
assert "75" in messages, "лог обязан называть сырой пол"
assert "20" in messages, "лог обязан называть итоговый (капнутый) TTL"
def test_cap_never_lowers_ttl_below_configured_value(monkeypatch: pytest.MonkeyPatch) -> None:
"""Потолок -- верхняя граница, не альтернативный источник истины: заданный TTL
(10) остаётся нижней границей независимо от того, насколько низко ушёл пол."""
db = _FakeDB(floor_days=1.0)
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 10
# ── avito self-descend (52 -> ... -> 10) не должен ломаться потолком ────────────
def test_avito_high_transient_floor_is_capped_not_left_unbounded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Наблюдённый на проде транзиентный пик avito (счётчики видели ttl_days_effective=52)
теперь капается на 2x ttl=20, а не пропускается в UPDATE как есть."""
db = _FakeDB(floor_days=52.0)
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 20
assert out["ttl_floor_capped"] == 1
assert out["ttl_days_floor_raw"] == 52
def test_avito_recovered_low_floor_still_reaches_configured_ttl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""После восстановления обхода (пол опустился ниже ttl_days=10, как на проде 52->10)
потолок не мешает нормальному пути -- эффективный TTL просто равен заданному."""
db = _FakeDB(floor_days=9.0)
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 10
assert "ttl_floor_capped" not in out
def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Промежуточная точка того же самопонижения (пол между ttl и потолком, например 18)
поднимает TTL как раньше -- потолок не мешает нормальному постепенному пути."""
db = _FakeDB(floor_days=18.0)
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 18
assert "ttl_floor_capped" not in out
# ── cap_mult конфигурируем per-source (найдено ревью 2026-08-15) ────────────────
# Дефолтный CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных источниках
# (cian/yandex 60 сут, avito 20 сут), а хвост переобхода не пропорционален
# ttl_days: avito p99=42.1 -- выше его же дефолтного потолка 20. cap_mult -- ручка
# для конкретно такого источника, без изменения дефолта для остальных.
def test_cap_mult_defaults_to_module_constant_when_not_overridden(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Без явного cap_mult поведение не меняется: потолок = ttl_days * CAP_MULT (2)."""
db = _FakeDB(floor_days=75.0)
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
assert out["ttl_days_effective"] == 10 * task_mod.CAP_MULT
def test_cap_mult_override_raises_the_ceiling_for_a_long_tailed_source(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""avito p99=42.1: cap_mult=6 (потолок 60) больше не режет пол ниже хвоста обхода,
в отличие от дефолтного cap_mult=2 (потолок 20)."""
db = _FakeDB(floor_days=45.0)
out = _run(
db,
monkeypatch,
listing_source="avito",
ttl_days=10,
revisit_floor_quantile=0.99,
cap_mult=6,
)
assert out["ttl_days_effective"] == 45
assert "ttl_floor_capped" not in out
def test_cap_mult_override_still_caps_when_floor_exceeds_the_wider_ceiling(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""cap_mult поднимает потолок, но не убирает его -- пол выше 60 всё равно срезается."""
db = _FakeDB(floor_days=90.0)
out = _run(
db,
monkeypatch,
listing_source="avito",
ttl_days=10,
revisit_floor_quantile=0.99,
cap_mult=6,
)
assert out["ttl_days_effective"] == 60
assert out["ttl_floor_capped"] == 1
assert out["ttl_days_floor_raw"] == 90
def test_cap_mult_is_threaded_into_update_params(monkeypatch: pytest.MonkeyPatch) -> None:
"""Капнутый по override'нутому потолку TTL реально уходит в UPDATE, не только считается."""
db = _FakeDB(floor_days=90.0)
_run(
db,
monkeypatch,
listing_source="avito",
ttl_days=10,
revisit_floor_quantile=0.99,
cap_mult=6,
)
_, update_params = db.update_query
assert update_params is not None
assert update_params["ttl_days"] == 60
# ── ttl_days <= 0 (LOW из ревью 2026-08-15) ──────────────────────────────────────
# До потолка max(ttl_days, floor) прикрывал ttl_days<=0, если пол посчитан и
# положителен. С потолком min(raw, ttl_days * cap_mult) при ttl_days<=0 капнутый
# потолок тоже <= 0 и побеждает в min() -- защита пола пропадает молча. Явный guard
# ловит это ДО любого SQL, тем же путём, что и невалидный staleness_column.
def test_ttl_days_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
db = _FakeDB(floor_days=75.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=0, revisit_floor_quantile=0.99)
assert db.executed == []
def test_ttl_days_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
db = _FakeDB(floor_days=75.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=-5, revisit_floor_quantile=0.99)
assert db.executed == []
def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Тот же контракт, что и невалидный staleness_column: run помечается failed,
а не остаётся 'running'."""
marked_failed: list[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: marked_failed.append((run_id, err, counters)),
)
db = _FakeDB(floor_days=75.0)
with pytest.raises(ValueError):
task_mod.deactivate_stale_listings(
db, # type: ignore[arg-type]
7,
listing_source="avito",
ttl_days=0,
)
assert len(marked_failed) == 1
assert marked_failed[0][0] == 7
# ── cap_mult < 1 (HIGH из ревью круга 2, 2026-08-15) ─────────────────────────────
# Тот же класс дыры, что и ttl_days<=0 выше, но со стороны потолка: cap_mult -- ЕДИНСТВЕННЫЙ
# запланированный способ его задать -- руками вписать в jsonb default_params расписания
# (см. миграцию для avito), т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода.
# cap_mult=0 -> capped=0 -> effective_ttl_days=0 -> UPDATE снимает весь активный пул
# источника молча. cap_mult<1 (например 0.5) опускает потолок НИЖЕ заданного оператором
# ttl_days -- прямое нарушение инварианта, который проверяет
# test_cap_never_lowers_ttl_below_configured_value для пола, но не было проверено для
# потолка при некорректном cap_mult.
def test_cap_mult_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0)
assert db.executed == []
def test_cap_mult_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=-2)
assert db.executed == []
def test_cap_mult_below_one_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
"""cap_mult=0.5 опустил бы потолок НИЖЕ заданного ttl_days -- та самая инверсия,
которую тест test_cap_never_lowers_ttl_below_configured_value гарантирует для пола."""
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0.5)
assert db.executed == []
def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
"""Опечатка в jsonb default_params (строка вместо числа) не должна молча пройти
в SQL -- TypeError из сравнения `cap_mult < 1` ловится тем же except Exception,
что и ValueError-гварды, и маршрутизируется через mark_failed. Никакого SQL не
исполняется, ни один active-лот не тронут."""
db = _FakeDB(floor_days=52.0)
with pytest.raises(TypeError):
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult="6")
assert db.executed == []
# ── cap_mult / ttl_days -- bool (найдено ревью круга 3, 2026-08-15) ─────────────
# bool -- подкласс int в Python: `True < 1` ложно, `True <= 0` ложно. Числовые
# guard'ы выше (`cap_mult < 1`, `ttl_days <= 0`) поэтому НЕ ловят jsonb `true` в
# default_params расписания -- ровно тот класс опечатки, ради которого guard'ы
# вообще написаны. `cap_mult=True` даёт потолок == ttl_days (ttl_days * True ==
# ttl_days) -- пол молча отключается без единого ValueError. `ttl_days=True` даёт
# ttl_days == 1 -- TTL молча меняется на 1 сутки. Явная type-проверка ловит оба
# ДО числового сравнения и ДО любого SQL.
def test_cap_mult_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
"""cap_mult=True: `True < 1` ложно -- без явной type-проверки потолок = ttl_days
(пол молча отключается) вместо ValueError. Воспроизведено на HEAD ветки."""
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=True)
assert db.executed == []
def test_cap_mult_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
"""cap_mult=False уже ловится `cap_mult < 1` (False == 0), но type-guard идёт
первым -- проверяем, что путь всё равно ValueError, а не иной exception."""
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=False)
assert db.executed == []
def test_ttl_days_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
"""ttl_days=True: `True <= 0` ложно -- без явной type-проверки TTL молча
становится 1 сутки (True ведёт себя как int 1) вместо ValueError."""
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=True, revisit_floor_quantile=0.99)
assert db.executed == []
def test_ttl_days_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
"""ttl_days=False уже ловится `ttl_days <= 0` (False == 0), но type-guard идёт
первым -- проверяем, что путь всё равно ValueError."""
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
_run(db, monkeypatch, ttl_days=False, revisit_floor_quantile=0.99)
assert db.executed == []
def test_cap_mult_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Тот же контракт, что и ttl_days<=0: run помечается failed, а не остаётся 'running',
и НИ ОДНА строка не деактивируется (в отличие от воспроизведённого на HEAD дефекта, где
cap_mult=0 давало effective_ttl_days=0 и снимало весь активный пул источника)."""
marked_failed: list[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: marked_failed.append((run_id, err, counters)),
)
db = _FakeDB(floor_days=52.0)
with pytest.raises(ValueError):
task_mod.deactivate_stale_listings(
db, # type: ignore[arg-type]
9,
listing_source="avito",
ttl_days=10,
revisit_floor_quantile=0.99,
cap_mult=0,
)
assert len(marked_failed) == 1
assert marked_failed[0][0] == 9
assert db.executed == []
# ── проводка cap_mult в product_handlers ─────────────────────────────────────────
def test_handler_wires_cap_mult_from_schedule_params() -> None:
"""Тот же приём, что test_handler_wires_revisit_floor_from_schedule_params:
читаем исходник файлом (product_handlers тянет scraper_kit, которого в юнит-
окружении может не быть) и проверяем именно проводку default_params -> вызов."""
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("cap_mult", CAP_MULT)' in flat
assert "cap_mult=cap_mult" in job

View file

@ -3,8 +3,6 @@
Каждая правка эпика тест, который краснеет без неё:
подключено:
- houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница
не подтверждена иначе false «не смотрели» выдаётся за false «посмотрели»);
- domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules —
именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным;
- filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash.
@ -15,7 +13,11 @@
читателя и писателя;
- listings.merged_into, house_sources.raw_payload колонки без писателя;
- v_data_quality.price_disagreements_count показатель, который не мог быть
ненулевым.
ненулевым;
- houses.has_panorama (хвост, мигр. 259) признак, которого нет на площадке.
Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя;
подключённый писатель за 7 дней записал 12 значений, все false. Разница между
«проводка оборвана» и «мерить нечего» видна не по коду, а по площадке.
задокументировано:
- BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт —
@ -29,19 +31,13 @@ from __future__ import annotations
import os
import re
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.cian.valuation import _parse_valuation_state
from scraper_kit.providers.yandex.valuation import (
ValuationHistoryItem,
ValuationHouseMeta,
YandexValuationResult,
)
from app.services import estimator
from app.services.estimator import _save_yandex_history_items
REPO_ROOT = Path(__file__).resolve().parents[3]
TRADEIN = REPO_ROOT / "tradein-mvp"
@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql"
# ─────────────────────────────────────────────────────────────────────────────
# Подключено 1/3: houses.has_panorama
# ─────────────────────────────────────────────────────────────────────────────
def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult:
return YandexValuationResult(
address="Екатеринбург, ул. Куйбышева, 106",
offer_category="APARTMENT",
offer_type="SELL",
page=1,
source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test",
house=meta,
history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)],
)
def _panorama_updates(db: MagicMock) -> list[dict]:
"""Параметры всех db.execute, которые обновляли houses.has_panorama."""
found = []
for call in db.execute.call_args_list:
sql = str(call.args[0])
if "has_panorama" in sql and "UPDATE houses" in sql:
found.append(call.args[1])
return found
def test_has_panorama_written_when_page_rendered() -> None:
"""Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой."""
db = MagicMock()
result = _result_with_meta(
ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True)
)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(99, 0.9, "fp"),
):
_save_yandex_history_items(db, result)
updates = _panorama_updates(db)
assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674"
assert updates[0] == {"hid": 99, "panorama": True}
def test_has_panorama_false_written_when_page_rendered() -> None:
"""Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false."""
db = MagicMock()
result = _result_with_meta(
ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False)
)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(7, 0.9, "fp"),
):
_save_yandex_history_items(db, result)
assert _panorama_updates(db) == [{"hid": 7, "panorama": False}]
def test_has_panorama_written_when_page_has_no_history() -> None:
"""Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде.
Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие
страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение
о доме к наличию объявлений отношения не имеет.
"""
db = MagicMock()
result = _result_with_meta(
ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True)
)
result.history_items = []
with patch(
"app.services.estimator.match_or_create_house",
return_value=(42, 0.9, "fp"),
):
assert _save_yandex_history_items(db, result) == 0
assert _panorama_updates(db) == [{"hid": 42, "panorama": True}]
def test_has_panorama_not_written_when_page_unconfirmed() -> None:
"""Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false."""
db = MagicMock()
result = _result_with_meta(ValuationHouseMeta(has_panorama=False))
with patch(
"app.services.estimator.match_or_create_house",
return_value=(5, 0.9, "fp"),
):
_save_yandex_history_items(db, result)
assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали"
def test_has_panorama_not_written_without_house_id() -> None:
"""Дом не сматчился → писать некуда, но и падать нельзя."""
db = MagicMock()
result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16))
with patch(
"app.services.estimator.match_or_create_house",
side_effect=RuntimeError("no house"),
):
_save_yandex_history_items(db, result)
assert _panorama_updates(db) == []
# ─────────────────────────────────────────────────────────────────────────────
# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка
# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка
# ─────────────────────────────────────────────────────────────────────────────
@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None:
# ─────────────────────────────────────────────────────────────────────────────
# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали
# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали
# ─────────────────────────────────────────────────────────────────────────────
@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None:
"asking_to_sold_ratios_tiered",
"asking_to_sold_tier_bounds",
"price_disagreements_count",
# Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета
# источников. Гейт краснеет на любом из трёх, а не только на одном (первая
# редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет).
"has_panorama",
]
offenders: list[str] = []
for path in _live_python_sources():
@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None:
assert "COMMENT ON VIEW v_price_divergence" in sql
def test_has_panorama_dropped_together_with_the_contract_column() -> None:
"""Снос колонки обязан пересобрать market.v_houses и вернуть ему грант.
has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку
не удаляет значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три
части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен.
"""
sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8")
assert "DROP VIEW IF EXISTS market.v_houses" in sql
assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql
assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока"
view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0]
assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой"
# Контракт — обещание стабильности: пересборка не должна заодно потерять соседей.
for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"):
assert kept in view_ddl, f"{kept} пропала из контракта при пересборке"
assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, (
"DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит "
"permission denied на следующем прогоне"
)
def test_price_divergence_is_documented_as_structurally_empty() -> None:
"""Оставленный задел обязан говорить, чем он НЕ является сегодня."""
sql = MIGRATION.read_text(encoding="utf-8")

View file

@ -0,0 +1,208 @@
"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа.
Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и
period_months ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и
слово «ФАКТИЧЕСКИЕ» читалось как «недавние».
Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein):
deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01
(223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк,
месяцы ровно {01, 04, 07, 10} deal_date это метка КВАРТАЛЬНОЙ пачки.
Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15%
+окно 12 мес + ppm²-банды, включая city-wide widen):
свежайшая I кв. 2026 654 (74.2%)
коридора нет вовсе 165 (18.7%)
свежайшая IV кв. 2025 62 ( 7.0%)
у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего
максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу.
Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в
dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки.
"""
from __future__ import annotations
import os
from datetime import date
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.schemas.trade_in import AnalogLot, DkpCorridor
from app.services.estimator import _fetch_dkp_corridor
_ADDRESS = "Екатеринбург, ул. Гоголя, 18"
def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]:
return {"price_per_m2": ppm2, "deal_date": deal_date}
def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any:
"""Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen.
Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем
_load_sber_index_series, затем (опционально) city-wide widen.
"""
db = MagicMock()
calls: list[Any] = []
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
result = MagicMock()
calls.append(query)
sql = str(query)
if "sber" in sql.lower():
result.mappings.return_value.all.return_value = []
elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql:
result.mappings.return_value.all.return_value = street_rows
elif "deal_city_price_bands" in sql:
result.mappings.return_value.all.return_value = city_rows or []
else:
result.mappings.return_value.all.return_value = []
return result
db.execute.side_effect = _execute
return db
def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def]
return _fetch_dkp_corridor(
_db(street_rows, city_rows),
address=_ADDRESS,
rooms=2,
area=60.0,
city="екатеринбург" if city_rows is None else "нижний тагил",
)
# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ─────────────────────────────────
def test_latest_deal_date_reaches_the_response_schema() -> None:
"""Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки.
На origin/main падает AttributeError поля в схеме нет, а Pydantic v2
молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» нет.
"""
raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))])
assert raw is not None
corridor = DkpCorridor(**raw)
assert corridor.latest_deal_date == date(2026, 1, 1)
def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None:
"""Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025.
Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы
(2026-01-01) для них чужая, более свежая дата.
"""
raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))])
assert raw is not None
assert raw["latest_deal_date"] == date(2025, 10, 1)
def test_deal_without_price_does_not_donate_its_freshness() -> None:
"""Строка без ppm² не входит в границы коридора — не входит и в его возраст.
Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не
участвуя ни в одном из чисел под ней.
"""
raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))])
assert raw is not None
assert raw["count"] == 1
assert raw["latest_deal_date"] == date(2025, 10, 1)
def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None:
"""#oblast-D widen: числа переехали на city-выборку — дата обязана тоже.
Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для
глаза; но на экране после widen'а стоят city-числа, которые кончаются
III кв. 2025. Подпись описывает то, что показано.
"""
street = [_deal(200_000, date(2026, 1, 1))]
city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))]
raw = _fetch(street, city)
assert raw is not None
assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)"
assert raw["latest_deal_date"] == date(2025, 7, 1)
def test_no_deals_means_no_corridor_and_nothing_to_date() -> None:
"""count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать."""
assert _fetch([]) is None
def test_period_months_is_not_a_freshness_claim() -> None:
"""Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа.
Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев»
(mappers.ts) а v1 печатал.
"""
raw = _fetch([_deal(200_000, date(2025, 10, 1))])
assert raw is not None
assert raw["period_months"] == 12
assert raw["latest_deal_date"] == date(2025, 10, 1)
# ── PDF: то же обещание, та же подпись ──────────────────────────────────────
def _estimate_with_deals(dates: list[date]) -> Any:
est = MagicMock()
est.actual_deals = [
AnalogLot(
address="Екатеринбург, ул. Гоголя",
area_m2=60.0,
rooms=2,
floor=None,
total_floors=None,
price_rub=12_000_000,
price_per_m2=200_000,
listing_date=d,
days_on_market=None,
date_precision="quarter",
source="rosreestr",
)
for d in dates
]
return est
def test_pdf_deals_page_dates_the_deals_it_shows() -> None:
"""PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка.
До правки страница печатала «Период сделок: 08.2025 08.2026», где правый
конец = сегодня: окно поиска, выданное за период данных.
"""
from app.services.exporters.trade_in_pdf import deals_as_of_label
assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == (
"по I кв. 2026"
)
assert deals_as_of_label(_estimate_with_deals([])) is None
def test_quarter_label_matches_every_deal_date_present_on_prod() -> None:
"""Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка.
Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`.
"""
from app.services.exporters.trade_in_pdf import deals_as_of_label
expected = {
date(2026, 1, 1): "по I кв. 2026",
date(2025, 10, 1): "по IV кв. 2025",
date(2025, 7, 1): "по III кв. 2025",
date(2025, 4, 1): "по II кв. 2025",
date(2025, 1, 1): "по I кв. 2025",
date(2024, 10, 1): "по IV кв. 2024",
date(2024, 7, 1): "по III кв. 2024",
date(2024, 4, 1): "по II кв. 2024",
date(2024, 1, 1): "по I кв. 2024",
}
for d, label in expected.items():
assert deals_as_of_label(_estimate_with_deals([d])) == label

View file

@ -331,18 +331,24 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
# ---------------------------------------------------------------------------
# Fix 5a — sber staleness warning
# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846)
#
# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой
# возраст недостижим по построению (period_month — метка первого числа + лаг
# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки
# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим»
# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место —
# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ.
# ---------------------------------------------------------------------------
def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
"""_load_sber_index_series логирует warning при stale серии."""
def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None:
"""Серия отдаётся как есть; календарного warning'а в горячем пути больше нет."""
import logging
from app.services.estimator import _load_sber_index_series
# Серия с единственным месяцем 2 года назад
stale_month = date(2024, 1, 1)
stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"period_month": stale_month, "index_value_rub_m2": 100_000.0}
@ -351,33 +357,9 @@ def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None
with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
series = _load_sber_index_series(mock_db, region="Свердловская область")
assert len(series) == 1
assert stale_month in series
# Warning о staleness должен быть залогирован
assert series == {stale_month: 100_000.0}
stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}"
def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None:
"""_load_sber_index_series НЕ логирует warning при свежей серии."""
import logging
from app.services.estimator import _load_sber_index_series
# Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога).
today = datetime.now(tz=UTC).date()
fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца
mock_db = MagicMock()
mock_db.execute.return_value.mappings.return_value.all.return_value = [
{"period_month": fresh_month, "index_value_rub_m2": 128_000.0}
]
with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
series = _load_sber_index_series(mock_db, region="Свердловская область")
assert len(series) == 1
stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}"
assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}"
# ---------------------------------------------------------------------------

View file

@ -378,7 +378,7 @@ def test_corridor_clamp_above_corridor_tier_c_clamps() -> None:
def test_corridor_clamp_inside_corridor_is_noop() -> None:
# Эконом/комфорт: headline в коридоре (с учётом slack) → ничего не меняется.
new_ppm2, new_price, new_low, new_high, clamped = _clamp(
new_ppm2, _, _, _, clamped = _clamp(
median_ppm2=140_000, corridor_high=130_000, count=20, tier="C"
)
assert clamped is False

View file

@ -178,21 +178,22 @@ def test_save_history_items_inserts_each():
):
saved = _save_yandex_history_items(db, result)
assert saved == 2
# 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE
# houses.has_panorama — считаем именно вставки истории, а не все execute.
# 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова:
# #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой.
rows = _history_rows(db)
assert isinstance(rows, list) and len(rows) == 2
# Два коммита: панорама (до истории) + батч истории. Раньше был один.
assert db.commit.call_count == 2
# Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе
# с колонкой, хвост #2674, мигр. 259.
assert db.commit.call_count == 1
def test_save_history_items_empty_no_commit():
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%).
Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается
а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает.
отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь
match_or_create_house вызывается до возврата а записей по-прежнему ноль:
истории нет, вставлять нечего.
"""
db = MagicMock()
result = YandexValuationResult(
@ -246,9 +247,9 @@ def test_save_history_items_ext_id_stable_across_calls():
def test_save_history_items_db_error_rolls_back_batch():
"""Any item failing rolls back the whole batch — batch semantics (finding #5).
#2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение
доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает),
а батч истории проходил бы успешно, и тест молча проверял бы не тот путь.
#2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе
и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы
не тот путь, стоит появиться любому новому execute перед вставкой истории.
"""
db = MagicMock()
@ -266,5 +267,4 @@ def test_save_history_items_db_error_rolls_back_batch():
saved = _save_yandex_history_items(db, result)
assert saved == 0 # whole batch rolled back
db.rollback.assert_called_once()
# Панорама коммитится отдельно и раньше — её успех не отменяет отката истории.
assert db.commit.call_count == 1
assert db.commit.call_count == 0

View file

@ -0,0 +1,510 @@
"""Unit tests for the `houses` fallback tier of `geocode()` (#2626).
Covers:
- `_norm_local_house`: normalization of corpus/slash house-number forms
(«49 к 1» / «49-к1» / «49 корпус 1» «49к1»; «88 / 2» «88/2»).
- `_extract_local_house_token`: pulling the house-number token out of a raw
user address, WITH the corpus/slash suffix that `_parse_street_house`'s
`_HOUSE_NUM` drops.
- `_clean_local_house_street` / `_row_local_house`: extracting a comparable
(street, house) pair out of the free-text `houses.address` column (multiple
scraper source formats avito/cian/derived/yandex_valuation).
- `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical
name), regardless of leading district/city noise.
- `_local_houses_match`: full tier with a mocked DB session
exact number match, corpus-1 fallback guess («49» «49к1»), and the
defensive "ambiguous → None" invariant (no guessing on >1 distinct match).
- `geocode()` wiring: local-houses tier is the LAST step, only reached when
cache/geoportal/cadastral/Nominatim all miss, and marks
`GeocodeResult.address_refined=True`.
Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as
regression fixtures: «ул Онуфриева, д 24» «Начдива Онуфриева, 24к1»,
«ул. Хрустальногорская, д. 88/2» exact match, «ул Крестинского, д 49»
genuinely ambiguous in prod data (two DIFFERENT buildings both stored as
«Крестинского, 49к1» must NOT resolve, per the defensive "no guessing" rule).
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import ( # noqa: E402
GeocodeSuggestion,
_clean_local_house_street,
_extract_local_house_token,
_local_houses_match,
_norm_local_house,
_row_local_house,
_street_tail_matches,
geocode,
)
# ── _norm_local_house ────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("raw", "expected"),
[
("49 к 1", "49к1"),
("49-к1", "49к1"),
("49 корпус 1", "49к1"),
("49 корп. 1", "49к1"),
("88 / 2", "88/2"),
("88/2", "88/2"),
("35А", "35а"),
("13Б", "13б"),
("13-б", "13б"),
("44", "44"),
],
)
def test_norm_local_house(raw: str, expected: str) -> None:
assert _norm_local_house(raw) == expected
# ── _extract_local_house_token ───────────────────────────────────────────────
@pytest.mark.parametrize(
("address", "expected"),
[
("ул Крестинского, д 49", "49"),
("ул. Хрустальногорская, д. 88/2", "88/2"),
("ул Онуфриева, д 24", "24"),
("Крестинского 49к1", "49к1"),
("8 Марта 204", "204"), # digit-leading street name doesn't confuse it
("Малышева 30", "30"),
# #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11»
# → дом «11», чужое здание). Реальные строки из trade_in_estimates:
(
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
"ул Педагогическая, д 15, кв 11",
"15",
),
(
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
"ул Педагогическая, д 15, кв 48",
"15",
),
# корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома,
# «кв 41» — уйти:
(
"620149, Свердловская обл, г Екатеринбург, Ленинский р-н, "
"ул Начдива Онуфриева, д 26 к 1, кв 41",
"26к1",
),
# подъезд/этаж — тот же класс бага, что и квартира (последнее число в
# строке — не дом):
(
"Россия, Свердловская область, Екатеринбург, Трамвайный переулок, "
"2к2, подъезд 1, этаж 25, кв. 205",
"2к2",
),
],
)
def test_extract_local_house_token(address: str, expected: str) -> None:
assert _extract_local_house_token(address) == expected
def test_extract_local_house_token_none_for_garbage() -> None:
assert _extract_local_house_token("") is None
assert _extract_local_house_token("Екатеринбург") is None
# ── _clean_local_house_street / _street_tail_matches ────────────────────────
def test_clean_local_house_street_strips_type_regardless_of_position() -> None:
"""Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются."""
assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева"
assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская"
def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None:
"""Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива
Онуфриева» (каноничное имя ГАР, как в houses.address)."""
assert _street_tail_matches("начдива онуфриева", "онуфриева") is True
def test_street_tail_matches_exact_equality() -> None:
assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True
def test_street_tail_matches_rejects_non_suffix_substring() -> None:
"""«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово."""
assert _street_tail_matches("онуфриева южная", "онуфриева") is False
# ── _row_local_house: разбор houses.address разных форматов источников ──────
@pytest.mark.parametrize(
("row_address", "expected"),
[
(
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
("р-н чкаловский мкр. ботанический крестинского", "49к1"),
),
("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")),
("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")),
(
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"),
),
("Крестинского, 44", ("крестинского", "44")),
# house-then-district order («·» separator, no comma before house) —
# match-from-start of the LAST comma-segment still finds the leading token.
("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")),
],
)
def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None:
assert _row_local_house(row_address) == expected
def test_row_local_house_none_without_house_segment() -> None:
"""Нет запятой (номер дома не отделён сегментом) → None, не гадаем."""
assert _row_local_house("Крестинского") is None
assert _row_local_house("") is None
# ── _local_houses_match: full tier, mocked db ────────────────────────────────
def _make_row(address: str, lat: float, lon: float) -> MagicMock:
row = MagicMock()
row.address = address
row.lat = lat
row.lon = lon
return row
def _db_with_rows(rows: list[MagicMock]) -> MagicMock:
db = MagicMock()
db.execute.return_value.fetchall.return_value = rows
return db
def test_local_houses_match_exact_house_number() -> None:
"""«88/2» точно совпадает с единственной строкой houses — возвращает её координаты."""
db = _db_with_rows(
[
_make_row("Хрустальногорская ул.,88", 56.79412, 60.498687),
_make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106),
]
)
hit = _local_houses_match(db, "хрустальногорская", "88/2")
assert hit is not None
assert isinstance(hit, GeocodeSuggestion)
assert hit.lat == pytest.approx(56.793218)
assert hit.lon == pytest.approx(60.497106)
assert hit.kind == "house"
def test_local_houses_match_street_tail_and_corpus1_guess() -> None:
"""«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ
корпус «24к1» уверенная догадка (нет sibling-корпусов не угадайка)."""
db = _db_with_rows(
[
_make_row(
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
56.802928,
60.551696,
),
]
)
hit = _local_houses_match(db, "онуфриева", "24")
assert hit is not None
assert hit.lat == pytest.approx(56.802928)
assert hit.lon == pytest.approx(60.551696)
def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None:
"""#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ
разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «24к1»
не угадывает конкретное здание среди known-siblings честный None, не
«уверенный» результат с confidence='exact' на случайно выбранном доме."""
db = _db_with_rows(
[
_make_row(
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
56.802928,
60.551696,
),
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
]
)
assert _local_houses_match(db, "онуфриева", "24") is None
def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None:
"""Sibling-guard ловит не только «кN», но и «/N» вариант того же номера."""
db = _db_with_rows(
[
_make_row("улица X, 24к1", 56.80, 60.60),
_make_row("улица X, 24/2", 56.81, 60.61),
]
)
assert _local_houses_match(db, "x", "24") is None
def test_local_houses_match_no_corpus1_candidate_returns_none() -> None:
"""Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None."""
db = _db_with_rows(
[
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
]
)
assert _local_houses_match(db, "онуфриева", "24") is None
def test_local_houses_match_ambiguous_exact_number_returns_none() -> None:
"""Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами
(две разные строки houses) неоднозначность, фолбэк не угадывает, None."""
db = _db_with_rows(
[
_make_row(
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
56.789895,
60.632464,
),
_make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079),
]
)
assert _local_houses_match(db, "крестинского", "49к1") is None
def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None:
"""«49» → «49к1»-кандидатов больше одного (разные координаты) → None."""
db = _db_with_rows(
[
_make_row("улица X, 49к1", 56.80, 60.60),
_make_row("улица X, 49к1", 56.81, 60.61),
]
)
assert _local_houses_match(db, "x", "49") is None
def test_local_houses_match_deduplicates_same_building_different_sources() -> None:
"""Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными
координатами НЕ считается неоднозначностью (дедуп по округлённым coords)."""
db = _db_with_rows(
[
_make_row("улица X, 49к1", 56.800001, 60.600001),
_make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source
]
)
hit = _local_houses_match(db, "x", "49к1")
assert hit is not None
assert hit.lat == pytest.approx(56.800001)
def test_local_houses_match_no_guess_for_non_digit_house() -> None:
"""Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1
ДОГАДКА не пробуется (не «35к3к1»), результат None."""
db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)])
assert _local_houses_match(db, "x", "35к3") is None
def test_local_houses_match_returns_none_on_db_error() -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("connection lost")
assert _local_houses_match(db, "онуфриева", "24") is None
# ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ───────────────
def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None:
"""Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 —
~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только
ЗАПРОС пользователя, не координаты строки-источника bbox-фильтр внутри
`_local_houses_match` обязан отбросить такую строку, а не вернуть её как
confidence='exact' совпадение чужого города."""
db = _db_with_rows(
[_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ
)
assert _local_houses_match(db, "маяковского", "7") is None
def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None:
"""Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT)
по-прежнему проходит bbox-фильтр не режет реальные ЕКБ-дома."""
db = _db_with_rows(
[_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)],
)
hit = _local_houses_match(db, "маяковского", "8")
assert hit is not None
assert hit.lat == pytest.approx(56.862701)
# ── deterministic ORDER BY (#2626 review R2 #5) ──────────────────────────────
def test_local_houses_match_query_has_deterministic_order_by() -> None:
"""Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку
в порядке сканирования недетерминированно между вызовами. SQL обязан
сортировать явно."""
db = _db_with_rows([])
_local_houses_match(db, "x", "1")
sql_text = str(db.execute.call_args[0][0])
assert "ORDER BY" in sql_text.upper()
# ── geocode() wiring — last-resort tier, sets address_refined ───────────────
async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None:
"""Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир
вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`."""
db = MagicMock()
hit = GeocodeSuggestion(
label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
lat=56.802928,
lon=60.551696,
kind="house",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None),
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._cache_put") as mock_cache_put,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
patch(
"app.services.geocoder._local_houses_match",
return_value=hit,
) as mock_local,
):
result = await geocode("ул Онуфриева, д 24", db)
assert result is not None
assert result.lat == pytest.approx(56.802928)
assert result.confidence == "exact"
assert result.address_refined is True
mock_local.assert_called_once()
# #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник
# координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует.
mock_cache_put.assert_not_called()
async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None:
"""geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` —
флаг честно относится ТОЛЬКО к houses-фолбэку."""
db = MagicMock()
hit = GeocodeSuggestion(
label="ул. Серова, д. 27, Екатеринбург",
full_address="ул. Серова, д. 27, Екатеринбург",
lat=56.81188,
lon=60.59739,
kind="house",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=hit),
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._local_houses_match",
) as mock_local,
):
result = await geocode("Серова 27", db)
assert result is not None
assert result.address_refined is False
mock_local.assert_not_called()
async def test_geocode_returns_none_when_local_houses_also_misses() -> None:
"""Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем)."""
db = MagicMock()
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None),
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local,
):
result = await geocode("ул Онуфриева, д 24", db)
assert result is None
mock_local.assert_called_once()
async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None:
"""End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом
«кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11
(`Педагогическая ул.,11` чужое здание) `_local_houses_match` не
замокан, проверяем полную цепочку `geocode()` `_extract_local_house_token`
SQL-lookup."""
db = _db_with_rows(
[
_make_row("Педагогическая ул.,11", 56.835387, 60.654104),
_make_row("Педагогическая ул.,15", 56.835284, 60.655829),
]
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None),
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._cache_put") as mock_cache_put,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=None,
),
):
result = await geocode(
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
"ул Педагогическая, д 15, кв 11",
db,
)
assert result is not None
assert result.lat == pytest.approx(56.835284)
assert result.lon == pytest.approx(60.655829)
assert result.address_refined is True
mock_cache_put.assert_not_called()

View file

@ -0,0 +1,35 @@
"""GET/HEAD /health — uptime-monitor honesty (#uptime-honest-green).
GlitchTip PING-мониторы шлют HEAD (или GET без чтения тела). Голый
`@app.get("/health")` без явного HEAD-хендлера отдаёт 405 на HEAD Starlette
НЕ добавляет HEAD автоматически к FastAPI `@app.get()` роуту (в отличие от
низкоуровневого `Route(methods=["GET"])`). Прод-симптом: `HEAD /health` 405,
монитор либо красный по конструкции, либо (при PING без сверки статуса)
зелёный вне зависимости от факта. Тест фиксирует оба метода.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app
def test_health_get_ok() -> None:
client = TestClient(app)
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
def test_health_head_ok_no_body() -> None:
"""HEAD /health — то, что реально шлёт uptime-monitor. Должен быть 200, без тела."""
client = TestClient(app)
resp = client.head("/health")
assert resp.status_code == 200
assert resp.content == b""
# RFC 9110 §9.3.2 — HEAD должен вернуть те же заголовки представления
# (Content-Type), что и GET; Content-Length допустимо не совпадать (payload
# header field, MAY быть опущен для HEAD).
assert resp.headers["content-type"] == "application/json"

View file

@ -0,0 +1,336 @@
"""honest-run-status (2026-08-15): статус прогона не должен рапортовать 'done' поверх
провала или нуля. Три прод-факта закрыты этой правкой:
(a) avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1}
-> status='done' 89% отказов, статус зелёный. mark_backfill_finished звал
mark_done, потому что produced=6 (>0); ни _sweep_run_did_nothing (нет
anchors_total/errors_count у backfill'ов), ни _phase_totally_failed (ключи
"attempted"/"failed" без фазового префикса) эту форму counters не ловили.
Фикс: _failed_ratio_too_high внутри mark_done.
(b) yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при
processed=5, succeeded=0, rows_inserted=0, failed_resolve=4-5 сторож нулевого
результата (_alert_if_consecutive_zero_results) слеп, т.к. _RESULT_COUNTER_KEYS
не знал ни одного ключа этого sweep'а (total_seen/lots_fetched/unique_fetched).
Фикс: _RESULT_COUNTER_KEYS дополнен 'succeeded'. Первая версия правки добавляла
голые 'rows_inserted'/'processed' ревью нашло, что 'rows_inserted' пишет ЕЩЁ
rosreestr_dkp_import (66/67 прод-прогонов, здоровый ноль догнавшего импорта, а не
отказ) и завёл бы непрерываемый ложный zero-стрик, а 'processed' счётчик
попыток (==limit даже при частичном провале у newbuilding_enrich) и маскирует
реальные отказы. 'succeeded' пишут только yandex_newbuilding_sweep и
newbuilding_enrich, численно совпадает с прежним 'rows_inserted' на всех
прод-прогонах sweep'а — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured
и test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже.
(c) admin-витрина показывала new_count=0 у трёх подряд cian_full_load, хотя реально
сохранено saved_inserted=482/214/239 full-load'ы не пишут ни 'new_count', ни
'lots_inserted'. Фикс: _column_counts дополнен saved_inserted/rows_inserted.
Проверяем на обоих модулях (kit-копия и app-копия байт-эквивалентны по докстрингу
runs.py), тем же паттерном, что test_2625_run_that_did_nothing.py.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration import runs as kit_runs
from app.services import scrape_runs as app_runs
_MODULES = {"kit": kit_runs, "app": app_runs}
def _capture_status(mod: Any, counters: dict[str, Any]) -> list[str]:
"""Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов.
Тот же helper, что в test_2625_run_that_did_nothing.py читаем СТАТУС В SQL, а не
имя вызванной функции.
"""
statuses: list[str] = []
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
sql = str(stmt)
for status in ("done", "failed", "banned"):
if f"status = '{status}'" in sql:
statuses.append(status)
return MagicMock()
db = MagicMock()
db.execute.side_effect = _execute
with patch.object(mod, "sentry_sdk", MagicMock()):
mod.mark_done(db, 1, dict(counters))
return statuses
def _capture_backfill_status(
counters: dict[str, Any], *, source: str = "avito_detail_backfill", aborted: bool = False
) -> list[str]:
"""Прогнать app_runs.mark_backfill_finished на фейковой сессии (mark_done НЕ мокан —
в отличие от test_backfill_honest_status.py, здесь важно именно его РЕАЛЬНОЕ
поведение: mark_backfill_finished решает вызвать mark_done, а решает ли mark_done
остаться 'done' или сам себя переквалифицировать в 'failed' предмет этого теста).
mark_backfill_finished есть только в app_runs (kit-копия его не держит см.
docstring модуля runs.py, "mark_skipped есть только здесь" тот же принцип
относится к продуктовым финализаторам detail-backfill'ов).
"""
statuses: list[str] = []
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
sql = str(stmt)
for status in ("done", "failed", "banned"):
if f"status = '{status}'" in sql:
statuses.append(status)
return MagicMock()
db = MagicMock()
db.execute.side_effect = _execute
with patch.object(app_runs, "sentry_sdk", MagicMock()):
app_runs.mark_backfill_finished(
db, 1, dict(counters), source=source, aborted_by_blocks=aborted
)
return statuses
# ── (a) failed_ratio: прод-факт avito_detail_backfill 15.08 ─────────────────────────
def test_prod_fact_avito_15_08_no_longer_done() -> None:
"""{"attempted":64,"failed":57,"enriched":6,"blocked":1} — 89% отказов — 'failed',
НЕ 'done'. Красный на старом коде (produced=6 != 0 -> mark_done -> 'done')."""
counters = {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1}
assert _capture_backfill_status(counters) == ["failed"]
def test_prod_fact_avito_reason_names_the_ratio() -> None:
reason = app_runs._failed_ratio_too_high(
{"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1}
)
assert reason is not None
assert "failed-ratio-honest-status" in reason
assert "57 из 64" in reason
assert "89%" in reason
@pytest.mark.parametrize("name", list(_MODULES))
@pytest.mark.parametrize(
("counters", "flagged", "why"),
[
({"attempted": 64, "failed": 57}, True, "прод-факт: 89% отказов"),
({"attempted": 10, "failed": 5}, True, "ровно порог failed (0.5)"),
({"attempted": 20, "failed": 3}, True, "ровно порог degraded (0.15)"),
({"attempted": 20, "failed": 2}, False, "ниже порога degraded (0.10)"),
({"attempted": 2, "failed": 2}, False, "ratio=1.0, но < _FAILED_RATIO_MIN_ATTEMPTS"),
({"attempted": 0, "failed": 0}, False, "нет попыток вовсе"),
({"failed": 5}, False, "нет attempted — чужой словарь"),
({"attempted": 50}, False, "нет failed — чужой словарь"),
({}, False, "пустые counters"),
(
{"anchors_total": 5, "errors_count": 5, "lots_fetched": 0},
False,
"sweep-словарь (anchors_total), не detail-backfill",
),
],
)
def test_failed_ratio_classifier_boundaries(
name: str, counters: dict[str, Any], flagged: bool, why: str
) -> None:
reason = _MODULES[name]._failed_ratio_too_high(counters)
assert (reason is not None) is flagged, why
# ── (5) не должен палить прогоны с малой/умеренной долей отказов ────────────────────
@pytest.mark.parametrize("name", list(_MODULES))
def test_low_failure_ratio_stays_done(name: str) -> None:
"""Штатный шум (10% отказов) не становится 'failed' — не каждый отказ диагноз."""
counters = {"attempted": 50, "enriched": 45, "failed": 5}
assert _capture_status(_MODULES[name], counters) == ["done"]
def test_tiny_batch_zero_produced_fails_via_old_rule_not_ratio() -> None:
"""2 попытки, обе отказали, produced=0 — доля тут не при чём (attempted < floor
_FAILED_RATIO_MIN_ATTEMPTS, _failed_ratio_too_high вернул бы None); статус всё
равно 'failed', но по СТАРОМУ правилу #2674 (produced==0), внутри
mark_backfill_finished mark_done/_failed_ratio_too_high тут не вызываются вовсе.
Показывает, что новая проверка не дублирует и не подменяет старую."""
counters = {"attempted": 2, "enriched": 0, "failed": 2}
assert _capture_backfill_status(counters) == ["failed"]
def test_tiny_batch_with_partial_success_stays_done() -> None:
"""2 попытки, 1 успех, 1 отказ (ratio=0.5, но attempted < floor=3) — стрик слишком
короткий, чтобы доля что-то значила -> остаётся 'done'."""
counters = {"attempted": 2, "enriched": 1, "failed": 1}
assert _capture_backfill_status(counters) == ["done"]
@pytest.mark.parametrize("name", list(_MODULES))
def test_honest_empty_sweep_unaffected_by_failed_ratio(name: str) -> None:
"""Сознательно спящее расписание (город без новостроек): sweep-словарь без
attempted/failed вовсе -> failed_ratio не о чем судить, честная пустота остаётся
'done' (см. также test_2625_run_that_did_nothing.py::test_honest_empty_stays_done)."""
counters = {"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}
assert _capture_status(_MODULES[name], counters) == ["done"]
# ── (b) _RESULT_COUNTER_KEYS: прод-факт yandex_newbuilding_sweep 26.07-10.08 ─────────
def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None:
"""processed=5, succeeded=0, rows_inserted=0, failed_resolve=4 — раньше
_run_result_count возвращал None ("не измерено"); теперь измеренный 0 (через
'succeeded', не 'rows_inserted' см. ниже, почему ключ переигран ревью)."""
counters = {
"total": 309,
"fetchable": 200,
"pending": 50,
"processed": 5,
"skipped_already_enriched": 0,
"succeeded": 0,
"resolved_slug": 1,
"failed_resolve": 4,
"failed_fetch": 0,
"rows_inserted": 0,
"duration_sec": 42.0,
}
assert app_runs._run_result_count(counters) == 0
assert kit_runs._run_result_count(counters) == 0
def test_succeeded_is_the_measured_key_not_rows_inserted_or_processed() -> None:
"""'succeeded' читается как результат; голые 'rows_inserted'/'processed' в
_RESULT_COUNTER_KEYS больше не участвуют (были в первой версии правки, снято
ревью см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured и
test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже)."""
counters = {"processed": 5, "rows_inserted": 0}
assert app_runs._run_result_count(counters) is None
assert kit_runs._run_result_count(counters) is None
def test_rosreestr_dkp_import_healthy_zero_stays_unmeasured() -> None:
"""Прод-факт rosreestr_dkp_import (2026-08-15, 66 из 67 прогонов за 90д): инкрементальный
импорт догнал источник rows_fetched==rows_skipped, rows_inserted=0. Это ЗДОРОВЫЙ
ответ (нечего вставлять), а не отказ; словарь не содержит 'succeeded' вовсе.
Первая версия правки добавляла голый 'rows_inserted' в _RESULT_COUNTER_KEYS тогда
этот прод-факт читался бы как "измеренный провал" и копил бы практически
непрерываемый zero-стрик (rosreestr_dkp_import не прерывается другим статусом:
он либо 'done' с этим же нулём, либо не бежал). Ревью поймало это до деплоя
правильный ответ: "не измерено" (None), стрик не копится."""
counters = {
"last_id": 6829903,
"batches_done": 49,
"rows_errored": 0,
"rows_fetched": 96974,
"rows_skipped": 96974,
"rows_updated": 0,
"rows_inserted": 0,
}
assert app_runs._run_result_count(counters) is None
assert kit_runs._run_result_count(counters) is None
def test_newbuilding_enrich_partial_failure_not_masked_by_processed() -> None:
"""Прод-факт newbuilding_enrich (09.08): processed=25 (счётчик ПОПЫТОК, ==limit),
succeeded=14 44% отказов. Если бы сторож читал 'processed' как результат, партиальный
провал замаскировался бы под measured-25 (сторож нулевого результата промолчал бы
ровно там, где должен был сработать при полном провале). 'succeeded' даёт честные 14."""
counters = {
"failed": 11,
"enriched": 14,
"attempted": 25,
"processed": 25,
"succeeded": 14,
"failed_fetch": 11,
}
assert app_runs._run_result_count(counters) == 14
assert kit_runs._run_result_count(counters) == 14
@pytest.mark.parametrize("name", list(_MODULES))
def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) -> None:
"""(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с succeeded=0
-> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал результат "не
измеренным" и молчал бы вечно (см. #2703 в docstring модуля)."""
mod = _MODULES[name]
row = MagicMock()
row.status = "done"
row.counters = {"processed": 5, "succeeded": 0, "rows_inserted": 0, "failed_resolve": 4}
db = MagicMock()
result = MagicMock()
result.fetchall.return_value = [row, row, row]
db.execute.return_value = result
with patch.object(mod, "sentry_sdk") as mock_sentry:
mod._alert_if_consecutive_zero_results(db, "yandex_newbuilding_sweep")
mock_sentry.capture_message.assert_called_once()
@pytest.mark.parametrize("name", list(_MODULES))
def test_zero_result_watchdog_silent_on_rosreestr_dkp_import_streak(name: str) -> None:
"""Негативный аналог теста выше: та же лестница из 3 подряд 'done', но словарь
rosreestr_dkp_import (нет 'succeeded') -> сторож не считает результат измеренным
и НЕ шлёт алерт регрессионный тест на замечание ревью (HIGH #1)."""
mod = _MODULES[name]
row = MagicMock()
row.status = "done"
row.counters = {
"last_id": 6829903,
"rows_fetched": 96974,
"rows_skipped": 96974,
"rows_inserted": 0,
}
db = MagicMock()
result = MagicMock()
result.fetchall.return_value = [row, row, row]
db.execute.return_value = result
with patch.object(mod, "sentry_sdk") as mock_sentry:
mod._alert_if_consecutive_zero_results(db, "rosreestr_dkp_import")
mock_sentry.capture_message.assert_not_called()
# ── (c) _column_counts: прод-факт cian_full_load new_count=0 при saved_inserted>0 ───
@pytest.mark.parametrize("name", list(_MODULES))
def test_prod_fact_cian_full_load_saved_inserted_surfaces_as_new_count(name: str) -> None:
"""saved_inserted=482 (прод-факт: три подряд прогона 482/214/239) — new_count
больше не 0, хотя ключей 'new_count'/'lots_inserted' в counters нет вовсе."""
counters = {"unique_fetched": 1200, "saved_inserted": 482, "saved_updated": 30}
total_seen, new_count = _MODULES[name]._column_counts(counters)
assert total_seen == 1200
assert new_count == 482
@pytest.mark.parametrize("name", list(_MODULES))
def test_yandex_newbuilding_rows_inserted_surfaces_as_new_count(name: str) -> None:
counters = {"rows_inserted": 7}
_, new_count = _MODULES[name]._column_counts(counters)
assert new_count == 7
@pytest.mark.parametrize("name", list(_MODULES))
def test_new_count_priority_unchanged_by_new_keys(name: str) -> None:
"""'new_count' явный ключ всё ещё побеждает 'lots_inserted'/'saved_inserted'
расширение списка не меняет приоритет уже существующих ключей."""
counters = {"new_count": 5, "lots_inserted": 99, "saved_inserted": 1}
_, new_count = _MODULES[name]._column_counts(counters)
assert new_count == 5
@pytest.mark.parametrize("name", list(_MODULES))
def test_lots_inserted_still_beats_saved_inserted(name: str) -> None:
"""Порядок пикулярно НЕ переставлен для уже существующей пары — 'lots_inserted'
(city/newbuilding-sweep'ы) проверяется раньше 'saved_inserted' (full-load'ы),
т.к. это разные, непересекающиеся семейства источников."""
counters = {"lots_inserted": 12, "saved_inserted": 999}
_, new_count = _MODULES[name]._column_counts(counters)
assert new_count == 12

View file

@ -0,0 +1,215 @@
"""listing_segment upsert self-heal: строка не должна вечно застревать с NULL-сегментом.
Баг: `scraper_kit.base.save_listings` писал `listing_segment` ТОЛЬКО в INSERT-ветке
upsert'а — колонки не было ни в `ON CONFLICT (dedup_hash) DO UPDATE SET`, ни в
reconcile-UPDATE (dedup_hash-drift fallback, срабатывает при UniqueViolation по
(source, source_id)). Итог: если первый скрейп объявления не смог определить сегмент
(классификатор вернул None), строка рождалась с `listing_segment IS NULL` и
НИКОГДА не самочинялась на последующих пересборах, даже когда сегмент становился
определим. Симптом лечили отдельной джобой деактивации
(`deactivate_stale_{cian,yandex}_null_segment`, миграция 266, PR #2908) — чистит
мусор в `is_active`, но не лечит саму запись сегмента.
Fix: `listing_segment = COALESCE(EXCLUDED.listing_segment, listings.listing_segment)`
в ON CONFLICT DO UPDATE + `listing_segment = COALESCE(:listing_segment, listing_segment)`
в reconcile UPDATE тот же идиом, что уже применён для `city` (#2594,
test_listings_city_from_sweep.py) и `kitchen_area_m2`/`ceiling_height_m` (#2007):
новое значение обновляет строку, но НЕ затирает уже известное пустым.
Тесты здесь, как и соседний test_listings_city_from_sweep.py, мокают db.execute и
проверяют SQL-текст + bind-параметры (unit-уровень, без реальной Postgres)
COALESCE-семантику "новое непустое побеждает / пустое не затирает старое" исполняет
сама база при выполнении запроса.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.base import ScrapedLot as KitLot
from scraper_kit.base import save_listings as kit_save_listings
@contextmanager
def _nested_ctx() -> Any:
yield MagicMock()
def _mock_db_insert_path(listing_id: int = 42) -> MagicMock:
"""Session mock для fresh INSERT path (xmax = 0 → inserted)."""
insert_row = MagicMock()
insert_row.id = listing_id
insert_row.inserted = True
db = MagicMock()
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
s = str(sql)
res = MagicMock()
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
res.fetchone.return_value = None
elif "FROM listings_snapshots" in s:
res.fetchone.return_value = None
elif "INSERT INTO listings (" in s:
res.fetchone.return_value = insert_row
else:
res.fetchone.return_value = None
return res
db.execute.side_effect = _execute
db.begin_nested.side_effect = _nested_ctx
return db
def _find_call(db: MagicMock, needle: str) -> tuple[str, dict[str, Any]]:
for call in db.execute.call_args_list:
sql = str(call.args[0])
if needle in sql:
params = call.args[1] if len(call.args) > 1 else {}
return sql, params
raise AssertionError(f"SQL containing {needle!r} not found")
def _kit_matcher() -> MagicMock:
matcher = MagicMock()
matcher.match_or_create_house.return_value = (101, 1.0, "new")
matcher.upsert_listing_source.return_value = None
return matcher
def _lot(
source: str = "avito",
source_id: str = "1",
listing_segment: str | None = None,
) -> KitLot:
return KitLot(
source=source,
source_url=f"https://www.{source}.ru/item/{source_id}",
source_id=source_id,
address="ул. Победы, 30",
listing_segment=listing_segment,
price_rub=3_000_000,
)
# ── save_listings(...) — INSERT path ────────────────────────────────────────
def test_save_listings_writes_listing_segment_into_insert_sql() -> None:
"""listing_segment передаётся в SQL params И колонка есть в INSERT-списке."""
db = _mock_db_insert_path()
lot = _lot(listing_segment="vtorichka")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
sql, params = _find_call(db, "INSERT INTO listings (")
assert "listing_segment" in sql, "listing_segment column must be in INSERT column list"
assert params["listing_segment"] == "vtorichka"
def test_save_listings_listing_segment_none_backward_compat() -> None:
"""Классификатор не определил сегмент (None) — INSERT всё равно проходит, NULL."""
db = _mock_db_insert_path()
lot = _lot(listing_segment=None)
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["listing_segment"] is None
# ── ON CONFLICT DO UPDATE — COALESCE self-heal (главный фикс) ──────────────
def test_save_listings_on_conflict_coalesces_listing_segment() -> None:
"""ON CONFLICT DO UPDATE — listing_segment = COALESCE(EXCLUDED.listing_segment,
listings.listing_segment), не blind overwrite и не "никогда не обновляется"."""
db = _mock_db_insert_path()
lot = _lot(listing_segment="vtorichka")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
sql, params = _find_call(db, "INSERT INTO listings (")
assert "listing_segment = COALESCE(" in sql
assert "EXCLUDED.listing_segment, listings.listing_segment" in sql
# Повторный скрейп с ОПРЕДЕЛЁННЫМ сегментом — новое значение уходит в EXCLUDED,
# COALESCE на стороне Postgres применит его к прежде-NULL строке (self-heal).
assert params["listing_segment"] == "vtorichka"
def test_save_listings_on_conflict_listing_segment_none_does_not_blind_overwrite() -> None:
"""Повторный скрейп БЕЗ сегмента (classifier снова None) — SQL всё равно
несёт COALESCE (не голый EXCLUDED), значит уже известный сегмент строки
в БД НЕ будет затёрт пустым при выполнении запроса."""
db = _mock_db_insert_path()
lot = _lot(listing_segment=None)
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
sql, params = _find_call(db, "INSERT INTO listings (")
assert "listing_segment = COALESCE(" in sql
assert "EXCLUDED.listing_segment, listings.listing_segment" in sql
assert params["listing_segment"] is None
# ── Reconcile UPDATE (dedup_hash drift) — тот же self-heal ─────────────────
def test_save_listings_reconcile_update_coalesces_listing_segment() -> None:
"""dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:listing_segment,
listing_segment), не blind overwrite. Без этого фикса строки, дошедшие до
reconcile (content дрейфит, старый dedup_hash не находится, INSERT ловит
UniqueViolation по (source, source_id)), остались бы незалеченными."""
import psycopg.errors
from sqlalchemy.exc import IntegrityError
uv_orig = psycopg.errors.UniqueViolation()
integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig)
rec_row = MagicMock()
rec_row.id = 88
db = MagicMock()
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
s = str(sql)
res = MagicMock()
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
res.fetchone.return_value = None
elif "FROM listings_snapshots" in s:
res.fetchone.return_value = None
elif "INSERT INTO listings (" in s:
raise integrity_err
elif "UPDATE listings" in s and "SET dedup_hash" in s:
res.fetchone.return_value = rec_row
else:
res.fetchone.return_value = None
return res
db.execute.side_effect = _execute
@contextmanager
def _nested() -> Any:
try:
yield MagicMock()
except IntegrityError:
raise
db.begin_nested.side_effect = _nested
lot = _lot(source="avito", source_id="7960764619", listing_segment="novostroyki")
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
sql, params = _find_call(db, "SET dedup_hash")
assert "listing_segment = COALESCE(:listing_segment, listing_segment)" in sql
assert params["listing_segment"] == "novostroyki"

View file

@ -0,0 +1,452 @@
"""Static guards for migration 262 (wave 2 — оставшиеся 40 городов Свердловской обл.).
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой
БД; здесь фиксируем структурные инварианты миграции (транзакционность, отсутствие DDL,
отсутствие psycopg CAST-ловушки, все enabled=false) и, ГЛАВНОЕ, parity-guard между
data/sql (что реально сеется в scrape_schedules) и `scraper_kit.orchestration.pipeline`
(`CITY_ANCHORS`/`CITY_LOCATIONS`, откуда sweep берёт координаты и provider-id по
`default_params->>'city'`):
- каждый slug из миграций 179_/262_ ОБЯЗАН существовать в CITY_ANCHORS иначе
`get_city_anchors(slug)` вернёт None и `_job_*_city_sweep` молча упадёт на
EKB_ANCHORS (город "включат", а сборка физически уйдёт в Екатеринбург);
- и наоборот: КАЖДЫЙ ключ CITY_ANCHORS (кроме None-пути ЕКБ) обязан иметь schedule-
строки где-то (179_ ИЛИ 262_);
- #262 ревью (после первой версии файла): строка для (provider, city) существует
ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий provider-идентификатор в CITY_LOCATIONS
подтверждён (не None) НЕ "каждый город получает все 3 источника". Первая версия
заводила все 123 (41×3) строки, планируя добыть идентификаторы ПОСЛЕ это был бы
ровно тот силентный ЕКБ-fallback баг, о котором она сама предупреждала. Этот файл
проверяет обратное соответствие в обе стороны для avito_slug/yandex_rgid/
cian_region_id.
Это ловит ИМЕННО тот класс регрессии, которого просил избежать заказчик: опечатка в
slug/рассинхрон идентификатора при заведении миграции, из-за которой sweep молча
резолвится на EKB_ANCHORS или ЕКБ region_id/rgid.
"""
from __future__ import annotations
import re
from pathlib import Path
from scraper_kit.orchestration.pipeline import CITY_ANCHORS, CITY_LOCATIONS
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_179 = _SQL_DIR / "179_scrape_schedules_seed_oblast_city_sweeps.sql"
_MIGRATION_262 = _SQL_DIR / "262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql"
_PROVIDERS = ("avito", "cian", "yandex")
# source-строка внутри VALUES (), напр. " 'avito_city_sweep_nizhniy_tagil',".
# Ограничено 4-пробельным отступом + запятой в конце — не матчит примеры в header-
# комментариях (см. "UPDATE scrape_schedules SET enabled = true WHERE source = '...';").
_ROW_SOURCE_RE = re.compile(r"^ '(avito|cian|yandex)_city_sweep_([a-z_]+)',$", re.MULTILINE)
_WAVE1_SLUGS = {
"nizhniy_tagil",
"kamensk_uralskiy",
"pervouralsk",
"verkhnyaya_pyshma",
"serov",
}
_WAVE2_SLUGS = {
"novouralsk",
"revda",
"polevskoy",
"asbest",
"bogdanovich",
"irbit",
"krasnoufimsk",
"berezovskiy",
"zarechny",
"kachkanar",
"krasnoturinsk",
"severouralsk",
"ivdel",
"tavda",
"turinsk",
"sysert",
"sredneuralsk",
"degtyarsk",
"verkhnyaya_salda",
"nizhnyaya_salda",
"nevyansk",
"artemovskiy",
"kamyshlov",
"alapaevsk",
"sukhoy_log",
"kushva",
"krasnouralsk",
"karpinsk",
"nizhnyaya_tura",
"verkhniy_tagil",
"nizhnie_sergi",
"lesnoy",
"rezh",
"aramil",
"volchansk",
"verkhnyaya_tura",
"mikhaylovsk",
"verkhoturye",
"talitsa",
"novaya_lyalya",
}
assert len(_WAVE2_SLUGS) == 40
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
def _sql(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _executable_sql(path: Path) -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql(path).splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _row_sources(path: Path) -> list[tuple[str, str]]:
"""(provider, slug) пар из исполняемых INSERT-строк VALUES (не из header-комментов)."""
return _ROW_SOURCE_RE.findall(_executable_sql(path))
def _flat(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
# ── existence / basic shape ───────────────────────────────────────────────────
def test_migration_262_exists() -> None:
assert _MIGRATION_262.exists(), f"missing migration: {_MIGRATION_262}"
def test_migration_262_is_transactional() -> None:
sql = _sql(_MIGRATION_262)
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_262_no_ddl() -> None:
flat = _flat(_executable_sql(_MIGRATION_262))
assert "alter table" not in flat
assert "create table" not in flat
assert "drop table" not in flat
assert "truncate" not in flat
def test_migration_262_no_psycopg_cast_trap() -> None:
assert not re.search(r":\w+::", _sql(_MIGRATION_262))
def test_migration_262_idempotent_on_conflict_do_nothing() -> None:
flat = _flat(_executable_sql(_MIGRATION_262))
assert "on conflict (source) do nothing" in flat
# Одна INSERT-инструкция на весь файл (не 102 отдельных INSERT) — ON CONFLICT
# покрывает весь батч разом; повторный прогон всего файла — no-op.
assert flat.count("insert into scrape_schedules") == 1
def test_migration_262_excludes_bisert_entirely() -> None:
"""Бисерть — пгт, не город; у Циана её нет вообще (любой запрос отдаёт Сысерть).
Regression-guard: ни одной строки source LIKE '%bisert%' в исполняемом SQL."""
flat = _flat(_executable_sql(_MIGRATION_262))
assert "bisert" not in flat
# ── row counts (102 = 23 avito + 40 cian + 39 yandex) ───────────────────────
def test_migration_262_has_exactly_102_rows() -> None:
rows = _row_sources(_MIGRATION_262)
assert len(rows) == 102, f"expected 23 avito + 40 cian + 39 yandex = 102 rows, got {len(rows)}"
def test_migration_262_row_counts_per_provider() -> None:
rows = _row_sources(_MIGRATION_262)
by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS}
for provider, slug in rows:
by_provider[provider].add(slug)
assert len(by_provider["avito"]) == 23
assert len(by_provider["cian"]) == 40
assert len(by_provider["yandex"]) == 39
# cian — ровно все 40 wave-2 slug'ов (cian_region_id подтверждён у всех).
assert by_provider["cian"] == _WAVE2_SLUGS
# yandex — все, КРОМЕ mikhaylovsk.
assert by_provider["yandex"] == _WAVE2_SLUGS - {"mikhaylovsk"}
# avito — подмножество (только подтверждённые avito_slug), не пересекается с
# городами, у которых avito_slug=None в CITY_LOCATIONS.
assert by_provider["avito"] <= _WAVE2_SLUGS
def test_migration_262_no_duplicate_sources() -> None:
rows = _row_sources(_MIGRATION_262)
sources = [f"{p}_city_sweep_{s}" for p, s in rows]
assert len(sources) == len(set(sources)), "duplicate source в 262_"
def test_migration_262_does_not_touch_wave1_cities() -> None:
"""Regression-guard: 262_ не должен переопределять/дублировать wave-1 5 городов —
они уже сидированы 179_ и живут (enabled управляется отдельно от них)."""
rows = _row_sources(_MIGRATION_262)
slugs = {slug for _provider, slug in rows}
overlap = slugs & _WAVE1_SLUGS
assert not overlap, f"262_ пересекается с wave-1 городами: {overlap}"
def test_migration_262_all_rows_enabled_false() -> None:
"""Все 102 строки должны быть enabled=false (dormant by design — оператор включает
волнами вручную). Считаем по числу `false,` сразу после source-литерала."""
executable = _executable_sql(_MIGRATION_262)
tuples = re.findall(
r"'(?:avito|cian|yandex)_city_sweep_[a-z_]+',\s*\n\s*(true|false),",
executable,
)
assert len(tuples) == 102
assert set(tuples) == {"false"}, "найдена строка с enabled=true — нарушение dormant-инварианта"
# ── CITY_LOCATIONS parity (ГЛАВНЫЙ regression-guard после ревью) ───────────
def test_migration_262_row_exists_iff_identifier_confirmed() -> None:
"""Строка (provider, city) есть в 262_ ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий
provider-идентификатор в CITY_LOCATIONS подтверждён (не None).
Это единственный источник правды после ревью: НЕ "каждый wave-2 город получает 3
строки" (так было в отклонённой первой версии — риск silent EKB-fallback), а
"строка существует ровно там, где есть подтверждённый id".
"""
rows = _row_sources(_MIGRATION_262)
by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS}
for provider, slug in rows:
by_provider[provider].add(slug)
for slug in _WAVE2_SLUGS:
loc = CITY_LOCATIONS[slug]
has_avito_row = slug in by_provider["avito"]
has_avito_id = loc.avito_slug is not None
assert has_avito_row == has_avito_id, (
f"{slug}: avito_slug={loc.avito_slug!r} (confirmed={has_avito_id}) но "
f"avito-row-exists={has_avito_row} — рассинхрон CITY_LOCATIONS vs 262_"
)
has_cian_row = slug in by_provider["cian"]
has_cian_id = loc.cian_region_id is not None
assert has_cian_row == has_cian_id, (
f"{slug}: cian_region_id={loc.cian_region_id!r} (confirmed={has_cian_id}) но "
f"cian-row-exists={has_cian_row} — рассинхрон CITY_LOCATIONS vs 262_"
)
has_yandex_row = slug in by_provider["yandex"]
has_yandex_id = loc.yandex_rgid is not None
assert has_yandex_row == has_yandex_id, (
f"{slug}: yandex_rgid={loc.yandex_rgid!r} (confirmed={has_yandex_id}) но "
f"yandex-row-exists={has_yandex_row} — рассинхрон CITY_LOCATIONS vs 262_"
)
def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None:
"""Каждый slug из CITY_LOCATIONS с непустым avito_slug имеет avito_city_sweep_<slug>
строку в 262_ (wave-2) или 179_ (wave-1) и наоборот, каждая avito-строка
принадлежит городу с непустым avito_slug."""
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "avito"}
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "avito"}
scheduled_avito = rows_262 | rows_179
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
assert scheduled_avito == confirmed_avito, (
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
)
def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None:
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "yandex"}
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "yandex"}
scheduled_yandex = rows_262 | rows_179
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
assert scheduled_yandex == confirmed_yandex, (
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
)
def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> None:
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "cian"}
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "cian"}
scheduled_cian = rows_262 | rows_179
confirmed_cian = {
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
}
assert scheduled_cian == confirmed_cian, (
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
)
def test_mikhaylovsk_has_only_cian_row() -> None:
"""Regression-guard для самого узкого случая: mikhaylovsk — единственный город без
yandex_rgid (город отсутствует в гео-базе Яндекса) и без avito_slug должен иметь
РОВНО одну строку (cian), НЕ три."""
rows = _row_sources(_MIGRATION_262)
mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"}
assert mikhaylovsk_providers == {
"cian"
}, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}"
# ── CITY_ANCHORS parity ─────────────────────────────────────────────────────
def test_migration_262_slugs_all_exist_in_city_anchors() -> None:
"""Каждый slug из 262_ ОБЯЗАН быть в CITY_ANCHORS — иначе get_city_anchors(slug)
вернёт None и sweep молча резолвится на EKB_ANCHORS (собирает ЕКБ вместо города)."""
rows = _row_sources(_MIGRATION_262)
slugs = {slug for _provider, slug in rows}
missing = slugs - set(CITY_ANCHORS)
assert not missing, f"slugs из 262_ отсутствуют в CITY_ANCHORS: {sorted(missing)}"
def test_migration_179_slugs_all_exist_in_city_anchors() -> None:
"""Тот же guard для wave-1 (179_) — sanity, что baseline не сломан этим PR."""
rows = _row_sources(_MIGRATION_179)
slugs = {slug for _provider, slug in rows}
missing = slugs - set(CITY_ANCHORS)
assert not missing, f"slugs из 179_ отсутствуют в CITY_ANCHORS: {sorted(missing)}"
def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
"""Обратное направление: каждый ключ CITY_ANCHORS обязан иметь schedule-строки в
179_ ИЛИ 262_ мёртвая запись без сидов сигнализирует рассинхрон/опечатку."""
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
slug for _p, slug in _row_sources(_MIGRATION_262)
}
orphaned = set(CITY_ANCHORS) - seeded_slugs
assert (
not orphaned
), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
def test_city_anchors_wave2_count_and_content() -> None:
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS
assert len(CITY_ANCHORS) == 45
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:
"""Каждая wave-2 запись — ровно 1 anchor (город компактнее ЕКБ, см. коммент в
pipeline.py) с непустой человекочитаемой подписью, заканчивающейся на 'центр'."""
for slug in _WAVE2_SLUGS:
anchors = CITY_ANCHORS[slug]
assert len(anchors) == 1, f"{slug}: ожидался 1 anchor, получено {len(anchors)}"
lat, lon, label = anchors[0]
assert 55.0 < lat < 62.0, f"{slug}: lat={lat} вне разумного диапазона Свердловской обл."
assert 56.0 < lon < 67.0, f"{slug}: lon={lon} вне разумного диапазона Свердловской обл."
assert label.strip().lower().endswith("центр"), f"{slug}: подпись '{label}' без 'центр'"
def test_city_locations_wave2_count_and_content() -> None:
"""CITY_LOCATIONS покрывает ровно те же 45 slug'ов, что CITY_ANCHORS (иначе
get_city_location(slug) вернёт None для известного anchor'ами города и сборка
молча уйдёт на ЕКБ region_id/rgid)."""
assert set(CITY_LOCATIONS) == set(CITY_ANCHORS)
def test_city_locations_cian_region_id_never_none() -> None:
"""cian_region_id подтверждён у ВСЕХ 45 городов (wave 1 + wave 2) — единственное
поле CityLocation, у которого нет Optional-пути."""
for slug, loc in CITY_LOCATIONS.items():
assert loc.cian_region_id is not None, f"{slug}: cian_region_id не должен быть None"
# ── window scheduling ───────────────────────────────────────────────────────
_EKB_OCCUPIED = {"avito": {6}, "cian": {2, 3, 4}, "yandex": {16}}
_WAVE1_OCCUPIED = {
"avito": {0, 1, 5, 7, 8},
"cian": {9, 10, 11, 12, 13},
"yandex": {14, 15, 17, 18, 19},
}
# Математический минимум коллизий одного источника в одном часе (round-robin по
# 23/40/39 городам на 18/16/18 свободных часов соответственно).
_MAX_COLLISIONS = {"avito": 2, "cian": 3, "yandex": 3}
def _row_windows(path: Path) -> list[tuple[str, str, int, int]]:
"""(provider, slug, window_start_hour, window_end_hour) для каждой VALUES-строки."""
executable = _executable_sql(path)
pattern = re.compile(
r"'(avito|cian|yandex)_city_sweep_([a-z_]+)',\s*\n\s*(?:true|false),\s*\n\s*(\d+),\s*\n\s*(\d+),",
)
return [(p, s, int(a), int(b)) for p, s, a, b in pattern.findall(executable)]
def test_migration_262_windows_are_one_hour() -> None:
"""Окно ровно на час. Час 23 заворачивается в 0 (cross-midnight) — scheduler
это поддерживает явно (`window_end_hour <= window_start_hour` окно через
полночь, см. scraper_kit.orchestration.scheduler)."""
for provider, slug, start, end in _row_windows(_MIGRATION_262):
expected = (start + 1) % 24
assert (
end == expected
), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})"
def test_migration_262_window_hours_satisfy_db_check_constraint() -> None:
"""Оба часа обязаны попадать в 0..23 — иначе миграция не применится вовсе.
Регресс: первая версия давала start=23, end=24 у пяти строк. Проверка
«end == start + 1» это пропускала, а прод-констрейнт `window_end_range`
(052_scrape_schedules.sql: CHECK (window_end_hour BETWEEN 0 AND 23))
нет, и весь INSERT падал в CI на реальном postgres.
"""
for provider, slug, start, end in _row_windows(_MIGRATION_262):
assert 0 <= start <= 23, f"{provider}_{slug}: window_start_hour={start} вне 0..23"
assert 0 <= end <= 23, f"{provider}_{slug}: window_end_hour={end} вне 0..23"
def test_migration_262_windows_avoid_ekb_and_wave1_occupied_hours() -> None:
"""Ни одна wave-2 строка не наезжает на занятые ЕКБ-окна (avito 6-7, cian 2-5,
yandex 16-17) или на окна wave-1 (179_) тот же provider."""
for provider, slug, start, _end in _row_windows(_MIGRATION_262):
occupied = _EKB_OCCUPIED[provider] | _WAVE1_OCCUPIED[provider]
assert start not in occupied, (
f"{provider}_city_sweep_{slug}: окно start={start} пересекает занятый "
f"ЕКБ/wave-1 час ({sorted(occupied)})"
)
def test_migration_262_same_provider_collisions_within_math_minimum() -> None:
"""Коллизии ОДНОГО источника в одном окне не превышают математический минимум
(round-robin по подтверждённым городам на свободные часы), и распределены
равномерно (не сконцентрированы в 1-2 часах)."""
from collections import Counter
rows = _row_windows(_MIGRATION_262)
for provider in _PROVIDERS:
hours = [start for p, _slug, start, _end in rows if p == provider]
counts = Counter(hours)
assert max(counts.values()) <= _MAX_COLLISIONS[provider], (
f"{provider}: час {counts.most_common(1)} превышает математический минимум "
f"коллизий ({_MAX_COLLISIONS[provider]})"
)
def test_migration_262_cross_provider_overlap_allowed() -> None:
"""Разные провайдеры МОГУТ делить окно в рамках этой миграции (не ограничивается) —
документируем это явно, чтобы будущий рефактор не поломал предположение по ошибке."""
rows = _row_windows(_MIGRATION_262)
avito_hours = {start for p, _s, start, _e in rows if p == "avito"}
cian_hours = {start for p, _s, start, _e in rows if p == "cian"}
assert avito_hours and cian_hours

View file

@ -1,13 +1,20 @@
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846).
Покрывает:
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
- fresh: age <= max_age_days (алерта нет);
- stale: age > max_age_days (алерт);
- граница порога (== max_age_days нет алерта; +1 день алерт).
2. check_sber_freshness с FakeDB (fresh / stale / emptymark_failed / кастомный lag).
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте /
загрузка встала / полных прогонов не было вовсе / граница порога.
2. check_sber_freshness с FakeDB прод-реплей 2026-08-12 и двусторонность:
при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой.
3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы
отставшее табло).
4. Свойства миграций 180/212 + регистрацию в kit product_handlers.
Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index):
latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток),
dinamika-tsen-obyavlenii = 2026-05-01 (103);
последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639);
такт загрузки interval_days = 7;
прогон id=37 (05-31) status='done' при {errors: 9, upserted: 0}, за успех НЕ считается.
"""
from __future__ import annotations
@ -22,7 +29,6 @@ import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import settings
from app.services.product_handlers import build_product_handlers
from app.tasks import sber_freshness_monitor as mon
@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql"
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
_MAY_2026 = date(2026, 5, 1)
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
# Прод-состояние 2026-08-12.
_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток
_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103
_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639
_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days
_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера
def _now(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, tzinfo=UTC)
def test_fresh_within_max_age() -> None:
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
assert v.stale is False
assert v.age_days == 30
assert v.latest_period == _MAY_2026
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
def test_stale_beyond_max_age() -> None:
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
assert v.stale is True
def test_loader_in_cadence_no_alert_even_at_age_72() -> None:
"""Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим.
После полного прогона наш max(period_month) равен максимуму источника ПО
ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток лаг ПУБЛИКАЦИИ Сбера,
а не наше отставание.
"""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=_LAST_FULL_PULL,
pull_interval_days=_PULL_INTERVAL,
)
assert v.age_days == 72
assert v.pull_lag_days == 6
assert v.stale is False
def test_loader_stalled_alerts_at_the_same_age() -> None:
"""Тот же возраст периода, но полный прогон 20 суток назад → тревога.
20 суток реальный разрыв прод-истории (07-17 08-06) при пороге 2×7=14.
"""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=_now(2026, 7, 23),
pull_interval_days=_PULL_INTERVAL,
)
assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше
assert v.pull_lag_days == 20
assert v.max_pull_lag_days == 14
assert v.stale is True
def test_no_complete_pull_ever_alerts() -> None:
"""Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=None,
pull_interval_days=_PULL_INTERVAL,
)
assert v.stale is True
assert v.pull_lag_days == -1
def test_threshold_boundary_exact_no_alert() -> None:
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
assert v.age_days == 60
"""Ровно на пороге (2 такта) алерта ещё нет — строгое >."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL),
last_complete_pull_at=_LAST_FULL_PULL,
pull_interval_days=_PULL_INTERVAL,
)
assert v.pull_lag_days == 14
assert v.stale is False
def test_threshold_boundary_next_day_alert() -> None:
"""Порог + 1 день (age=61) — первый алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
assert v.age_days == 61
def test_threshold_boundary_next_day_alerts() -> None:
"""Порог + 1 сутки — первый алерт (два такта подряд пропущены)."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1),
last_complete_pull_at=_LAST_FULL_PULL,
pull_interval_days=_PULL_INTERVAL,
)
assert v.pull_lag_days == 15
assert v.stale is True
def test_threshold_follows_pull_cadence() -> None:
"""Порог — производная такта загрузки, а не константа: такт 28 → порог 56."""
v = mon.evaluate_sber_freshness(
_JUN_2026,
_PROD_NOW,
last_complete_pull_at=_now(2026, 7, 23), # 20 суток
pull_interval_days=28,
)
assert v.max_pull_lag_days == 56
assert v.stale is False # при месячном такте 20 суток — норма
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
class _Row:
def __init__(self, latest: date | None) -> None:
self.latest = latest
def __init__(self, **kw: Any) -> None:
self.__dict__.update(kw)
class _FakeResult:
def __init__(self, latest: date | None) -> None:
self._latest = latest
def __init__(self, row: _Row | None) -> None:
self._row = row
def first(self) -> _Row:
return _Row(self._latest)
def first(self) -> _Row | None:
return self._row
class _FakeDB:
def __init__(self, latest: date | None) -> None:
self._latest = latest
"""Отвечает на три запроса монитора; пригоден и для старой версии кода.
Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра на такой
запрос отдаём максимум по всем табло, ровно как это делал бы Postgres.
"""
def __init__(
self,
latest_by_dash: dict[str, date],
last_pull: datetime | None = _LAST_FULL_PULL,
interval_days: str | None = str(_PULL_INTERVAL),
) -> None:
self._latest_by_dash = latest_by_dash
self._last_pull = last_pull
self._interval_days = interval_days
self.rolled_back = False
self.asked_dashboards: list[str] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
return _FakeResult(self._latest)
sql = str(stmt)
params = params or {}
if "scrape_runs" in sql:
return _FakeResult(_Row(last_pull=self._last_pull))
if "scrape_schedules" in sql:
return _FakeResult(_Row(interval_days=self._interval_days))
if "dash" in params:
self.asked_dashboards.append(params["dash"])
return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"])))
# Старый монитор: max(period_month) по всей таблице.
latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None
return _FakeResult(_Row(latest=latest))
def rollback(self) -> None:
self.rolled_back = True
@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
return calls
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
assert calls["done"] == out
assert calls["failed"] is None
def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB:
"""Прод-состояние 2026-08-12 (оба табло вторички)."""
return _FakeDB(
{"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026},
last_pull=last_pull,
)
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
def test_prod_replay_healthy_loader_silent_source_no_alert(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина.
На main монитор мерил календарь (72 > 60) и писал ERROR двенадцатые сутки
подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации
Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же.
"""
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-07-12 (прод): age=72 > 60 → алерт.
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 1
db = _prod_db()
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["age_days"] == 72
assert out["latest_month"] == 5
# Монитор НЕ падает при алерте — прогон done, а не failed.
assert out["alert"] == 0
assert calls["done"] == out
assert calls["failed"] is None
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None:
"""Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой.
На main оба состояния дают alert=1 (вердикт зависит только от календаря)
сторож не умеет зеленеть, что и было исходным дефектом.
"""
_patch_runs(monkeypatch)
healthy = mon.check_sber_freshness(
_prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type]
run_id=2,
params={},
now=_PROD_NOW,
)
stalled = mon.check_sber_freshness(
_prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type]
run_id=3,
params={},
now=_PROD_NOW,
)
assert healthy["age_days"] == stalled["age_days"] == 72
assert (healthy["alert"], stalled["alert"]) == (0, 1)
def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None:
"""Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит.
Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт
SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь
ровно то состояние, что дал бы прод с одним лишь id=37.
"""
_patch_runs(monkeypatch)
out = mon.check_sber_freshness(
_prod_db(last_pull=None), # type: ignore[arg-type]
run_id=4,
params={},
now=_PROD_NOW,
)
assert out["alert"] == 1
assert out["pull_lag_days"] == -1
def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None:
"""Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице)."""
_patch_runs(monkeypatch)
db = _prod_db()
out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert db.asked_dashboards[0] == "real_estate_deals"
assert (out["latest_year"], out["latest_month"]) == (2026, 6)
def test_falls_back_to_next_dashboard_when_first_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Первое табло пусто → берём следующее, как и оценщик."""
_patch_runs(monkeypatch)
db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026})
out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["latest_month"] == 5
assert out["age_days"] == 103
def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одного табло с данными — оценивать нечего, это сбой монитора."""
calls = _patch_runs(monkeypatch)
db = _FakeDB(None)
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["alert"] == 0
assert calls["done"] is None
assert calls["failed"] is not None
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
"""Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь.
Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180).
С любым её значением вердикт один и тот же порог берётся из такта загрузки.
"""
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
out = mon.check_sber_freshness(
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
) # type: ignore[arg-type]
assert out["alert"] == 1
base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type]
tweaked = mon.check_sber_freshness(
_prod_db(), # type: ignore[arg-type]
run_id=9,
params={"lag_allowance_days": 0},
now=_PROD_NOW,
)
assert base["alert"] == tweaked["alert"] == 0
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
"""Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет."""
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
assert out_exact["age_days"] == threshold
assert out_exact["alert"] == 0
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
assert out_over["alert"] == 1
db = _FakeDB(
{"real_estate_deals": _JUN_2026},
last_pull=_now(2026, 7, 23), # 20 суток
interval_days="28",
)
out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["max_pull_lag_days"] == 56
assert out["alert"] == 0
def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем."""
_patch_runs(monkeypatch)
db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None)
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type]
assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS
# ── Миграция 180 ──────────────────────────────────────────────────────────────
@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None:
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
def test_migration_180_lag_allowance_25() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "lag_allowance_days" in sql
assert "25" in sql
def test_migration_180_no_psycopg_trap() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert not re.search(r":\w+::", sql)
# ── Миграция 212: такт загрузки не должен пересекать порог монитора ───────────
# ── Миграция 212: такт загрузки = источник порога ─────────────────────────────
#
# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74
# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника.
# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора.
# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят
# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при
# полном прогоне шестидневной давности, потолок 53 держался бы только если бы
# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода
# не должен расходиться с тактом, который сеет миграция.
def test_migration_212_makes_pull_cadence_weekly() -> None:
@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None:
assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type)
def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None:
"""Инвариант: пол возраста + такт загрузки < порога монитора.
Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог =
sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 запас
7 суток. При прежних 28: 46+28=74 > 60 тревога каждый цикл, что и наблюдали.
"""
def test_default_pull_interval_matches_migration_212() -> None:
"""Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой."""
interval_days = int(
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1)
)
observed_floor_days = 46
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
assert observed_floor_days + interval_days < threshold, (
f"такт {interval_days}д даёт потолок возраста "
f"{observed_floor_days + interval_days}д при пороге {threshold}д — "
"монитор снова будет мерить наш такт, а не застой источника"
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr]
)
assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days
# ── Один порог, а не два ──────────────────────────────────────────────────────
def test_no_second_calendar_threshold_in_settings() -> None:
"""#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться."""
from app.core.config import settings
assert not hasattr(settings, "sber_index_max_age_days")
# ── Регистрация в kit registry ─────────────────────────────────────────────────

View file

@ -15,6 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
@ -259,3 +260,216 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None:
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
# ── RetryError fingerprint stabilization (glitchtip-noise, #<GlitchTip triage>) ─
#
# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address
# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в
# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в
# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина
# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере).
# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and-
# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit —
# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся
# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError)
# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные
# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории
# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ.
from tenacity import RetryError # noqa: E402
def _hint_for(exc: BaseException) -> dict:
"""Строит hint в форме, которую sentry_sdk реально передаёт в before_send —
`exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint
полагается именно на эту форму, не на уже сериализованный event dict)."""
return {"exc_info": (type(exc), exc, exc.__traceback__)}
def _raise_retry_error_from(cause: BaseException) -> RetryError:
try:
raise cause
except type(cause) as caught:
try:
raise RetryError(None) from caught
except RetryError as retry_exc:
return retry_exc
def test_stabilize_retry_error_sets_stable_fingerprint() -> None:
"""RetryError коллапсится в persistent issue по (culprit, имени типа причины) —
НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без
`event["logger"]` (напр. capture_exception без LoggingIntegration) culprit
падает на явный "unknown", а не пропадает из fingerprint молча."""
exc = _raise_retry_error_from(TimeoutError("Nominatim timed out"))
out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None:
"""Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только
culprit (logger-имя модуля) + фиксированное имя типа исключения-причины
(маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...)."""
exc = _raise_retry_error_from(
ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345")
)
out = stabilize_retry_error_fingerprint({}, _hint_for(exc))
assert out is not None
fingerprint_text = " ".join(out["fingerprint"])
assert "95.165.147.218" not in fingerprint_text
assert "12345" not in fingerprint_text
assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"]
def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None:
"""`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля,
вызвавшего logger.exception/.error) идёт в fingerprint как culprit стабильно
per-модуль, не переменные данные запроса."""
exc = _raise_retry_error_from(TimeoutError("timed out"))
out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None:
"""Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из
НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один
issue разные проблемы не сливаются, даже если типы причины совпали."""
exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out"))
exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out"))
out_geocoder = stabilize_retry_error_fingerprint(
{"logger": "app.services.geocoder"}, _hint_for(exc_geocoder)
)
out_scraper = stabilize_retry_error_fingerprint(
{"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper)
)
assert out_geocoder is not None
assert out_scraper is not None
assert out_geocoder["fingerprint"] != out_scraper["fingerprint"]
class _DecoyRetryError(Exception):
"""Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError —
но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2
claim #4) ложно матчило бы такое; isinstance — нет."""
_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc]
def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None:
"""type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только
реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук."""
exc = _DecoyRetryError("unrelated exception, same class __name__ by accident")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_operational_error_untouched() -> None:
"""401-аналог задачи: OperationalError — содержательная категория (реальный
сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с
шумом)."""
from sqlalchemy.exc import OperationalError
exc = OperationalError("SELECT 1", {}, Exception("connection refused"))
event = {"level": "error", "message": "db connection failed"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None:
"""500-аналог задачи: обычное необработанное исключение (не RetryError)
проходит без изменений."""
exc = RuntimeError("scraper city-sweep failed")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_no_exc_info_untouched() -> None:
"""capture_message-based события (нет exc_info) — фильтр не трогает, напр.
scrape_runs.py consecutive-failure алерты (content-ful, должны доходить)."""
event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"}
out = stabilize_retry_error_fingerprint(dict(event), {})
assert out == event
def test_stabilize_retry_error_handles_non_dict_event() -> None:
assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type]
# ── 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.
# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()):
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py
# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip
# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же
# per-address issue-explosion, который reraise=True должен был устранить, просто
# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по
# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send).
_NOMINATIM_403_TEMPLATE = (
"Client error '403 Forbidden' for url "
"'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n"
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403"
)
def _httpx_error_event(message: str) -> dict:
return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}}
def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None:
"""Query string режется целиком из httpx-style 'for url' сообщения — host+path
остаются стабильными для группировки."""
encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3"
event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb))
out = scrub_pii_event(event, {})
assert out is not None
value = out["exception"]["values"][0]["value"]
assert "search?[REDACTED]'" in value
assert "%D0%95" not in value
assert "nominatim.openstreetmap.org/search" in value # host+path сохранены
def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None:
"""Два РАЗНЫХ адреса (переменная часть query) после редактора дают
ИДЕНТИЧНЫЙ текст сообщения GlitchTip group-title больше не плодит issue
на каждый адрес (review round 2 claim #1)."""
event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1"))
event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5"))
out_a = scrub_pii_event(event_a, {})
out_b = scrub_pii_event(event_b, {})
assert out_a is not None
assert out_b is not None
assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"]
def test_scrub_pii_event_httpx_url_without_query_untouched() -> None:
"""URL без query string (напр. scraper detail page — переменная часть в
ПУТИ, не в query) остаётся нетронутым regex матчит только `?...`."""
message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'"
event = _httpx_error_event(message)
out = scrub_pii_event(event, {})
assert out is not None
assert out["exception"]["values"][0]["value"] == message
def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None:
"""Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и
кавычками не должен ложно матчиться."""
benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес"
event = {"extra": {"note": benign}}
out = scrub_pii_event(event, {})
assert out is not None
assert out["extra"]["note"] == benign

View file

@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]:
"""Строки батча house_placement_history из мока сессии.
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
сломалась, как только у функции появился второй execute перед вставкой истории.
Фильтруем по SQL: тест переживёт любой новый вызов.
"""
for call in db.execute.call_args_list:
if "INSERT INTO house_placement_history" in str(call.args[0]):
@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None:
assert saved == 0
# db.execute не должен вызываться для пустого rows (нет INSERT)
assert _history_rows(db) == []
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
# ли история по площади.
assert db.commit.call_count == 2
# Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит
# (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259.
assert db.commit.call_count == 1
db.rollback.assert_not_called()

View file

@ -1,7 +1,7 @@
"""Unit tests for YandexValuationScraper — anonymous house-history scraper.
Fixture HTML simulates the Yandex valuation page body text containing:
- House meta block (year, floors, type, ceiling, lift, total objects, panorama)
- House meta block (year, floors, type, ceiling, lift, total objects)
- 2-3 historical offer entries with full structure
Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг
@ -78,7 +78,6 @@ def test_parse_house_meta_full():
assert meta.ceiling_height == 2.50
assert meta.has_lift is True
assert meta.total_objects == 12
assert meta.has_panorama is False
def test_parse_house_meta_no_lift():
@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift():
assert meta.ceiling_height == 3.0
def test_parse_house_meta_with_panorama():
text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание"
def test_parse_house_meta_brick_with_lift():
"""#2674 (хвост): фикстура больше не содержит «Панорама».
Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором;
зелёный он был по построению. На настоящей странице оценки этого слова нет
проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов.
Остаток теста осмысленный: кирпич + лифт на короткой мета-строке.
"""
text = "7 объектов Дом 2010 года Лифт Кирпичное здание"
meta = YandexValuationScraper._parse_house_meta(text)
assert meta.has_panorama is True
assert meta.has_lift is True
assert meta.house_type == "brick"
@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items():
Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item.
"""
text = (
"Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт "
"Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт "
"1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже "
"2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже"
)

View file

@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]:
"""Строки батча house_placement_history из мока сессии.
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
сломалась, как только у функции появился второй execute перед вставкой истории.
Фильтруем по SQL: тест переживёт любой новый вызов.
"""
for call in db.execute.call_args_list:
if "INSERT INTO house_placement_history" in str(call.args[0]):

View file

@ -1,15 +1,17 @@
/**
* SiteFooter подвал. Серверный компонент.
*
* Что здесь честно ОТСУТСТВУЕТ:
* - Реквизиты юрлица/ИП. В репозитории их нет (поиск по коду, бэкенду и
* разметке не дал ни наименования, ни ИНН/ОГРН), а выдумывать реквизиты
* оператора персональных данных на публичной странице нельзя. Блок
* рендерится, как только `LEGAL_ENTITY` в content.ts перестанет быть null;
* заполнить обязательно до открытия домена наружу 152-ФЗ требует
* идентифицируемого оператора.
* - E-mail поддержки: реального адреса в коде тоже нет. Единственный
* проверяемый канал телеграм-бот из `v2/SupportChatContext.tsx`.
* 2026-08-13: блоки реквизитов и почты, которых здесь раньше не было, теперь
* рендерятся данные появились (`LEGAL_ENTITY`, `SUPPORT_EMAIL` в content.ts),
* и это не косметика: наименование, ИНН, ОГРН и работающий контакт на странице
* с офертой прямое требование модерации эквайера и условие идентифицируемости
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же
* набор рендерят оферта и политика ПДн, и разъехаться они не должны.
*
* Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не
* по коротким meraocenka.ru/oferta: короткие адреса существуют только на
* публичном хосте (rewrite в Caddy), а это же поддерево открывается и с
* gendsgn.ru/trade-in/mera-public там короткая ссылка ушла бы в 404.
*
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
@ -21,8 +23,11 @@ import { safeUrl } from "@/lib/safeUrl";
import {
LEGAL_ENTITY,
OFFER_PATH,
PRIVACY_PATH,
REFUND_PATH,
REGION_NAME,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
@ -49,6 +54,12 @@ export function SiteFooter() {
<div>
<p className={styles.footerTitle}>Связаться</p>
<p className={styles.footerText}>
Почта:{" "}
<a className={styles.link} href={`mailto:${SUPPORT_EMAIL}`}>
{SUPPORT_EMAIL}
</a>
</p>
{telegramHref ? (
<p className={styles.footerText}>
Поддержка в Telegram:{" "}
@ -61,14 +72,22 @@ export function SiteFooter() {
{SUPPORT_TELEGRAM_LABEL}
</a>
</p>
) : (
<p className={styles.footerText}>Контакты появятся к запуску.</p>
)}
) : null}
</div>
<div>
<p className={styles.footerTitle}>Документы</p>
<ul className={styles.footerLinks} role="list">
<li>
<Link className={styles.link} href={OFFER_PATH}>
Публичная оферта
</Link>
</li>
<li>
<Link className={styles.link} href={REFUND_PATH}>
Политика возврата
</Link>
</li>
<li>
<Link className={styles.link} href={PRIVACY_PATH}>
Обработка персональных данных
@ -82,8 +101,8 @@ export function SiteFooter() {
<span>© {year} МЕРА</span>
{LEGAL_ENTITY && (
<span>
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn},{" "}
{LEGAL_ENTITY.address}
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "}
{LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address}
</span>
)}
<span>

View file

@ -4,6 +4,14 @@
* Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" "/v2",
* то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение
* (и дальше на /login). Пока публичная страница одна, вордмарк просто текст.
*
* ФОРМУЛИРОВКА ДЕСКРИПТОРА (14.08.2026, юр-документ владельца, блок 3). Строка
* шапки обязана читаться как «МЕРА · Оценка вторичного жилья по рыночным данным ·
* <регион>»: слова «по рыночным данным» то, чем сервис отличается от оценки по
* 135-ФЗ, и юрист попросил, чтобы это стояло в самом заметном дескрипторе, а не
* только в дисклеймерах внизу. «МЕРА» приходит из вордмарка ниже (у него свой
* letter-spacing), поэтому в headerTag её нет строка складывается из двух узлов.
* Регион из REGION_NAME, не строкой (правило content.ts).
*/
import { REGION_NAME } from "../content";
@ -18,7 +26,7 @@ export function SiteHeader() {
МЕРА
</div>
<p className={styles.headerTag}>
Оценка вторичного жилья · {REGION_NAME}
Оценка вторичного жилья по рыночным данным · {REGION_NAME}
</p>
</div>
</header>

View file

@ -0,0 +1,154 @@
/**
* AccuracyV3 «Точность»: KPI-плитки + таблица сверок прогноза с фактом
* сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
*
* KPI-значения из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это
* плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения
* замером (см. шапку файла).
*
* Таблица сверок `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было
* заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому
* строки витринный пример формата отчёта, а не факт. Ровно поэтому подпись
* под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · » фраза,
* прямо утверждающая, что за строками стоят настоящие обезличенные сделки.
* Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись
* честно называет таблицу иллюстрацией формата, а вторую половину исходной
* фразы («"выше средней цены" разница со средней ценой сделки по похожим
* квартирам в районе») сохраняет она объясняет, ЧТО значит колонка, а не
* заявляет что-то о происхождении данных.
*
* Таблица размечена ARIA role="table/row/columnheader/cell" (а не <table>):
* на мобильном брейке (см. landing-v3.module.css) она превращается в карточки
* без горизонтального скролла, а implicit table-роли всё равно снимаются
* Safari при смене display у настоящей <table> тот же квирк, что уже описан
* в HowItWorks.tsx для списков, только для таблиц его обходят явные роли, а
* не CSS-костыль.
*/
import {
ACCURACY_PERIOD_LABEL,
ACCURACY_PLACEHOLDER,
PROOF_ROWS_PLACEHOLDER,
} from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
const KPI_TILES = [
{
value: ACCURACY_PLACEHOLDER.priceError,
label: "средняя ошибка по цене",
note: ACCURACY_PLACEHOLDER.priceErrorNote,
},
{
value: ACCURACY_PLACEHOLDER.daysError,
label: "точность по сроку продажи",
note: ACCURACY_PLACEHOLDER.daysErrorNote,
},
{
value: ACCURACY_PLACEHOLDER.coverage,
label: "сделок попали в диапазон",
note: ACCURACY_PLACEHOLDER.coverageNote,
},
] as const;
export function AccuracyV3() {
return (
<section
id="accuracy"
aria-labelledby="accuracy-v3-title"
className={styles.accSection}
>
<div className={styles.accContainer}>
<div className={styles.accHead}>
<div className={styles.accEyebrow} aria-hidden="true">
ПРОГНОЗ ПРОТИВ ФАКТА
</div>
<h2 id="accuracy-v3-title" className={styles.accTitle}>
Мы сверяем каждый прогноз с реальной сделкой
</h2>
<p className={styles.accLead}>
{`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`}
</p>
</div>
<ul className={styles.accKpiGrid} role="list">
{KPI_TILES.map((tile) => (
<li key={tile.label} className={styles.accKpiTile}>
<div className={styles.accKpiValue}>{tile.value}</div>
<div className={styles.accKpiLabel}>{tile.label}</div>
<div className={styles.accKpiNote}>{tile.note}</div>
</li>
))}
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
<div className={styles.accKpiValue}>
{ACCURACY_PLACEHOLDER.checks}
</div>
<div className={styles.accKpiLabel}>проверок за год</div>
<div className={styles.accKpiNoteDark}>
{ACCURACY_PLACEHOLDER.checksNote}
</div>
</li>
</ul>
<div
className={styles.accTable}
role="table"
aria-label="Сверка прогноза «Меры» с фактом сделки"
>
<div className={styles.accTableHead} role="row">
<span role="columnheader">Объект</span>
<span role="columnheader">Мера сказала</span>
<span role="columnheader">Факт сделки</span>
<span role="columnheader">Расхождение</span>
<span role="columnheader" className={styles.accTableHeadCellRight}>
Выше средней цены по району
</span>
</div>
{PROOF_ROWS_PLACEHOLDER.map((row) => (
<div key={row.addr} className={styles.accRow} role="row">
<div role="cell">
<span className={styles.accObjectAddr}>{row.addr}</span>
<span className={styles.accObjectMeta}>{row.meta}</span>
</div>
<div
role="cell"
className={styles.accCellSaid}
data-label="Мера сказала"
>
{row.said}
</div>
<div
role="cell"
className={styles.accCellFact}
data-label="Факт сделки"
>
{row.fact}
</div>
<div
role="cell"
className={styles.accCellErr}
data-label="Расхождение"
>
{row.err}
</div>
<div
role="cell"
className={styles.accCellGain}
data-label="Выше средней цены по району"
>
<div className={styles.accGainStack}>
<div className={styles.accGainValue}>{row.gain}</div>
<div className={styles.accGainPct}>{row.gainPct}</div>
</div>
</div>
</div>
))}
</div>
<p className={styles.accFootnote}>
Таблица иллюстрирует формат отчёта, это не список конкретных сделок.
«Выше средней цены» разница со средней ценой сделки по похожим
квартирам в районе.
</p>
</div>
</section>
);
}

View file

@ -0,0 +1,140 @@
"use client";
/**
* CityPicker выбор города в шапке лэндинга v3 (макет ~ строки 47-73).
*
* Презентационный клиентский остров: сегодня публичный расчёт выключен
* (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь
* город никуда не отправляется состояние живёт только в этом компоненте и
* показывает посетителю, что сервис знает больше одного города области.
* Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора
* наружу (проп/контекст) в этом заходе scope ограничен шапкой/подвалом/
* sticky-баром, самой формы здесь нет.
*
* Список городов и деление на «полное покрытие» / «данных меньше» берутся из
* `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно
* объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса»
* по городам (тысячи объявлений) мы их не повторяем: таких цифр в коде нет,
* а гадать нельзя (правило честности в content.ts).
*/
import { useEffect, useId, useRef, useState } from "react";
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
import { PRIMARY_CITY, REGION_NAME } from "../../content";
import styles from "../../landing-v3.module.css";
export function CityPicker() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [city, setCity] = useState<string>(DEFAULT_CITY.label);
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
// Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx.
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Escape — закрыть и вернуть фокус на кнопку.
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
const normalizedQuery = query.trim().toLowerCase();
const filtered = normalizedQuery
? OBLAST_CITIES.filter((c) =>
c.label.toLowerCase().includes(normalizedQuery),
)
: OBLAST_CITIES;
return (
<div className={styles.hdrCityWrap} ref={wrapRef}>
<button
ref={buttonRef}
type="button"
className={styles.hdrCityBtn}
onClick={() => setOpen((v) => !v)}
aria-haspopup="true"
aria-expanded={open}
aria-controls={panelId}
>
<span className={styles.hdrCityDot} aria-hidden="true" />
<span>{city}</span>
<span className={styles.hdrCityCaret} aria-hidden="true">
{open ? "▲" : "▼"}
</span>
</button>
{open ? (
<div
id={panelId}
className={styles.hdrCityPanel}
aria-label="Выбор города"
>
<div className={styles.hdrCitySearchRow}>
<input
type="text"
className={styles.hdrCityInput}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск города"
aria-label="Поиск города"
/>
</div>
<div className={styles.hdrCityList}>
{filtered.length > 0 ? (
filtered.map((c) => (
<button
key={c.id}
type="button"
className={styles.hdrCityItem}
data-active={c.label === city ? "true" : undefined}
onClick={() => {
setCity(c.label);
setOpen(false);
setQuery("");
buttonRef.current?.focus();
}}
>
<span className={styles.hdrCityItemName}>{c.label}</span>
<span className={styles.hdrCityItemNote}>
{c.label === PRIMARY_CITY
? "Полное покрытие"
: "Данных меньше"}
</span>
</button>
))
) : (
<p className={styles.hdrCityEmpty}>
Города нет в списке. Мы работаем там, где хватает данных по
сделкам напишите нам, и добавим.
</p>
)}
</div>
<div className={styles.hdrCityFooter}>
ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
{REGION_NAME.toUpperCase()}
</div>
</div>
) : null}
</div>
);
}

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