Merge remote-tracking branch 'forgejo/main' into local/pr2836
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m36s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m36s
This commit is contained in:
commit
c66047683c
186 changed files with 21592 additions and 1699 deletions
|
|
@ -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 → НЕ триггерит деплой
|
||||
|
|
|
|||
|
|
@ -41,9 +41,21 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
|
|||
|
||||
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
|
||||
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
|
||||
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
|
||||
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
|
||||
не доверяй `tail`.
|
||||
деструктивный DDL хитит прод на деплое.
|
||||
|
||||
**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит
|
||||
миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754):
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
||||
```
|
||||
|
||||
`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы.
|
||||
|
||||
Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная
|
||||
формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо:
|
||||
`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть.
|
||||
|
||||
## Rapid-merge trap
|
||||
|
||||
|
|
|
|||
|
|
@ -51,9 +51,25 @@ jobs:
|
|||
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
|
||||
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
|
||||
- 'auth/**'
|
||||
# Реестр городов — фронтовый файл, но его читает БЭКЕНДОВЫЙ тест
|
||||
# (tests/test_public_mera_api.py сверяет то, что мы предлагаем
|
||||
# выбрать, с тем, на что умеет отвечать проба покрытия). Без этой
|
||||
# строки правка одного лишь дропдауна не гоняла бы сверку — а
|
||||
# разошлись списки ровно так: город добавили на фронте, в пороги
|
||||
# покрытия не внесли, и житель Серова получал «вы вне области».
|
||||
- 'tradein-mvp/frontend/src/lib/city-registry.ts'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
# Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в
|
||||
# КОРНЕ репы, но его читает фронтовый тест
|
||||
# (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет,
|
||||
# что каждый маршрут публичного сайта действительно раздаётся на
|
||||
# meraocenka.ru. Без этой строки правка одного лишь Caddyfile не
|
||||
# запускала бы НИ ОДИН гейт, и удаление короткого адреса из
|
||||
# allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить»
|
||||
# стала бы ссылкой в 404.
|
||||
- 'Caddyfile'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
browser:
|
||||
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
|
||||
|
|
@ -85,6 +101,21 @@ jobs:
|
|||
CI_PG: ci-pg-tradein-${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# ПОЛНАЯ история, а не дефолтный depth=1 (#2683).
|
||||
# tests/test_migration_numbering.py сверяет номер новой миграции с
|
||||
# origin/main и с точкой ветвления. Ровно этот флаг их и даёт: при
|
||||
# depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*`
|
||||
# (видно в логе прогона), при depth=1 — только `+<sha>:refs/remotes/
|
||||
# pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет.
|
||||
# Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера
|
||||
# git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection
|
||||
# refused), сеть есть только у самого checkout.
|
||||
#
|
||||
# Гейт при отсутствии эталона краснеет, а не пропускается: молча
|
||||
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
|
||||
# Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Поднять Postgres и собрать схему tradein
|
||||
working-directory: .
|
||||
|
|
@ -116,7 +147,7 @@ jobs:
|
|||
# бы, а тесты всё равно скипались.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
|
||||
postgis/postgis:16-3.4
|
||||
|
|
@ -183,13 +214,22 @@ jobs:
|
|||
restore-keys: |
|
||||
uv-tradein-${{ runner.os }}-
|
||||
|
||||
- name: Sync deps (incl. dev group — pytest)
|
||||
- name: Sync deps (incl. dev group — pytest, ruff)
|
||||
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
|
||||
# вверх от cwd.
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint (ruff check)
|
||||
# Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint]
|
||||
# select = E F I B UP N RUF), но до этого шага их никто не гонял в CI —
|
||||
# "дерево чистое" было непроверенным утверждением, а не гарантией.
|
||||
# Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше),
|
||||
# т.е. ровно то, что видит `uv sync --frozen` в Dockerfile.
|
||||
# Blocking: любое нарушение → job RED (не декоративно).
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
|
||||
#
|
||||
|
|
@ -221,7 +261,7 @@ jobs:
|
|||
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
|
||||
if: always()
|
||||
working-directory: .
|
||||
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
|
||||
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
|
||||
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
|
||||
|
|
|
|||
|
|
@ -65,6 +65,45 @@ jobs:
|
|||
python3 scripts/check-workflow-ports.py --selftest
|
||||
python3 scripts/check-workflow-ports.py
|
||||
|
||||
- name: "Guard: Caddyfile синтаксически валиден"
|
||||
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
||||
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
||||
#
|
||||
# ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один
|
||||
# workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При
|
||||
# этом deploy.yml применяет его не через `reload` (тот отказался бы
|
||||
# принять битый конфиг и оставил бы старый работать), а через
|
||||
# `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер
|
||||
# в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru,
|
||||
# obsidian, status. То есть цена опечатки в этом файле — полный
|
||||
# даунтайм, а гейта на неё не было.
|
||||
#
|
||||
# `docker cp`, а НЕ `-v "$PWD:/etc/caddy"`. Job сам исполняется внутри
|
||||
# контейнера, и `docker run` создаёт КОНТЕЙНЕР-БРАТ на том же демоне:
|
||||
# путь в `-v` резолвится на ХОСТЕ, а `$PWD` — это путь внутри job-
|
||||
# контейнера, которого на хосте нет. Первая версия этого шага так и
|
||||
# упала: `open /etc/caddy/Caddyfile: no such file or directory`.
|
||||
# Копирование не зависит от того, как смонтирован workspace.
|
||||
#
|
||||
# Образ тот же `caddy:2`, что в docker-compose.prod.yml — проверяем ровно
|
||||
# тем парсером, который будет читать конфиг на проде.
|
||||
#
|
||||
# Копируем и `caddy/` — Caddyfile делает `import caddy/users.caddy.snippet`,
|
||||
# и без него validate упадёт на импорте (файл в репозитории есть).
|
||||
#
|
||||
# Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую
|
||||
# строку — это нормально, синтаксис от их значений не зависит.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cid=$(docker create -w /work caddy:2 \
|
||||
caddy validate --config /work/Caddyfile --adapter caddyfile)
|
||||
docker cp Caddyfile "$cid:/work/Caddyfile"
|
||||
docker cp caddy "$cid:/work/caddy"
|
||||
rc=0
|
||||
docker start -a "$cid" || rc=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
exit "$rc"
|
||||
|
||||
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
|
||||
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
|
||||
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
|
||||
|
|
@ -142,7 +181,7 @@ jobs:
|
|||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||
postgres:16
|
||||
|
|
@ -233,11 +272,18 @@ jobs:
|
|||
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
|
||||
# term-missing → видно непокрытые строки прямо в job-логе.
|
||||
run: |
|
||||
# #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит
|
||||
# в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как
|
||||
# «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется:
|
||||
# ниже `exit $rc`.
|
||||
rc=0
|
||||
uv run pytest -q -rs --ignore=tests/smoke \
|
||||
--cov=app \
|
||||
--cov-report=term-missing:skip-covered \
|
||||
--cov-report=xml:coverage.xml \
|
||||
--cov-fail-under=65
|
||||
--cov-fail-under=65 || rc=$?
|
||||
echo "### pytest вернул код $rc"
|
||||
exit $rc
|
||||
|
||||
- name: Coverage summary → job output
|
||||
# Дешёвый human-readable итог. Бежит даже если gate упал (if: always) —
|
||||
|
|
@ -246,20 +292,34 @@ jobs:
|
|||
# если переменная пустая/файла нет, печатаем в обычный лог (fallback).
|
||||
if: always()
|
||||
run: |
|
||||
echo "### шаг «Coverage summary» начался"
|
||||
[ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; }
|
||||
report="$(uv run coverage report --skip-covered --sort=cover | tail -40)"
|
||||
# NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с
|
||||
# кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` —
|
||||
# то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал
|
||||
# неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден.
|
||||
# `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после
|
||||
# упавшей команды просто не выполнится, и код возврата снова потеряется.
|
||||
cov_rc=0
|
||||
uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$?
|
||||
echo "### coverage report вернул код $cov_rc"
|
||||
report="$(tail -40 /tmp/cov_report.txt)"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{ echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$report"
|
||||
fi
|
||||
echo "### шаг «Coverage summary» закончился успешно"
|
||||
|
||||
- name: Снести тестовый Postgres
|
||||
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
|
||||
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
|
||||
if: always()
|
||||
working-directory: .
|
||||
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
run: |
|
||||
echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})"
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
echo "### шаг «Снести тестовый Postgres» закончился успешно"
|
||||
|
||||
frontend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -176,6 +176,11 @@ jobs:
|
|||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует
|
||||
# origin/main и общего предка с HEAD, а даёт их именно depth=0 — при
|
||||
# depth=1 checkout тянет один sha и ветки main в клоне нет.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
# Официальный standalone-инсталлер: системный `pip install uv` на
|
||||
|
|
@ -227,10 +232,49 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Build & push tradein-backend
|
||||
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
|
||||
# валит весь шаг ДО push нового образа — деплой тогда молча
|
||||
# пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай
|
||||
# без cache-from — ниже.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
|
||||
|
|
@ -251,6 +295,52 @@ jobs:
|
|||
${{ env.IMAGE_BACKEND }}:latest
|
||||
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push tradein-backend без кеша (битый buildcache, #2841)
|
||||
# cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2,
|
||||
# issue #2): успешный ретрай перезаписывает битый buildcache-тег своими
|
||||
# слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча
|
||||
# оставалась навсегда, следующий прогон снова падал на том же cache-from.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp
|
||||
file: ./tradein-mvp/backend/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_BACKEND }}:latest
|
||||
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
|
||||
# проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше).
|
||||
# Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при
|
||||
# упавшем build — этот шаг единственный это заметит: манифеста с этим SHA
|
||||
# не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy
|
||||
# ниже пропускается вместо накатки старого :latest на прод.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -267,8 +357,41 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
|
||||
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
|
||||
|
|
@ -279,6 +402,10 @@ jobs:
|
|||
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
|
||||
|
||||
- name: Build & push tradein-frontend
|
||||
# id + continue-on-error — см. tradein-backend (#2841): битый blob в
|
||||
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/frontend
|
||||
|
|
@ -303,6 +430,47 @@ jobs:
|
|||
${{ env.IMAGE_FRONTEND }}:latest
|
||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841)
|
||||
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
|
||||
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
|
||||
# слоями (mode=max), это и есть самолечение.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/frontend
|
||||
push: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_FRONTEND }}:latest
|
||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
build-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -321,10 +489,47 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Build & push tradein-browser
|
||||
# id + continue-on-error — см. tradein-backend выше (#2841): битый blob
|
||||
# в удалённом buildcache не должен ронять сборку и молча пропускать деплой.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/browser
|
||||
|
|
@ -335,6 +540,41 @@ jobs:
|
|||
${{ env.IMAGE_BROWSER }}:latest
|
||||
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push tradein-browser без кеша (битый buildcache, #2841)
|
||||
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
|
||||
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
|
||||
# слоями (mode=max), это и есть самолечение.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/browser
|
||||
push: true
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_BROWSER }}:latest
|
||||
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, test, build-backend, build-frontend, build-browser]
|
||||
|
|
@ -444,8 +684,9 @@ jobs:
|
|||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||
# #2216) glob'ом не подхватывается.
|
||||
# ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
|
||||
# до main, здесь и применяется, а имя закрепляется в _schema_migrations.
|
||||
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
|
||||
|
||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||
|
|
@ -869,3 +1110,33 @@ jobs:
|
|||
# The changes job reads this file on the next run to compute cumulative diff.
|
||||
echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha
|
||||
echo "→ Deployed SHA marker updated: $GITHUB_SHA"
|
||||
|
||||
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
|
||||
# молча (result=skipped), когда `test` или один из build-* падает (например,
|
||||
# битый blob в buildcache роняет `docker/build-push-action` — до ретрая
|
||||
# выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это
|
||||
# было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя
|
||||
# tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`,
|
||||
# кроме отмены прогона) и сама падает, если deploy не завершился success —
|
||||
# неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/
|
||||
# health-check/сверка образов #2679). Красная точка встаёт именно там, где
|
||||
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
|
||||
deploy-status:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, build-backend, build-frontend, build-browser, deploy]
|
||||
if: always() && !cancelled()
|
||||
steps:
|
||||
- name: Итог прогона — деплой обязан быть success, не skipped/failure
|
||||
run: |
|
||||
echo "test: ${{ needs.test.result }}"
|
||||
echo "build-backend: ${{ needs.build-backend.result }}"
|
||||
echo "build-frontend: ${{ needs.build-frontend.result }}"
|
||||
echo "build-browser: ${{ needs.build-browser.result }}"
|
||||
echo "deploy: ${{ needs.deploy.result }}"
|
||||
if [ "${{ needs.deploy.result }}" != "success" ]; then
|
||||
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
|
||||
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
|
||||
"Смотри логи test/build-backend/build-frontend/build-browser/deploy выше."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
|
|
@ -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 "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
91
.github/workflows/ci.yml
vendored
91
.github/workflows/ci.yml
vendored
|
|
@ -1,91 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/**'
|
||||
- 'fix/**'
|
||||
- 'refactor/**'
|
||||
- 'chore/**'
|
||||
- 'docs/**'
|
||||
- 'perf/**'
|
||||
- 'test/**'
|
||||
- 'hotfix/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_DB: gendesign
|
||||
POSTGRES_USER: gendesign
|
||||
POSTGRES_PASSWORD: gendesign
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U gendesign"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install system deps for geo + WeasyPrint
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
|
||||
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Type check (mypy strict on core)
|
||||
run: |
|
||||
uv run mypy \
|
||||
app/services/generative \
|
||||
app/services/site_finder/scorer.py
|
||||
|
||||
- name: Test (pytest)
|
||||
run: uv run pytest -q
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci || npm install
|
||||
- run: npm run lint
|
||||
- run: npm run type-check
|
||||
- run: npm run build
|
||||
160
Caddyfile
160
Caddyfile
|
|
@ -222,8 +222,7 @@ www.gendsgn.ru {
|
|||
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
|
||||
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
|
||||
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
|
||||
# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что
|
||||
# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО
|
||||
# автоматический HTTPS в Caddy изолирован per-hostname. Повторные неудачные попытки ДО
|
||||
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
|
||||
# validations/hostname/hour) — не критично, просто подождать; `docker volume
|
||||
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
|
||||
|
|
@ -251,22 +250,108 @@ meraocenka.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
|
||||
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
|
||||
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
|
||||
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
|
||||
# документ был бы недоступен с публичной страницы.
|
||||
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
|
||||
# юридических документов (оферта ссылается на meraocenka.ru/refund,
|
||||
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
|
||||
# поэтому обязаны резолвиться сами по себе.
|
||||
#
|
||||
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
|
||||
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
|
||||
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
|
||||
# в scripts/smoke-mera-perimeter.sh.
|
||||
handle /trade-in/mera-public/* {
|
||||
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
|
||||
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
|
||||
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
|
||||
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
|
||||
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
|
||||
# фронте — обычный <a>, потому что next/link подставляет basePath).
|
||||
#
|
||||
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
|
||||
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
|
||||
# который в ней указан. Каноничность для поисковиков задана отдельно, через
|
||||
# `alternates.canonical` на каждой странице.
|
||||
#
|
||||
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
|
||||
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
|
||||
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
|
||||
# (и проверка в scripts/smoke-mera-perimeter.sh).
|
||||
#
|
||||
# NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина
|
||||
# техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а
|
||||
# для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при
|
||||
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
||||
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
||||
# «/», и запрос закольцуется.
|
||||
# `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная
|
||||
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
|
||||
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
|
||||
# когда вариант выберут и он станет корнем.
|
||||
@meraPages path /estimate /oferta /refund /privacy /v3
|
||||
handle @meraPages {
|
||||
rewrite * /trade-in/mera-public{path}
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up -X-Authenticated-User
|
||||
}
|
||||
}
|
||||
|
||||
# Тот же адрес со слэшем на конце → 301 на канонический вид без слэша.
|
||||
# Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер
|
||||
# `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы
|
||||
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
||||
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
|
||||
# со слэшем и здесь нормализуются.
|
||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$
|
||||
handle @meraShortSlash {
|
||||
redir * /{re.shortslash.1} permanent
|
||||
}
|
||||
|
||||
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
|
||||
# страницы, а не два работающих.
|
||||
#
|
||||
# Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал
|
||||
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
|
||||
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
|
||||
# канонический адрес один.
|
||||
#
|
||||
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
|
||||
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста)
|
||||
# под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела
|
||||
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
|
||||
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
|
||||
#
|
||||
# `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он
|
||||
# начинается со слэша, Caddy разбирает как inline path-matcher — то есть
|
||||
# `redir / permanent` означает «для пути / редиректить на permanent», а не
|
||||
# «редиректить на /». Проверено на живом Caddy: без `*` длинные адреса
|
||||
# отдавали пустой 200 (матчер не совпадал, директива не срабатывала, тело
|
||||
# пустое) — хуже, чем 404, потому что выглядит как рабочая пустая страница.
|
||||
@meraLongRoot path /trade-in/mera-public /trade-in/mera-public/
|
||||
handle @meraLongRoot {
|
||||
redir * / permanent
|
||||
}
|
||||
|
||||
# Длинные адреса страниц → короткие. Пути перечислены ПОИМЁННО, обе формы
|
||||
# (со слэшем на конце и без) — не шаблоном и не регекспом.
|
||||
#
|
||||
# ПОЧЕМУ НЕ РЕГЕКСП С ЗАХВАТОМ ХВОСТА. Очевидный вариант
|
||||
# `path_regexp ^/trade-in/mera-public/(.+)$` + `redir /{re.…1}` — открытый
|
||||
# редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
|
||||
# `/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`,
|
||||
# а браузеры трактуют `/\` как `//` — Location уводит на ЧУЖОЙ хост. Это
|
||||
# готовая фишинговая заготовка с домена, который напечатан внутри оферты и
|
||||
# уходит модератору эквайера. Проверено на живом Caddy, воспроизводится.
|
||||
# С поимённым списком такой путь просто не матчится и падает в 404 ниже.
|
||||
#
|
||||
# ПОЧЕМУ `uri strip_prefix` + `{uri}`, А НЕ `redir /oferta` в каждой ветке.
|
||||
# `{uri}` переносит query-строку: уже размещённые ссылки с UTM-метками
|
||||
# после редиректа не теряют атрибуцию. Обёртка `route` обязательна —
|
||||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||||
# (бесконечный цикл; поймано на локальном стенде).
|
||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
|
||||
handle @meraLongPages {
|
||||
route {
|
||||
uri strip_prefix /trade-in/mera-public
|
||||
redir * {uri} permanent
|
||||
}
|
||||
}
|
||||
|
||||
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
|
||||
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
|
||||
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
|
||||
|
|
@ -292,8 +377,38 @@ meraocenka.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до
|
||||
# бэкенда. Под /api/public/ по определению не лежит ничего закрытого:
|
||||
# гарантию даёт структура пакета app/api/public/, а не аккуратность этого
|
||||
# матчера (разбор — в app/api/public/mera.py). Матчер тем не менее узкий:
|
||||
# /trade-in/api/v1/* по-прежнему падает в catch-all 404 ниже.
|
||||
#
|
||||
# ПОЧЕМУ ПУТЬ С ПРЕФИКСОМ /trade-in, А НЕ КОРОТКИЙ /api/public/*.
|
||||
# Тот же URL обязан работать и на gendsgn.ru/trade-in/mera-public — ту же
|
||||
# страницу оттуда открывают для QA (там она за basic_auth). На gendsgn.ru
|
||||
# корневой /api/* уже занят бэкендом Site Finder, то есть короткий путь
|
||||
# потребовал бы там ВТОРОГО handle, выигрывающего у существующего по
|
||||
# специфичности — то есть работоспособность публичной формы зависела бы от
|
||||
# порядка сортировки матчеров в чужом site-блоке. С префиксом /trade-in
|
||||
# запрос ловит уже существующий `handle /trade-in/api/*` (:123), и здесь
|
||||
# нужен ровно один новый handle. Цена — префикс /trade-in виден в devtools
|
||||
# публичного домена; он там и так виден на всех чанках Next (basePath).
|
||||
#
|
||||
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
|
||||
# часть маршрута FastAPI.
|
||||
#
|
||||
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
|
||||
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
|
||||
# анонимного домена — расширять доверие без нужды.
|
||||
handle /trade-in/api/public/* {
|
||||
uri strip_prefix /trade-in
|
||||
reverse_proxy tradein-backend:8000 {
|
||||
header_up -X-Authenticated-User
|
||||
}
|
||||
}
|
||||
|
||||
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
|
||||
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
|
||||
# /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется.
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
|
|
@ -345,25 +460,6 @@ errors.gendsgn.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Uptime Kuma — self-hosted uptime monitoring + public status page (#75 B6-1).
|
||||
# DNS: A-record status.gendsgn.ru → IP VPS (добавить перед деплоем стека).
|
||||
# Контейнер из docker-compose.uptime.yml (project gendesign-uptime) на shared
|
||||
# gendesign_shared network. Если стек не запущен — Caddy отдаёт 502 ТОЛЬКО на
|
||||
# этом домене, main-сайт не страдает (как obsidian.gendsgn.ru).
|
||||
#
|
||||
# ВНИМАНИЕ: status-page НАМЕРЕННО публичен (trust-building для пилотов, issue #75).
|
||||
# Admin-панель Kuma (/dashboard, /manage-*) защищена собственным логином Kuma —
|
||||
# НЕ кладём её за caddy/users.caddy.snippet, иначе double-auth сломает setup.
|
||||
status.gendsgn.ru {
|
||||
encode zstd gzip
|
||||
|
||||
reverse_proxy uptime-kuma:3001
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/status.gendsgn.ru.log
|
||||
}
|
||||
}
|
||||
|
||||
# Forgejo — self-hosted git (migration 2026-05-16).
|
||||
# DNS: A-record git.gendsgn.ru → IP VPS.
|
||||
# Forgejo container из forgejo-migration/docker-compose.yml на shared
|
||||
|
|
|
|||
|
|
@ -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
2
backend/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
.coverage
|
||||
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
|
||||
coverage.xml
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 (иначе сеть
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
|
||||
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
|
||||
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
|
||||
покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
|
||||
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
|
||||
покрытие domrf↔objective 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) — high≥3 / medium≥2 / 1 → low
|
||||
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример).
|
||||
• domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics
|
||||
docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён.
|
||||
• domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
|
||||
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
|
||||
domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
|
||||
медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже.
|
||||
• history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий
|
||||
ряд → связь rate↔sales / тренды не установлены.
|
||||
• 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-пример «покрытие domrf↔objective 2.5%»). None → low.
|
||||
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
|
||||
маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот
|
||||
`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% покрытия domrf↔objective», как было написано здесь
|
||||
раньше, — другая величина другого порядка.
|
||||
|
||||
Ключ фактора НЕ переименован намеренно: его читает фронт
|
||||
(`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: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа
|
||||
историческое — про маппинг domrf↔objective, продьюсера для которого
|
||||
нет и не было (#2464-H, см. _coverage_factor).
|
||||
history_months: глубина ряда (мес).
|
||||
confounded: True, если окно ряда пересекает шок-период (PR2).
|
||||
advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда).
|
||||
|
|
|
|||
|
|
@ -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 здесь НЕ выводим — нет
|
||||
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
|
||||
величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как
|
||||
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective —
|
||||
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
|
||||
|
||||
Порядок веток оставлен: если продьюсер появится, приоритет у него.
|
||||
"""
|
||||
if supply_layers is not None:
|
||||
coverage = supply_layers.get("domrf_coverage")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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), НЕ сырой.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "комфорт"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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% покрытия) —
|
||||
|
|
|
|||
|
|
@ -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 (слой жив, просто пуст)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
|
||||
|
||||
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
|
||||
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
|
||||
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
|
||||
корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые
|
||||
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
|
||||
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||
Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был
|
||||
(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога
|
||||
и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git:
|
||||
tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты,
|
||||
выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
|
||||
регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||
|
||||
Тест не требует БД — только чтение файлов.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
44
backend/tests/test_skip_guard_loud.py
Normal file
44
backend/tests/test_skip_guard_loud.py
Normal 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
|
||||
|
|
@ -78,6 +78,16 @@ services:
|
|||
image: postgis/postgis:16-3.4
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
# #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ,
|
||||
# и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10:
|
||||
# база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один
|
||||
# параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает
|
||||
# в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это
|
||||
# и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices).
|
||||
# 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12.
|
||||
# tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса).
|
||||
# Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер.
|
||||
shm_size: 1gb
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
|
|
@ -331,9 +341,19 @@ services:
|
|||
REDIS_URL: redis://redis:6379/2
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET}
|
||||
PORT: "8080"
|
||||
EMAIL_URL: consolemail://
|
||||
# Почта отключена по умолчанию: consolemail:// печатает письмо в stdout и
|
||||
# никуда его не отправляет. Реальный адрес приходит из /opt/gendesign/.env
|
||||
# (GLITCHTIP_EMAIL_URL) — в репозитории пароля почтового ящика быть не должно.
|
||||
#
|
||||
# ВАЖНО про схему DSN (django-environ, парсер GlitchTip): для порта 465 с
|
||||
# implicit SSL нужна схема smtp+ssl://, а НЕ smtps:// — вторая помечена
|
||||
# deprecated и включает STARTTLS (EMAIL_USE_TLS), то есть 465 с ней рвёт
|
||||
# соединение. Для 587/STARTTLS схема — smtp+tls://.
|
||||
# smtp+ssl://alerts%40meraocenka.ru:ПАРОЛЬ@smtp.beget.com:465
|
||||
# Логин — почтовый адрес целиком, @ в нём кодируется как %40.
|
||||
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
|
||||
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
|
||||
DEFAULT_FROM_EMAIL: errors@gendsgn.ru
|
||||
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
|
||||
ENABLE_USER_REGISTRATION: "true"
|
||||
ENABLE_ORGANIZATION_CREATION: "false"
|
||||
restart: always
|
||||
|
|
@ -365,9 +385,27 @@ services:
|
|||
REDIS_URL: redis://redis:6379/2
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET}
|
||||
CELERY_WORKER_AUTOSCALE: "1,3"
|
||||
# Письма и веб-хуки шлёт celery, то есть ИМЕННО этот контейнер, а не web.
|
||||
# До этой правки почтовых переменных здесь не было вовсе: настройка одного
|
||||
# glitchtip-web не дала бы ни одного отправленного письма — worker брал
|
||||
# умолчания образа. Значения обязаны совпадать с web (см. комментарий там).
|
||||
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
|
||||
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
|
||||
# Нужен для абсолютных ссылок внутри писем и веб-хуков: без него
|
||||
# уведомление приходит со ссылкой в никуда.
|
||||
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
|
||||
restart: always
|
||||
mem_limit: 384m
|
||||
networks: [default]
|
||||
# GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15,
|
||||
# см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО
|
||||
# этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web),
|
||||
# получателю `webhook` нужен доступ к http://tradein-backend:8000/... —
|
||||
# tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было
|
||||
# вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того,
|
||||
# как переменная окружения с URL вообще имеет смысл). default — обязательно
|
||||
# явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis
|
||||
# выше про неявную привязку к default).
|
||||
networks: [default, shared]
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
|
|
|
|||
180
ops/docker-prune.sh
Executable file
180
ops/docker-prune.sh
Executable file
|
|
@ -0,0 +1,180 @@
|
|||
#!/usr/bin/env bash
|
||||
# Периодическая уборка docker-мусора на прод-VM.
|
||||
#
|
||||
# ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201
|
||||
# том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых
|
||||
# прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не
|
||||
# было ни одного.
|
||||
#
|
||||
# Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml
|
||||
# снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил,
|
||||
# а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он
|
||||
# подбирает то, что runner не убрал за собой, и то, что накопилось раньше.
|
||||
#
|
||||
# ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно):
|
||||
# - остановленные контейнеры старше 24ч;
|
||||
# - висячие (dangling) образы старше 7 суток;
|
||||
# - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и
|
||||
# FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data,
|
||||
# tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются
|
||||
# НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune`
|
||||
# такой разницы не делает, поэтому здесь он намеренно не используется;
|
||||
# - зависшие (running, но фактически брошенные) job-контейнеры раннера Forgejo
|
||||
# Actions старше JOB_CONTAINER_MAX_AGE_HOURS. 2026-08-15: живьём на проде
|
||||
# обнаружены три штуки в статусе Up 4-8 недель (раннер не убрал контейнер
|
||||
# после прерванного/упавшего workflow — task killed, рестарт раннера в
|
||||
# процессе job'а и т.п.). CI job физически не идёт сутками, поэтому что
|
||||
# угодно с этим именем старше порога — гарантированный мусор, а не активная
|
||||
# задача. `docker container prune` их не видит: тот фильтрует только
|
||||
# status=exited, а эти контейнеры формально Up.
|
||||
#
|
||||
# Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает).
|
||||
# Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы):
|
||||
# 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1
|
||||
#
|
||||
# Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup
|
||||
# tradein, 05:00+ backfill'ы.
|
||||
#
|
||||
# Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI)
|
||||
# копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
STOPPED_AGE="${STOPPED_AGE:-24h}"
|
||||
IMAGE_AGE="${IMAGE_AGE:-168h}"
|
||||
# Job CI никогда не идёт сутками — что угодно с именем job-контейнера раннера
|
||||
# старше этого порога снимается безусловно (см. секцию 4 ниже).
|
||||
JOB_CONTAINER_MAX_AGE_HOURS="${JOB_CONTAINER_MAX_AGE_HOURS:-24}"
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
|
||||
|
||||
disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; }
|
||||
|
||||
before_pct="$(disk_used_pct)"
|
||||
log "старт: диск занят ${before_pct}%"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "DRY_RUN=1 — только показываю"
|
||||
fi
|
||||
|
||||
# ── 1. остановленные контейнеры ───────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
# `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»),
|
||||
# поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка.
|
||||
log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \
|
||||
$(docker container ls -aq --filter "status=exited" | wc -l)"
|
||||
else
|
||||
log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \
|
||||
2>&1 | tail -1)"
|
||||
fi
|
||||
|
||||
# ── 2. висячие образы ─────────────────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "висячих образов: $(docker image ls -qf dangling=true | wc -l)"
|
||||
else
|
||||
log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)"
|
||||
fi
|
||||
|
||||
# ── 3. тома-сироты известных форм ─────────────────────────────────────────────
|
||||
# Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой
|
||||
# отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся.
|
||||
mapfile -t candidates < <(
|
||||
docker volume ls -qf dangling=true \
|
||||
| grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true
|
||||
)
|
||||
|
||||
skipped="$(docker volume ls -qf dangling=true \
|
||||
| grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)"
|
||||
if [[ -n "$skipped" ]]; then
|
||||
log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
if [[ "${#candidates[@]}" -eq 0 ]]; then
|
||||
log "томов-сирот известных форм нет"
|
||||
elif [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "томов к удалению: ${#candidates[@]}"
|
||||
else
|
||||
removed=0
|
||||
for v in "${candidates[@]}"; do
|
||||
if docker volume rm "$v" >/dev/null 2>&1; then
|
||||
removed=$((removed + 1))
|
||||
fi
|
||||
done
|
||||
log "томов удалено: ${removed} из ${#candidates[@]}"
|
||||
fi
|
||||
|
||||
# ── 4. зависшие job-контейнеры раннера Forgejo Actions ───────────────────────
|
||||
# Фильтр по имени — ЯКОРЬ на начало (`^FORGEJO-ACTIONS-TASK-`), не "содержит
|
||||
# подстроку": `docker ps --filter name=` матчит как regex, поэтому `^...`
|
||||
# гарантирует точный префикс, а не случайное совпадение где-то в середине
|
||||
# имени сервисного контейнера. Долгоживущие сервисные контейнеры (forgejo,
|
||||
# forgejo-runner*, gendesign-*, tradein-*, couchdb) под этот префикс не
|
||||
# подпадают вообще — но ниже всё равно есть explicit-skip как страховка на
|
||||
# случай будущего переименования, а не молчаливая надежда на то, что фильтр
|
||||
# никогда не ошибётся.
|
||||
#
|
||||
# Возраст — из `docker inspect .State.StartedAt` (RFC3339), НЕ из текстового
|
||||
# "Up 4 weeks" в выводе `docker ps`: тот округляет к ближайшей крупной единице
|
||||
# и не пригоден для сравнения с порогом в часах.
|
||||
mapfile -t job_ids < <(docker ps -aq --filter "name=^FORGEJO-ACTIONS-TASK-" || true)
|
||||
|
||||
job_removed=0
|
||||
job_candidates=0
|
||||
if [[ "${#job_ids[@]}" -eq 0 ]]; then
|
||||
log "зависших job-контейнеров нет"
|
||||
else
|
||||
for id in "${job_ids[@]}"; do
|
||||
name="$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##' || true)"
|
||||
[[ -z "$name" ]] && continue
|
||||
|
||||
case "$name" in
|
||||
forgejo | forgejo-runner* | gendesign-* | tradein-* | couchdb)
|
||||
log "job-контейнеры: ПРОПУЩЕН сервисный '${name}' (не должен был пройти фильтр имени)"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
started_at="$(docker inspect --format '{{.State.StartedAt}}' "$id" 2>/dev/null || true)"
|
||||
[[ -z "$started_at" || "$started_at" == "0001-01-01T00:00:00Z" ]] && continue
|
||||
|
||||
started_epoch="$(date -u -d "$started_at" +%s 2>/dev/null || echo 0)"
|
||||
[[ "$started_epoch" -eq 0 ]] && continue
|
||||
|
||||
now_epoch="$(date -u +%s)"
|
||||
age_hours=$(((now_epoch - started_epoch) / 3600))
|
||||
[[ "$age_hours" -lt "$JOB_CONTAINER_MAX_AGE_HOURS" ]] && continue
|
||||
|
||||
job_candidates=$((job_candidates + 1))
|
||||
size="$(docker ps -a --filter "id=${id}" --size --format '{{.Size}}' 2>/dev/null \
|
||||
| awk '{print $1}' || true)"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "job-контейнеры: [dry-run] снял бы '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?})"
|
||||
continue
|
||||
fi
|
||||
|
||||
if docker rm -f "$id" >/dev/null 2>&1; then
|
||||
job_removed=$((job_removed + 1))
|
||||
log "job-контейнеры: снят '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?} освобождён)"
|
||||
else
|
||||
log "job-контейнеры: НЕ удалось снять '${name}' (id ${id:0:12})"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$job_candidates" -eq 0 ]]; then
|
||||
log "job-контейнеры: ${#job_ids[@]} шт., ни один не старше порога ${JOB_CONTAINER_MAX_AGE_HOURS}ч"
|
||||
elif [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "job-контейнеры: к снятию ${job_candidates} из ${#job_ids[@]}"
|
||||
else
|
||||
log "job-контейнеры: снято ${job_removed} из ${job_candidates} кандидатов (порог ${JOB_CONTAINER_MAX_AGE_HOURS}ч)"
|
||||
fi
|
||||
fi
|
||||
|
||||
after_pct="$(disk_used_pct)"
|
||||
log "готово: диск занят ${after_pct}% (было ${before_pct}%)"
|
||||
|
||||
# Сигнал в лог, если места всё равно мало — повод посмотреть глазами.
|
||||
if [[ "$after_pct" -ge 85 ]]; then
|
||||
log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно"
|
||||
fi
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal file
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal 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"})
|
||||
36
ops/journald-gendesign.conf.example
Normal file
36
ops/journald-gendesign.conf.example
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# systemd-journald drop-in — cap persistent journal disk usage on prod VPS.
|
||||
#
|
||||
# ЗАМЕР 2026-08-15 (ssh gendesign, read-only): `/var/log` занимал 3.1G. Наивная
|
||||
# первая проверка `journalctl --disk-usage` показала только 174M и навела на
|
||||
# ложный след «основной объём — не journald». На деле `journalctl --disk-usage`,
|
||||
# запущенный НЕ из группы systemd-journal/adm, недосчитывает — он не может
|
||||
# полноценно перечислить архивные *.journal файлы без прав на чтение. Прямой
|
||||
# `du -sh /var/log/journal` дал 2.5G — это ~80% всего `/var/log`, ровно 100
|
||||
# файлов по ~48M в /var/log/journal/<machine-id>/. Второй по размеру вклад —
|
||||
# традиционный rsyslog (syslog/syslog.1/auth.log/kern.log/ufw.log/dmesg/btmp,
|
||||
# ~0.6G) — те уже ротируются через logrotate (видны .1/.4.gz копии), отдельного
|
||||
# вмешательства не требуют и вне scope этого файла.
|
||||
#
|
||||
# В /etc/systemd/journald.conf на проде НЕТ SystemMaxUse (все ключи закомменчены
|
||||
# дефолтами) — без явного лимита journald довольствуется default-правилом
|
||||
# «до 10% файловой системы», на VPS с диском ~145G это фактически безлимит.
|
||||
#
|
||||
# УСТАНОВКА НА СЕРВЕРЕ (руками, deploy.yml этот файл НЕ подхватывает —
|
||||
# systemd-конфиги вне /opt/gendesign, деплой синкает только сам репозиторий):
|
||||
# sudo mkdir -p /etc/systemd/journald.conf.d
|
||||
# sudo cp /opt/gendesign/ops/journald-gendesign.conf.example \
|
||||
# /etc/systemd/journald.conf.d/gendesign-max-use.conf
|
||||
# sudo systemctl restart systemd-journald
|
||||
#
|
||||
# `restart systemd-journald` применяет лимит немедленно — journald сам
|
||||
# провакуумит существующие архивные файлы вниз до SystemMaxUse (ожидаемый
|
||||
# эффект: /var/log/journal схлопнется примерно с 2.5G до ~500M). Это НЕ
|
||||
# `docker volume rm` / `caddy reload` — под общий deploy-guard не подпадает,
|
||||
# но всё равно на живом проде: делает user сам после ревью PR.
|
||||
#
|
||||
# Значение 500M — консервативный запас на 4 vCPU/4-16G VPS с активным CI
|
||||
# (docker/forgejo-runner логи в journald тоже льются). При необходимости
|
||||
# больше retention для дебага — поднять SystemMaxUse, не удалять файл.
|
||||
|
||||
[Journal]
|
||||
SystemMaxUse=500M
|
||||
0
ops/restore.sh
Normal file → Executable file
0
ops/restore.sh
Normal file → Executable file
|
|
@ -3,8 +3,16 @@
|
|||
#
|
||||
# Проверяет инварианты периметра (см. корневой Caddyfile):
|
||||
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
|
||||
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
|
||||
# политика ПДн, на которую ссылается футер.
|
||||
# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий
|
||||
# (у страницы один канонический адрес, старые ссылки не ломаются).
|
||||
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
|
||||
# напечатаны внутри самих юридических документов и уходят эквайеру.
|
||||
# 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга.
|
||||
# 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ
|
||||
# /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале
|
||||
# вела в 404).
|
||||
# 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на
|
||||
# публичном домене по-прежнему 404.
|
||||
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
|
||||
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
|
||||
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
|
||||
|
|
@ -41,16 +49,52 @@ check() {
|
|||
fi
|
||||
}
|
||||
|
||||
check_post() {
|
||||
local desc="$1" url="$2" body="$3" expected="$4"
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
|
||||
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
|
||||
if [ "$code" = "$expected" ]; then
|
||||
echo "PASS: $desc ($url -> $code)"
|
||||
else
|
||||
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
|
||||
|
||||
# 1. Публичный домен отдаёт 200 анонимно.
|
||||
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
|
||||
|
||||
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
|
||||
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
|
||||
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
|
||||
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
|
||||
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
|
||||
# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а
|
||||
# не 200: с 15.08.2026 у публичной страницы один канонический адрес.
|
||||
# Проверка осталась именно здесь, потому что раньше она сторожила
|
||||
# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при
|
||||
# переходе на короткие адреса длинные не превратились в 404 (тогда бы
|
||||
# сломались уже разосланные ссылки).
|
||||
check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301
|
||||
|
||||
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
|
||||
# эти три URL напечатаны внутри самих документов и уходят в заявку
|
||||
# эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на
|
||||
# 404, и заявку завернут. Проверяем все три поимённо, потому что и в
|
||||
# Caddyfile они перечислены поимённо (allowlist, не шаблон).
|
||||
check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200
|
||||
check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200
|
||||
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
|
||||
|
||||
# 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки
|
||||
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
|
||||
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
|
||||
|
||||
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
|
||||
# канонический адрес, а старые ссылки и закладки продолжают работать.
|
||||
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
|
||||
# `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в
|
||||
# подвале v3 вела в 404.
|
||||
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
|
||||
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
|
||||
|
||||
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
||||
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
||||
|
|
@ -68,6 +112,26 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
|
|||
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
|
||||
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
|
||||
|
||||
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
|
||||
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
|
||||
# по-прежнему 404.
|
||||
#
|
||||
# Пара проверок ниже неразделима: первая доказывает, что форма вообще
|
||||
# работает, вторая — что новый handle не расширил периметр до
|
||||
# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это
|
||||
# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный
|
||||
# префикс, а не поимённый проброс v1-путей).
|
||||
check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
|
||||
check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200
|
||||
check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404
|
||||
check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404
|
||||
|
||||
# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда
|
||||
# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он
|
||||
# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит
|
||||
# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile.
|
||||
check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
|
||||
|
||||
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
|
||||
#
|
||||
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
|
||||
|
|
@ -90,6 +154,27 @@ check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/use
|
|||
check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301
|
||||
check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301
|
||||
|
||||
# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
|
||||
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
|
||||
# СТОРОН прямо сейчас:
|
||||
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
|
||||
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
|
||||
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
|
||||
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
|
||||
# платёжные пути и требует X-Authenticated-User → 401 анониму.
|
||||
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
|
||||
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
|
||||
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
|
||||
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
|
||||
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
|
||||
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
|
||||
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
|
||||
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
|
||||
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
|
||||
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
|
||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
|
||||
|
||||
echo "========================================"
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "ALL CHECKS PASSED"
|
||||
|
|
|
|||
7
tradein-mvp/backend/app/api/public/__init__.py
Normal file
7
tradein-mvp/backend/app/api/public/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru.
|
||||
|
||||
Отдельный пакет, а не ещё один модуль в `app/api/v1/`, — намеренно: всё, что
|
||||
лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по
|
||||
дереву каталогов, а не по списку исключений в middleware. Разбор — в
|
||||
`app/api/public/mera.py`.
|
||||
"""
|
||||
288
tradein-mvp/backend/app/api/public/mera.py
Normal file
288
tradein-mvp/backend/app/api/public/mera.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки.
|
||||
|
||||
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
|
||||
-------------------------------------------------------
|
||||
На `meraocenka.ru` действует allowlist-by-default: Caddy проксирует поимённо
|
||||
перечисленные пути, всё остальное — 404 (см. корневой Caddyfile, site-блок
|
||||
meraocenka.ru; регресс — scripts/smoke-mera-perimeter.sh). Чтобы открыть там
|
||||
API, нужно было выбрать одно из двух:
|
||||
|
||||
(а) пробросить `/trade-in/api/v1/trade-in/coverage` и `.../geocode/suggest`
|
||||
поимённо — периметр остаётся узким, но одна опечатка в matcher'е
|
||||
(`/trade-in/api/*` вместо точного пути) открывает наружу ВЕСЬ v1: ~20
|
||||
ручек, включая PDF расчётов, фотографии объектов, историю и админку;
|
||||
(б) завести отдельный префикс, под которым по определению не может лежать
|
||||
ничего закрытого, и пробрасывать его целиком.
|
||||
|
||||
Выбрано (б). Разница не в удобстве, а в цене ошибки: при (а) безопасность
|
||||
периметра держится на аккуратности матчера, при (б) — на структуре кода.
|
||||
Добавить сюда ручку с приватными данными нужно СПЕЦИАЛЬНО (положить файл в
|
||||
`app/api/public/`), случайно — нельзя.
|
||||
|
||||
Тот же принцип, что уже применён на фронте: публичный лэндинг вынесен в
|
||||
`app/mera-public/` с guard-скриптом на граф импортов, а не помечен флагом
|
||||
внутри общего дерева.
|
||||
|
||||
АНОНИМНОСТЬ
|
||||
-----------
|
||||
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
|
||||
non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками —
|
||||
не префиксом: множество там — frozenset с проверкой `path in ...`, и
|
||||
добавление префиксной ветки ради двух путей расширило бы механизм, которым
|
||||
пользуется весь бэкенд, ради одной фичи.
|
||||
|
||||
ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ
|
||||
-----------------------
|
||||
Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage` —
|
||||
чистое чтение (один SELECT), `/suggest` — прокси автокомплита. Это не
|
||||
случайность, а условие, при котором публичная форма может работать ДО того,
|
||||
как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица
|
||||
попадает в `trade_in_estimates` раньше любого согласия, а пути удаления
|
||||
данных в бэкенде нет). Платный расчёт, который писать будет, открывается
|
||||
отдельно и только вместе с этим контуром.
|
||||
|
||||
БЮДЖЕТЫ
|
||||
-------
|
||||
Общий `RateLimitMiddleware` (300/60с на IP) здесь недостаточен: `/suggest`
|
||||
через DaData-тир геокодера — платный внешний вызов, то есть абуз стоит денег,
|
||||
а не только CPU. Поэтому у каждой ручки свой, заведомо более узкий per-IP
|
||||
бюджет поверх общего — тот же приём, что у анонимного чата поддержки
|
||||
(app/api/v1/support.py, `_anon_ip_limiter`).
|
||||
|
||||
Лимитеры in-process: при нескольких репликах бэкенда бюджет умножится на их
|
||||
число. Сейчас реплика одна (docker-compose, tradein-backend), что и делает
|
||||
допущение верным; при масштабировании — выносить в Redis (issue заводить
|
||||
тогда же, не раньше: преждевременный вынос добавит зависимость без выигрыша).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.v1.geocode import SuggestResponse, suggest_addresses
|
||||
from app.api.v1.trade_in import coverage_probe
|
||||
from app.core.db import get_db
|
||||
from app.core.public_request import install_address_log_redaction, public_request_scope
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Публичная форма обещает, что введённый адрес нигде не сохраняется. По базам
|
||||
# это так, по журналам не было — геокодер печатал запрос открытым текстом, а
|
||||
# прод пишет stdout в persistent journald. Ставим редакцию логов в момент
|
||||
# импорта модуля (его импортирует app/main.py) — то есть ровно тогда, когда
|
||||
# публичные ручки вообще появляются в приложении. Разбор — в
|
||||
# app/core/public_request.py.
|
||||
install_address_log_redaction()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес
|
||||
# с debounce'ом — это единицы запросов на один адрес, поэтому 40/мин хватает
|
||||
# на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг
|
||||
# осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь,
|
||||
# нажал ещё раз».
|
||||
_SUGGEST_LIMIT = 20
|
||||
_COVERAGE_LIMIT = 15
|
||||
_WINDOW_S = 60.0
|
||||
|
||||
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
|
||||
_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S)
|
||||
|
||||
# ── Общий суточный потолок публичных подсказок ──────────────────────────────
|
||||
#
|
||||
# Per-IP окна одного клиента ограничивают, но не ограничивают СУММУ. Считаем:
|
||||
# 20 запросов/мин с одного адреса — это 28 800 в сутки, а весь бесплатный тир
|
||||
# DaData у проекта — 10 000 в сутки И ОН ОБЩИЙ с закрытым контуром. То есть без
|
||||
# этого потолка один настойчивый клиент (или один скрипт) за несколько часов
|
||||
# выедает квоту, и подсказки перестают работать у ПЛАТЯЩИХ пилотов, а не только
|
||||
# у него. Найдено состязательным ревью и подтверждено на проде: достаточно
|
||||
# упомянуть в запросе не-екатеринбургский город, чтобы локальный кадастровый
|
||||
# тир отключился и запрос гарантированно ушёл во внешний сервис.
|
||||
#
|
||||
# 2000/сутки — заведомо меньше десятой доли тира: публичная форма не должна
|
||||
# уметь навредить закрытому контуру в принципе. Порог достижим только абузом
|
||||
# (живой посетитель тратит единицы запросов на адрес), поэтому исчерпание —
|
||||
# сигнал, а не штатный режим: логируем ошибкой.
|
||||
_DAILY_SUGGEST_BUDGET = 2000
|
||||
_daily_suggest_limiter = SlidingWindowLimiter(limit=_DAILY_SUGGEST_BUDGET, window_s=86_400.0)
|
||||
_GLOBAL_KEY = "public-suggest"
|
||||
|
||||
# ── Потолок одновременных подсказок ─────────────────────────────────────────
|
||||
#
|
||||
# Кадастровый тир геокодера уходит в FDW-скан ЧУЖОЙ базы (gendesign) и на
|
||||
# коротком вводе занимает около секунды, всё это время удерживая соединение из
|
||||
# пула. Пул общий с закрытым контуром и невелик (дефолт SQLAlchemy 5+10), так
|
||||
# что полтора десятка одновременных публичных подсказок способны положить
|
||||
# B2B-запросы в том же процессе — при том, что per-IP лимиты каждого из них
|
||||
# формально соблюдены.
|
||||
#
|
||||
# Ждём слот недолго и отвечаем 429, а не копим очередь: очередь под нагрузкой
|
||||
# превращается в те же занятые соединения плюс растущий таймаут у клиента.
|
||||
_SUGGEST_CONCURRENCY = 4
|
||||
_SUGGEST_SLOT_WAIT_S = 2.0
|
||||
_suggest_slots = asyncio.Semaphore(_SUGGEST_CONCURRENCY)
|
||||
|
||||
|
||||
def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None:
|
||||
"""429 при превышении per-IP бюджета. Попытку регистрируем ДО работы ручки.
|
||||
|
||||
В отличие от отправки сообщения в поддержку (там `record()` только на
|
||||
успех, чтобы неудача не съедала бюджет), здесь считаем каждую попытку:
|
||||
внешний вызов геокодера тратится и на запросе, который вернёт пусто, —
|
||||
иначе перебор мусорными строками не стоил бы атакующему ничего.
|
||||
"""
|
||||
ip = _client_ip(request)
|
||||
retry_after = limiter.retry_after(ip)
|
||||
if retry_after is not None:
|
||||
logger.info("public mera %s rate-limited for %s", what, ip)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов. Попробуйте через минуту.",
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
limiter.record(ip)
|
||||
|
||||
|
||||
class PublicSuggestInput(BaseModel):
|
||||
"""Вход публичного автокомплита.
|
||||
|
||||
Телом, а не query-параметрами — см. `public_suggest`.
|
||||
"""
|
||||
|
||||
q: str = Field(min_length=2, max_length=200)
|
||||
limit: int = Field(default=8, ge=1, le=10)
|
||||
city_hint: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
def _fold(text: str) -> str:
|
||||
"""ёЁ→еЕ + casefold — та же нормализация, что у городов в trade_in.py."""
|
||||
return text.translate(str.maketrans("ёЁ", "ее")).casefold()
|
||||
|
||||
|
||||
def _query_with_city(query: str, city_hint: str | None) -> str:
|
||||
"""Подставить выбранный город В САМУ СТРОКУ запроса.
|
||||
|
||||
ЗАЧЕМ. `city_hint` доезжает до геокодера, но НА ВЫДАЧУ ПОДСКАЗОК НЕ ВЛИЯЕТ:
|
||||
его использует только екатеринбургский кадастровый тир (как признак «речь
|
||||
не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и
|
||||
хинта не принимает. Замер на проде 16.08.2026: выбран Серов, введено
|
||||
«Ленина 1» → первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский».
|
||||
Человек выбирает верхний вариант и считает совсем чужой дом — ровно тот
|
||||
баг #2576, ради которого город и спрашивают.
|
||||
|
||||
С городом в строке («Серов Ленина 1») выдача становится серовской целиком —
|
||||
проверено там же.
|
||||
|
||||
Для Екатеринбурга подстановка безвредна: три разных адреса дали
|
||||
побайтово тот же результат с префиксом и без (кадастровый тир парсит
|
||||
улицу и дом одинаково). Поэтому правило одно на все города, без
|
||||
исключения для основного трафика — исключение пришлось бы поддерживать.
|
||||
|
||||
Чинится ЗДЕСЬ, а не в геокодере: там от `city_hint` зависит поведение
|
||||
закрытого контура (`target_city_ambiguous`), и менять его смысл ради
|
||||
публичной формы значит трогать чужой контракт.
|
||||
"""
|
||||
if not city_hint:
|
||||
return query
|
||||
if _fold(city_hint) in _fold(query):
|
||||
return query
|
||||
return f"{city_hint}, {query}"
|
||||
|
||||
|
||||
@router.post("/suggest", response_model=SuggestResponse)
|
||||
async def public_suggest(
|
||||
request: Request,
|
||||
payload: PublicSuggestInput,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> SuggestResponse:
|
||||
"""Автокомплит адреса для публичной формы (Свердловская область).
|
||||
|
||||
ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на
|
||||
публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а
|
||||
он пишет URI целиком — то есть адрес квартиры лёг бы в файл рядом с IP
|
||||
посетителя. Мы публично обещаем на этой же странице, что введённый адрес
|
||||
нигде не сохраняем; лог — это сохранение. Тело запроса в лог не попадает,
|
||||
поэтому обещание остаётся правдой без правки конфигурации логирования (её
|
||||
легко потерять при следующем рефакторинге Caddyfile — а тип запроса
|
||||
потерять нельзя, сломается сразу и заметно).
|
||||
|
||||
Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а
|
||||
не через query-параметры (frontend `estimate-draft.ts`).
|
||||
|
||||
Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран
|
||||
(`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику:
|
||||
публичная форма обязана резолвить адрес ровно так же, как платный расчёт,
|
||||
иначе аноним выберет дом, которого потом «не окажется».
|
||||
|
||||
Отличие от v1 ровно одно — потолок `limit` 10 вместо 15: выдача сверх
|
||||
десятка в публичном UI не показывается, а каждый лишний кандидат может
|
||||
стоить внешнего вызова.
|
||||
"""
|
||||
_enforce(_suggest_limiter, request, "suggest")
|
||||
|
||||
# Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его
|
||||
# собственным лимитом, и только оставшееся считаем в общий бюджет.
|
||||
daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY)
|
||||
if daily_retry is not None:
|
||||
logger.error(
|
||||
"публичные подсказки исчерпали суточный бюджет (%d) — квота геокодера "
|
||||
"защищена, но форма на лэндинге сейчас без автокомплита",
|
||||
_DAILY_SUGGEST_BUDGET,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Подсказки адреса временно недоступны. Введите адрес полностью.",
|
||||
headers={"Retry-After": str(int(daily_retry) + 1)},
|
||||
)
|
||||
_daily_suggest_limiter.record(_GLOBAL_KEY)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_suggest_slots.acquire(), timeout=_SUGGEST_SLOT_WAIT_S)
|
||||
except TimeoutError:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Сервис сейчас занят. Попробуйте ещё раз через несколько секунд.",
|
||||
headers={"Retry-After": "5"},
|
||||
) from None
|
||||
|
||||
try:
|
||||
# Пометка публичного запроса нужна ровно здесь: внутри `suggest_addresses`
|
||||
# геокодер логирует введённую строку, а публичная форма обещает, что
|
||||
# адрес не попадает в журналы.
|
||||
with public_request_scope():
|
||||
return await suggest_addresses(
|
||||
q=_query_with_city(payload.q, payload.city_hint),
|
||||
limit=payload.limit,
|
||||
db=db,
|
||||
city_hint=payload.city_hint,
|
||||
)
|
||||
finally:
|
||||
_suggest_slots.release()
|
||||
|
||||
|
||||
@router.post("/coverage", response_model=CoverageProbeResponse)
|
||||
def public_coverage(
|
||||
request: Request,
|
||||
payload: CoverageProbeInput,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> CoverageProbeResponse:
|
||||
"""Бесплатная проба покрытия (issue #2894) для публичной формы.
|
||||
|
||||
Делегирует в `app.api.v1.trade_in.coverage_probe` — ту же функцию, что
|
||||
вызывает закрытый контур. Копии SQL здесь нет намеренно: разбор #2894
|
||||
показал, что стоит когорте пробы разойтись с когортой платного расчёта —
|
||||
проба честно отвечает «есть данные» там, где расчёт увидит ноль.
|
||||
|
||||
Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) — бесплатный
|
||||
шаг доказывает наличие данных, цену продаёт платный.
|
||||
"""
|
||||
_enforce(_coverage_limiter, request, "coverage")
|
||||
return coverage_probe(payload=payload, db=db)
|
||||
|
|
@ -2230,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel):
|
|||
)
|
||||
only_status: str = Field(
|
||||
default="pending",
|
||||
description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.",
|
||||
description=(
|
||||
"Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор "
|
||||
"'transient_error' на половине пакета; явное значение = только этот статус."
|
||||
),
|
||||
)
|
||||
house_id: int | None = Field(
|
||||
default=None,
|
||||
|
|
@ -2267,7 +2270,12 @@ async def scrape_house_imv_backfill(
|
|||
batch_size: сколько домов обработать за запуск (default 50).
|
||||
request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV
|
||||
реагирует на частые запросы с datacenter-IP. Не снижать < 3s.
|
||||
only_status: по умолчанию 'pending'. Для retry failed — 'transient_error'.
|
||||
only_status: по умолчанию 'pending' — и тогда половина пакета сама уходит на
|
||||
повтор домов в 'transient_error' с непотраченным лимитом попыток (#2674:
|
||||
раньше повтор существовал только как этот параметр, и за 41 прогон его
|
||||
не передали ни разу — 1390 домов застряли навсегда). Явное значение
|
||||
отключает автоповтор и обрабатывает РОВНО указанный статус, включая
|
||||
дома, исчерпавшие лимит (imv_transient_attempts >= 3).
|
||||
house_id: обработать один дом (debug).
|
||||
|
||||
Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию.
|
||||
|
|
|
|||
219
tradein-mvp/backend/app/api/v1/glitchtip.py
Normal file
219
tradein-mvp/backend/app/api/v1/glitchtip.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/
|
||||
`alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в
|
||||
stdout и никуда их не доставляет — аудит на проде 2026-08-15).
|
||||
|
||||
GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`)
|
||||
умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK`` —
|
||||
"General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py
|
||||
send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py
|
||||
_send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую
|
||||
``send_webhook()`` — ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload
|
||||
(text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization,
|
||||
ни подписи, ни X-*). Значит:
|
||||
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
|
||||
``{"text": str, "attachments": [{"title","title_link","text","color",
|
||||
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
|
||||
2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки).
|
||||
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
|
||||
нечем (GlitchTip-сторона его не добавляет).
|
||||
|
||||
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
|
||||
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
|
||||
``secrets.compare_digest`` constant-time compare, тот же env. Отличие от
|
||||
rbac-паттерна: ТАМ пустой секрет — fail-open (есть второй рубеж, roles.yaml).
|
||||
ЗДЕСЬ секрет — единственный рубеж вообще, поэтому пустой секрет ИЛИ
|
||||
несконфигурированный Telegram-бот → 503 "не настроено", а не тихий
|
||||
fail-open настежь.
|
||||
|
||||
Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) — так его можно добавить в
|
||||
``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без
|
||||
regex/prefix-веток в ``rbac_guard``). Сам путь — не секрет, секрет — только
|
||||
значение query-параметра.
|
||||
|
||||
Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт
|
||||
``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` — оба сейчас сидят
|
||||
только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared``
|
||||
(алиас неявный — Docker embedded DNS резолвит по ``container_name``, тот же
|
||||
приём уже используется Caddy → ``tradein-backend:8000``, см. Caddyfile).
|
||||
Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``,
|
||||
иначе имя ``tradein-backend`` не резолвится — общей сети нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH
|
||||
# для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот
|
||||
# же потолок). Суффикс обрезки учтён в _truncate.
|
||||
_TELEGRAM_MAX_LEN = 4096
|
||||
_TRUNCATE_SUFFIX = "\n… (обрезано)"
|
||||
|
||||
# Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в
|
||||
# support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp
|
||||
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
||||
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||
|
||||
|
||||
class GlitchTipAttachment(BaseModel):
|
||||
"""Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ
|
||||
подмножества полей (uptime не шлёт ``fields``/``color``) — все опциональны,
|
||||
``extra="allow"`` на случай будущих версий GlitchTip."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
title: str | None = None
|
||||
title_link: str | None = None
|
||||
text: str | None = None
|
||||
color: str | None = None
|
||||
fields: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class GlitchTipWebhookPayload(BaseModel):
|
||||
"""Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и
|
||||
uptime-алертов (см. docstring модуля)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
text: str | None = None
|
||||
attachments: list[GlitchTipAttachment] | None = None
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX
|
||||
|
||||
|
||||
def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None:
|
||||
"""Ищет значение поля attachment.fields по title (issue-алерты кладут туда
|
||||
"Project" литералом — см. apps/alerts/webhooks.py send_issue_as_webhook)."""
|
||||
for field in attachment.fields or []:
|
||||
if str(field.get("title", "")).strip().lower() == label.lower():
|
||||
value = field.get("value")
|
||||
return str(value) if value is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str:
|
||||
lines = [f"GlitchTip: {payload.text or 'Alert'}"]
|
||||
for attachment in payload.attachments or []:
|
||||
block: list[str] = []
|
||||
project = _field_value(attachment, "Project")
|
||||
if project:
|
||||
block.append(f"Проект: {project}")
|
||||
if attachment.title:
|
||||
block.append(attachment.title)
|
||||
if attachment.text:
|
||||
block.append(attachment.text)
|
||||
if attachment.title_link:
|
||||
block.append(f"Ссылка: {attachment.title_link}")
|
||||
if block:
|
||||
lines.append("")
|
||||
lines.extend(block)
|
||||
lines.append("")
|
||||
lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str:
|
||||
"""Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`,
|
||||
ни `attachments`, либо тело — не JSON-объект вовсе) — не роняем запрос,
|
||||
пересылаем как есть с пометкой (см. требование задачи: неизвестная форма
|
||||
payload не должна давать 500)."""
|
||||
text_repr = raw_body.decode("utf-8", errors="replace")
|
||||
header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть"
|
||||
return _truncate(
|
||||
f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"
|
||||
)
|
||||
|
||||
|
||||
def _build_message(raw_body: bytes, received_at: datetime) -> str:
|
||||
try:
|
||||
data = json.loads(raw_body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
try:
|
||||
payload = GlitchTipWebhookPayload.model_validate(data)
|
||||
except ValidationError:
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
if payload.text is None and not payload.attachments:
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
return _truncate(_format_known_payload(payload, received_at))
|
||||
|
||||
|
||||
def _alerts_configured() -> bool:
|
||||
"""Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id
|
||||
темы алертов. Отсутствие любой — 503, а не тихий no-op и не fail-open."""
|
||||
return bool(
|
||||
settings.tradein_internal_auth_secret
|
||||
and settings.telegram_bot_token
|
||||
and settings.telegram_alerts_chat_id
|
||||
)
|
||||
|
||||
|
||||
def _verify_secret(provided: str) -> None:
|
||||
expected = settings.tradein_internal_auth_secret
|
||||
if not secrets.compare_digest(provided or "", expected):
|
||||
logger.warning("glitchtip webhook: invalid or missing secret query param")
|
||||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
||||
|
||||
|
||||
@router.post("/ops/glitchtip-webhook")
|
||||
async def glitchtip_webhook(
|
||||
request: Request,
|
||||
secret: Annotated[str, Query()] = "",
|
||||
) -> dict[str, str]:
|
||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
||||
|
||||
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||
хендлер сам делает единственную проверку (``secret`` query-параметр).
|
||||
"""
|
||||
if not _alerts_configured():
|
||||
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
|
||||
|
||||
_verify_secret(secret)
|
||||
|
||||
raw_body = await request.body()
|
||||
received_at = datetime.now(UTC)
|
||||
text = _build_message(raw_body, received_at)
|
||||
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
text=text,
|
||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
|
||||
# может легально висеть воркерные минуты ретраев.
|
||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramApiError:
|
||||
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
|
||||
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
|
||||
|
||||
return {"status": "ok"}
|
||||
|
|
@ -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-ФЗ"
|
||||
"Политикой обработки персональных данных"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import asyncio
|
|||
import calendar
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
|
||||
|
|
@ -25,6 +26,8 @@ from app.schemas.trade_in import (
|
|||
AnalogLot,
|
||||
AvitoImvSummary,
|
||||
CianPriceChangeStats,
|
||||
CoverageProbeInput,
|
||||
CoverageProbeResponse,
|
||||
DkpCorridor,
|
||||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
|
|
@ -2549,3 +2552,280 @@ def get_sales_vs_listings(
|
|||
data_quality="street_only" if total_deals > 0 else "no_data",
|
||||
pairs=pairs,
|
||||
)
|
||||
|
||||
|
||||
# ── Coverage probe (#2894) — бесплатный шаг лэндинга, ЦЕНЫ НЕТ ─────────────────
|
||||
# До оплаты человек видит, СКОЛЬКО похожих квартир продаётся рядом и КАК БЫСТРО
|
||||
# они уходят — ни одной рублёвой цифры (см. CoverageProbeResponse docstring).
|
||||
# Один SQL, ноль внешних вызовов, ноль записей — ручка дешёвая специально: её
|
||||
# планируется открыть анонимам отдельной задачей (#2895, со своим consent-
|
||||
# гейтом). RBAC здесь НЕ трогаем — путь остаётся закрытым (не в _PUBLIC_PATHS).
|
||||
# строго 1000м по ТЗ #2894 (НЕ DEFAULT_RADIUS_M эстиматора — тот допускает fallback до 2000)
|
||||
COVERAGE_RADIUS_M = 1000
|
||||
COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади
|
||||
COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS)
|
||||
|
||||
# MAJOR-2 (независимый ревью #2894): days_on_market на проде заполнена практически
|
||||
# только у yandex (avito/cian/domklik — 0 заполнено) — возраст известен у меньшинства
|
||||
# когорты, и на тонких когортах "медиана" считалась по 1-2 объявлениям. Ниже порога
|
||||
# n_with_age медиану не отдаём (null) — не продуктовое решение, а честность при
|
||||
# заведомо шумной статистике по единичным точкам.
|
||||
COVERAGE_MIN_AGE_SAMPLES = 5
|
||||
# 15% свежих yandex-строк имеют days_on_market > 365 (максимум 4261) — это почти
|
||||
# наверняка мёртвое/забытое объявление, которое никто не снял с публикации, а не
|
||||
# сигнал о реальном времени экспозиции рынка. Отбрасываем как выброс из медианы.
|
||||
COVERAGE_MAX_AGE_DAYS = 365
|
||||
|
||||
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
|
||||
#
|
||||
# ⚠️ Эти списки обязаны совпадать с `OBLAST_CITIES`
|
||||
# (frontend/src/lib/city-registry.ts) — тем, что человек видит в дропдауне.
|
||||
# Расхождение поймано на проде 16.08.2026: Серов предлагался к выбору, но
|
||||
# отсутствовал здесь, и житель Серова получал «этот адрес вне области, по
|
||||
# которой мы собираем данные» — про город В ТОЙ ЖЕ области, который мы ему сами
|
||||
# и предложили. Сверка теперь автоматическая, см.
|
||||
# tests/test_public_mera_api.py::test_offered_cities_match_coverage_cities.
|
||||
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
|
||||
# Серов добавлен 16.08.2026: в жёлтый тир, а не в зелёный — в радиусе 15 км от
|
||||
# центра 363 активных объявления (все свежие), это на порядок меньше городов
|
||||
# вокруг Екатеринбурга, но заведомо не ноль.
|
||||
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда", "Серов")
|
||||
COVERAGE_GREEN_MIN_N = 8
|
||||
COVERAGE_YELLOW_MIN_N = 12
|
||||
|
||||
|
||||
def _fold_city(name: str) -> str:
|
||||
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
||||
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
||||
|
||||
|
||||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||
}
|
||||
|
||||
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
|
||||
# `listings.city` найденной когорты — оказалось, что `listings.city` это город
|
||||
# СВИП-контекста скрейпера (миграция 196 — колонка заполняется тем городом,
|
||||
# который скрейпер обходил, не геокодом самого объявления). Замер на проде:
|
||||
# в радиусе 1000 м вокруг Берёзовского 90/90 строк имеют city='Екатеринбург';
|
||||
# вокруг Ревды 74/74 — city='Первоуральск'. Следствие: продавец в Берёзовском
|
||||
# видел на лэндинге «Екатеринбург», а сами COVERAGE_GREEN/YELLOW_CITIES для
|
||||
# городов-спутников были НЕДОСТИЖИМЫ (в БД нет ни одной строки с их city).
|
||||
# Фикс — детерминированный резолв по координатам ЗАПРОСА (никакого участия
|
||||
# клиента, никакой моды когорты): ближайший центроид города из списка ниже,
|
||||
# если он в пределах COVERAGE_CITY_MATCH_RADIUS_KM.
|
||||
#
|
||||
# Координаты — константа РЯДОМ С РУЧКОЙ, не таблица в БД: единственный
|
||||
# существующий кандидат на "готовый реестр городов" — это
|
||||
# frontend/src/lib/city-registry.ts (OBLAST_CITIES) и backend
|
||||
# geocoder.py::SVERDLOVSK_OBLAST_CITIES — оба хранят ТОЛЬКО текстовые лейблы
|
||||
# (city_hint для геокодера), без координат. Заводить миграцию + таблицу ради
|
||||
# статичного справочника из 8 географических центров населённых пунктов —
|
||||
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
|
||||
# рядом с порогами, которые они резолвят.
|
||||
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
|
||||
|
||||
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||
"Екатеринбург": (56.8389, 60.6057),
|
||||
"Верхняя Пышма": (56.9789, 60.5636),
|
||||
"Берёзовский": (56.9096, 60.8034),
|
||||
"Среднеуральск": (56.9848, 60.4759),
|
||||
"Нижний Тагил": (57.9099, 59.9819),
|
||||
"Каменск-Уральский": (56.4110, 61.9243),
|
||||
"Первоуральск": (56.9083, 59.9483),
|
||||
"Ревда": (56.7986, 59.9298),
|
||||
"Серов": (59.6047, 60.5772),
|
||||
}
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
|
||||
r_earth_km = 6371.0
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * r_earth_km * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
|
||||
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
|
||||
|
||||
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
|
||||
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
|
||||
и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то,
|
||||
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
|
||||
больше НЕ допускаются в качестве источника истины.
|
||||
"""
|
||||
nearest_city: str | None = None
|
||||
nearest_km = math.inf
|
||||
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
|
||||
distance_km = _haversine_km(lat, lon, clat, clon)
|
||||
if distance_km < nearest_km:
|
||||
nearest_km = distance_km
|
||||
nearest_city = city
|
||||
|
||||
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
|
||||
return "", 0, False
|
||||
|
||||
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
|
||||
return display, threshold, True
|
||||
|
||||
|
||||
@router.post("/coverage", response_model=CoverageProbeResponse)
|
||||
def coverage_probe(
|
||||
payload: CoverageProbeInput,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> CoverageProbeResponse:
|
||||
"""Бесплатная проба покрытия (issue #2894) — сколько похожих квартир рядом.
|
||||
|
||||
Когорта — тот же дедуп/cap-канон, что radius-тиры в estimator._fetch_analogs
|
||||
(rn_dup по (source, source_id), rn_addr cap по адресу, реюз тех же
|
||||
приватных helper'ов эстиматора — импорт локальный, как и в остальных
|
||||
ручках этого файла, чтобы не тащить тяжёлый app.services.estimator
|
||||
в module-level import graph): ST_DWithin 1000м, rooms точное совпадение,
|
||||
area ±15%, scraped_at не старше 14 дней, is_active.
|
||||
|
||||
MAJOR-1 fix (независимый ревью #2894): когорта пробы обязана быть
|
||||
ПОДМНОЖЕСТВОМ когорты платного эстиматора, не шире её — иначе проба честно
|
||||
отвечает "ok" там, где платный расчёт увидит 0. Три предиката ниже — тот же
|
||||
канон, что estimator._COMMON_WHERE (app/services/estimator.py:5441/5460) и
|
||||
inline-копия Tier W (estimator.py:5910/5916/5932, radius-тир, откуда реально
|
||||
берутся аналоги на 1000 м): guard новостроек, geo_precision != 'city'
|
||||
(#769 Part E — city-centroid листинги без реального адреса), price_rub > 0.
|
||||
|
||||
В ответе НЕТ ни одной цены — см. CoverageProbeResponse docstring.
|
||||
|
||||
MAJOR-2 (независимый ревью #2894): days_on_market на проде фактически
|
||||
заполнена только у ОДНОГО источника (yandex) — это ограничение данных, а
|
||||
не продуктовое решение. n_with_age в ответе честно считает, по скольким
|
||||
объявлениям взята медиана; ниже COVERAGE_MIN_AGE_SAMPLES — null (см. поле
|
||||
в ответе). Значения > COVERAGE_MAX_AGE_DAYS (почти наверняка мёртвое
|
||||
объявление) в расчёт медианы не берутся.
|
||||
|
||||
#oblast (2026-08): house_placement_history.exposure_days — реальная (не
|
||||
цензурированная) экспозиция history-строк — НЕ используется здесь: это
|
||||
house-level архив (join по house_id, не привязан к текущей radius/rooms/
|
||||
area когорте один-в-один), а не активные листинги в подобранном радиусе;
|
||||
сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша
|
||||
в честности (у нас и так честное имя поля — age активного объявления, не
|
||||
срок продажи). См. openQuestions PR #2894 при ревью.
|
||||
|
||||
Повторная проверка ручки (2026-08): город больше НЕ берётся из моды
|
||||
`listings.city` найденной когорты и НЕ зависит от `payload.city_hint` —
|
||||
оба источника ненадёжны (см. комментарий над `_CITY_CENTROIDS_DEG`).
|
||||
Город резолвится детерминированно по `payload.lat/lon` через
|
||||
`_resolve_coverage_city` — `city_hint` в payload остаётся только
|
||||
информационным полем (см. `CoverageProbeInput.city_hint`), на результат
|
||||
не влияет.
|
||||
"""
|
||||
from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS
|
||||
|
||||
area_min = payload.area_m2 * (1 - COVERAGE_AREA_TOLERANCE)
|
||||
area_max = payload.area_m2 * (1 + COVERAGE_AREA_TOLERANCE)
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH base AS (
|
||||
SELECT
|
||||
days_on_market,
|
||||
row_number() OVER (
|
||||
PARTITION BY address ORDER BY scraped_at DESC
|
||||
) AS rn_addr,
|
||||
{_RN_DUP_WINDOW}
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
AND rooms = :rooms
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND ST_DWithin(
|
||||
geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
|
||||
)
|
||||
-- MAJOR-1: sync с estimator._COMMON_WHERE (5441) / Tier W (5916) —
|
||||
AND price_rub > 0
|
||||
-- MAJOR-1: sync с estimator._COMMON_WHERE (5460) / Tier W (5932) —
|
||||
-- guard новостроек, NULL = legacy вторичка до м.011
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- MAJOR-1: sync с estimator Tier W (5910/5945-5948, #769 Part E) —
|
||||
-- исключает city-centroid листинги без реального адреса;
|
||||
-- IS DISTINCT FROM пропускает NULL (неизвестная точность)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
)
|
||||
SELECT
|
||||
count(*) AS n_listings,
|
||||
count(*) FILTER (
|
||||
WHERE days_on_market IS NOT NULL
|
||||
AND days_on_market <= :max_age_days
|
||||
) AS n_with_age,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market)
|
||||
FILTER (
|
||||
WHERE days_on_market IS NOT NULL
|
||||
AND days_on_market <= :max_age_days
|
||||
) AS median_age_days
|
||||
FROM base
|
||||
WHERE rn_addr <= :max_per_addr
|
||||
AND rn_dup = 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"rooms": payload.rooms,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"fresh_days": COVERAGE_FRESH_DAYS,
|
||||
"lat": payload.lat,
|
||||
"lon": payload.lon,
|
||||
"radius": COVERAGE_RADIUS_M,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
"max_age_days": COVERAGE_MAX_AGE_DAYS,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
n_listings = int(row["n_listings"]) if row else 0
|
||||
n_with_age = int(row["n_with_age"]) if row and row["n_with_age"] is not None else 0
|
||||
median_age = (
|
||||
round(row["median_age_days"])
|
||||
if row is not None
|
||||
and row["median_age_days"] is not None
|
||||
and n_with_age >= COVERAGE_MIN_AGE_SAMPLES
|
||||
else None
|
||||
)
|
||||
|
||||
city, threshold, supported = _resolve_coverage_city(payload.lat, payload.lon)
|
||||
|
||||
if not supported or n_listings == 0:
|
||||
status: Literal["ok", "thin", "not_covered"] = "not_covered"
|
||||
# Nit-fix (повторная проверка #2894): threshold неприменим при
|
||||
# not_covered — см. CoverageProbeResponse.threshold docstring. Раньше
|
||||
# поддерживаемый (по координатам) город с пустой когортой отдавал
|
||||
# реальный порог (8/12) вместе с not_covered — противоречило докстрингу.
|
||||
threshold = 0
|
||||
elif n_listings >= threshold:
|
||||
status = "ok"
|
||||
else:
|
||||
status = "thin"
|
||||
|
||||
logger.info(
|
||||
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d n_with_age=%d",
|
||||
payload.rooms,
|
||||
payload.area_m2,
|
||||
city,
|
||||
status,
|
||||
n_listings,
|
||||
n_with_age,
|
||||
)
|
||||
|
||||
return CoverageProbeResponse(
|
||||
status=status,
|
||||
n_listings=n_listings,
|
||||
median_listing_age_days=median_age,
|
||||
n_with_age=n_with_age,
|
||||
radius_m=COVERAGE_RADIUS_M,
|
||||
city=city,
|
||||
threshold=threshold,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -599,9 +599,13 @@ class Settings(BaseSettings):
|
|||
cian_valuation_max_rub: float = 500_000_000
|
||||
|
||||
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||||
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
||||
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
||||
sber_index_max_age_days: int = 35
|
||||
# #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению
|
||||
# (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток),
|
||||
# guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно
|
||||
# место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог
|
||||
# берёт из такта самой загрузки (scrape_schedules.default_params.interval_days),
|
||||
# так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться.
|
||||
# extra="ignore" в model_config защищает от startup-краха на leftover env var.
|
||||
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
|
||||
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
|
||||
avito_imv_thin_market_threshold: int = 10
|
||||
|
|
@ -629,6 +633,23 @@ class Settings(BaseSettings):
|
|||
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
||||
estimate_quarter_index_factor_min: float = 0.6
|
||||
estimate_quarter_index_factor_max: float = 1.8
|
||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||
#
|
||||
# Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр
|
||||
# известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м,
|
||||
# 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность
|
||||
# цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и
|
||||
# координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт
|
||||
# денежный — поэтому включение отдельным решением, после замера.
|
||||
#
|
||||
# Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с
|
||||
# заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И
|
||||
# сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к
|
||||
# 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить,
|
||||
# а не оставлять «на вырост».
|
||||
estimate_quarter_from_coords_enabled: bool = False
|
||||
|
||||
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
|
||||
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
|
||||
|
|
@ -976,6 +997,15 @@ class Settings(BaseSettings):
|
|||
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||||
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||||
|
||||
# ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ──
|
||||
# Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода
|
||||
# НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py.
|
||||
# Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open —
|
||||
# это единственный auth-рубеж эндпоинта, в отличие от rbac-путей).
|
||||
# ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID.
|
||||
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
|
||||
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
|
||||
|
||||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||||
|
|
|
|||
97
tradein-mvp/backend/app/core/public_request.py
Normal file
97
tradein-mvp/backend/app/core/public_request.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Пометка «этот запрос пришёл из публичной формы» и её единственное следствие:
|
||||
адрес, который ввёл аноним, не попадает в журналы.
|
||||
|
||||
ЗАЧЕМ ЭТО СУЩЕСТВУЕТ
|
||||
--------------------
|
||||
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что введённый
|
||||
адрес нигде не сохраняется. По базам данных это правда (обе публичные ручки
|
||||
только читают), а по журналам — не было: геокодер логирует запрос открытым
|
||||
текстом на каждый вызов, например
|
||||
|
||||
INFO app.services.dadata: dadata suggest: 'онуфриева 24' → 5 вариантов
|
||||
|
||||
Прод пишет stdout контейнеров в journald с persistent-хранилищем
|
||||
(`tradein-mvp/docker-compose.prod.yml`), то есть строка ложится на диск и живёт
|
||||
там неделями. Рядом, в access-логе Caddy, лежит IP того же запроса с той же
|
||||
меткой времени — то есть адрес квартиры фактически сохранён и сопоставим с
|
||||
человеком. Ровно то, что публичная страница обещает не делать.
|
||||
|
||||
Найдено состязательным ревью PR публичного периметра (16.08.2026) и
|
||||
воспроизведено на проде, а не выведено из чтения кода.
|
||||
|
||||
ПОЧЕМУ ФИЛЬТР, А НЕ ПРАВКА КАЖДОГО ВЫЗОВА logger
|
||||
------------------------------------------------
|
||||
Мест, где адрес попадает в лог, много (`app/services/dadata.py`,
|
||||
`app/services/geocoder.py` — успех, пустая выдача, сетевая ошибка, таймаут,
|
||||
кадастровый фолбэк), и любое новое добавится незаметно. Обещание не должно
|
||||
зависеть от того, вспомнил ли автор следующей правки про эту страницу.
|
||||
Фильтр — единственная точка, которая закрывает и уже написанное, и будущее.
|
||||
|
||||
ПОЧЕМУ contextvar
|
||||
-----------------
|
||||
Публичный и закрытый контуры обслуживает ОДИН процесс, и один и тот же
|
||||
`suggest()` вызывают оба. Различить их можно только по текущему запросу.
|
||||
`ContextVar` — то, что переживает `await` и копируется в `asyncio.to_thread`
|
||||
(им геокодер уходит в синхронный кадастровый тир), в отличие от глобального
|
||||
флага, который в конкурентной обработке принадлежал бы соседнему запросу.
|
||||
|
||||
Для B2B-трафика ничего не меняется: там флаг не выставлен, логи прежние — они
|
||||
нужны, чтобы разбирать жалобы пилотов на подсказки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
#: Истинно, пока обрабатывается запрос анонимной публичной формы.
|
||||
is_public_request: ContextVar[bool] = ContextVar("mera_is_public_request", default=False)
|
||||
|
||||
#: Что видно в журнале вместо сообщения. Уровень и логгер сохраняются — по ним
|
||||
#: по-прежнему видно, что вызов был и чем закончился.
|
||||
REDACTED_MESSAGE = "<публичный запрос МЕРЫ: содержимое скрыто>"
|
||||
|
||||
#: Логгеры, чьи записи могут содержать введённый адрес.
|
||||
ADDRESS_LOGGERS = ("app.services.dadata", "app.services.geocoder")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def public_request_scope() -> Iterator[None]:
|
||||
"""Помечает текущий запрос публичным на время работы блока."""
|
||||
token = is_public_request.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
is_public_request.reset(token)
|
||||
|
||||
|
||||
class RedactPublicAddressFilter(logging.Filter):
|
||||
"""Заменяет сообщение целиком, пока обрабатывается публичный запрос.
|
||||
|
||||
Целиком, а не по ключам: в шаблонах сообщений адрес стоит рядом с
|
||||
безобидными аргументами (`"%r → %d вариантов"`), и отличить их друг от
|
||||
друга внутри фильтра нельзя. Терять текст сообщения на публичном пути
|
||||
дешевле, чем хранить адреса; на закрытом контуре текст остаётся полным.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if is_public_request.get():
|
||||
record.msg = REDACTED_MESSAGE
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def install_address_log_redaction() -> None:
|
||||
"""Вешает фильтр на логгеры, видящие адрес. Идемпотентно.
|
||||
|
||||
Фильтр ставится на КОНКРЕТНЫЕ логгеры, а не на корневой хендлер: фильтры
|
||||
логгера применяются к записям этого логгера, а не ко всему, что через
|
||||
хендлер проходит, — то есть посторонние сообщения (пул соединений, старт
|
||||
приложения) во время публичного запроса не пострадают.
|
||||
"""
|
||||
for name in ADDRESS_LOGGERS:
|
||||
logger = logging.getLogger(name)
|
||||
if not any(isinstance(f, RedactPublicAddressFilter) for f in logger.filters):
|
||||
logger.addFilter(RedactPublicAddressFilter())
|
||||
|
|
@ -32,6 +32,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||
|
||||
from app.core.config import settings
|
||||
|
||||
# Платёжная нотификация Т-Банка (PR-D2, готовит почву под PR-D3 — путь ещё
|
||||
# закрыт rbac до того момента). Сервер-к-серверу, без сессии/X-Authenticated-User
|
||||
# → в общем лимитере попал бы в один и тот же per-IP ключ с любым другим
|
||||
# анонимным трафиком с той же исходящей сети банка. Мотив НЕ «банк упрётся в
|
||||
# лимит» — 300/60с и так щедро — а «429 никогда не должен стать причиной, по
|
||||
# которой денежное состояние разъехалось»: для банка недоставленная нотификация
|
||||
# = «доставка не удалась», альтернативного канала нет, а очередь ретраев
|
||||
# растягивается на сутки. Только точный путь notify — НЕ checkout (тот
|
||||
# инициирует пользователь с сессией/курсором в браузере, абуз там штатно
|
||||
# лимитируем как любой другой API-путь).
|
||||
_PAYMENTS_NOTIFY_PATH = "/api/v1/trade-in/payments/notify"
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Sliding-window rate limit на /api/v1/*. Health и статика — без лимита."""
|
||||
|
|
@ -42,6 +54,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
path = request.url.path
|
||||
# Платёжная нотификация — мимо ОБЩЕГО (per-user/per-IP shared) лимитера,
|
||||
# но НЕ без лимита вовсе: idiom `_notify_limiter` (`SlidingWindowLimiter`,
|
||||
# тот же приём, что `support.py:92`/`:319` — узкий per-feature бюджет
|
||||
# ВМЕСТО общего, не полное отключение защиты). Порог заведомо выше любого
|
||||
# штатного трафика банка (документированное расписание ретраев неизвестно,
|
||||
# см. mera-tbank-acquiring-recon.md — берём с кратным запасом), но конечен:
|
||||
# полное отключение оставило бы путь без backstop против шторма запросов —
|
||||
# подпись отсекает мусор ПОСЛЕ разбора тела (PR-D3), не до.
|
||||
if path == _PAYMENTS_NOTIFY_PATH:
|
||||
retry_after = _notify_limiter.check(_client_ip(request))
|
||||
if retry_after is not None:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Слишком много запросов. Попробуйте позже."},
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
return await call_next(request)
|
||||
# Лимитируем только API; health и прочее — пропускаем.
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
|
|
@ -143,6 +172,16 @@ class SlidingWindowLimiter:
|
|||
return None
|
||||
|
||||
|
||||
# Щедрый бюджет для платёжной нотификации (PR-D2): 3000/60с (50 req/s) — на два
|
||||
# порядка выше любого правдоподобного трафика банка (тест 400/60с проходит с
|
||||
# запасом в 7.5×), но конечен — backstop против шторма запросов на путь, где
|
||||
# подпись проверяется уже ПОСЛЕ разбора тела. Ключ — client IP (у сервер-к-
|
||||
# серверу вызова нет сессии/X-Authenticated-User).
|
||||
_NOTIFY_RATE_LIMIT = 3000
|
||||
_NOTIFY_RATE_WINDOW_S = 60.0
|
||||
_notify_limiter = SlidingWindowLimiter(limit=_NOTIFY_RATE_LIMIT, window_s=_NOTIFY_RATE_WINDOW_S)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,26 @@ _PUBLIC_PATHS = frozenset(
|
|||
# не секрет, читает только process env — быстрая справка для клиента/
|
||||
# поддержки/смоук-теста, не должна требовать сессию.
|
||||
"/api/v1/trade-in/version",
|
||||
# GlitchTip webhook → Telegram (app/api/v1/glitchtip.py): вызывается
|
||||
# ИЗ glitchtip-worker (docker-сеть gendesign_shared), не может нести
|
||||
# X-Authenticated-User/сессию. Путь фиксированный и не секрет — секрет
|
||||
# это query-параметр `secret`, который проверяет сам хендлер
|
||||
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
|
||||
"/api/v1/trade-in/ops/glitchtip-webhook",
|
||||
# Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга
|
||||
# идентичности нет и не будет — Caddy на этом домене вообще без
|
||||
# basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не
|
||||
# пишут в БД адрес физлица, поэтому открываются ДО контура согласия
|
||||
# 152-ФЗ (#2895), а платный расчёт — только вместе с ним. Разбор
|
||||
# периметра и бюджетов — app/api/public/mera.py.
|
||||
#
|
||||
# Строки ТОЧНЫЕ, префиксной ветки здесь нет: множество проверяется как
|
||||
# `path in _PUBLIC_PATHS`, и превращать его в префиксный матчер ради
|
||||
# двух путей значит расширить механизм, от которого зависит auth-гейт
|
||||
# всего бэкенда. Гарантия «под /api/public/ не лежит ничего закрытого»
|
||||
# держится на структуре пакета app/api/public/, а не на матчере.
|
||||
"/api/public/mera/suggest",
|
||||
"/api/public/mera/coverage",
|
||||
}
|
||||
)
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
|
|
|
|||
|
|
@ -40,7 +40,29 @@ logger = logging.getLogger(__name__)
|
|||
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
|
||||
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
|
||||
# циклическую зависимость.
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
#
|
||||
# PR-D2: `/api/v1/trade-in/payments/notify` — заранее в skip-набор (defense-in-
|
||||
# depth), хотя rbac ещё закрывает этот путь до PR-D3. Причины две:
|
||||
# 1) сам путь не должен попадать в аудит вообще — тело нотификации содержит
|
||||
# `Token`/`Pan`/`ExpDate` (см. `app/main.py._before_send`, тот же мотив, что
|
||||
# и вырезание тела из мониторинга); хоть это middleware само по себе тело
|
||||
# запроса в payload не пишет (только status_code/path/method), путь не
|
||||
# должен зависеть от того, что кто-то потом добавит поле "body" в событие;
|
||||
# 2) НЕ авторизующая проверка: `RequestAuditMiddleware` внешний относительно
|
||||
# `rbac_guard` и читает сырой `X-Authenticated-User` (см. `main.py` порядок
|
||||
# middleware) — анонимный POST на notify с подделанным заголовком
|
||||
# `X-Authenticated-User: admin` иначе писал бы фальшивые события в
|
||||
# `user_events` с атрибуцией admin, при этом rbac при этом ничего не знает
|
||||
# (сам гейт отдельно, 401 всё равно вернёт до PR-D3).
|
||||
_PUBLIC_PATHS = frozenset(
|
||||
{
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/v1/trade-in/payments/notify",
|
||||
}
|
||||
)
|
||||
|
||||
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
|
||||
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
|
|
@ -20,6 +20,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
|||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||
|
||||
from app.api.public import mera as public_mera
|
||||
from app.api.v1 import (
|
||||
admin,
|
||||
audit,
|
||||
|
|
@ -27,6 +28,7 @@ from app.api.v1 import (
|
|||
brand,
|
||||
buildings,
|
||||
geocode,
|
||||
glitchtip,
|
||||
lead,
|
||||
me,
|
||||
privacy_admin,
|
||||
|
|
@ -66,17 +68,48 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
|||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||||
# frontend), отдельного broker нет → мониторить нечего.
|
||||
if settings.glitchtip_dsn:
|
||||
from app.observability.sentry_scrub import redact_telegram_bot_token
|
||||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
scrub_public_address,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
|
||||
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
|
||||
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
|
||||
sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
|
||||
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
|
||||
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
|
||||
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
|
||||
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
|
||||
TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||
include_local_variables=False ниже — первый рубеж защиты.
|
||||
|
||||
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные —
|
||||
режет `request.data` целиком только для `/payments/*`, остальные пути
|
||||
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
|
||||
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
|
||||
(before_send и before_send_transaction) — вчерашний баг в Птице закрыл
|
||||
только error-канал, transaction-канал остался вообще без обработчика.
|
||||
|
||||
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
|
||||
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
||||
sentry_scrub.stabilize_retry_error_fingerprint."""
|
||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
||||
# брэдкрамб исходящего вызова геокодера несёт его же в query. Публичная
|
||||
# страница обещает, что адрес нигде не сохраняется; GlitchTip — внешний
|
||||
# сервис, значит обещание распространяется и на него.
|
||||
scrubbed = scrub_public_address(scrubbed, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
|
||||
if detokened is None:
|
||||
return None
|
||||
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
|
|
@ -88,6 +121,10 @@ if settings.glitchtip_dsn:
|
|||
# держит base URL с токеном в локальных переменных стек-фрейма — default
|
||||
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
|
||||
before_send=_before_send,
|
||||
# PR-D2: тот же обработчик на transaction-канал — traces_sample_rate=0.0
|
||||
# сегодня не шлёт трейсы вообще, но это belt-and-suspenders на случай,
|
||||
# если трейсинг когда-нибудь включат (см. docstring _before_send выше).
|
||||
before_send_transaction=_before_send,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
FastApiIntegration(),
|
||||
|
|
@ -210,6 +247,26 @@ def health() -> dict[str, str]:
|
|||
return {"status": "ok", "environment": settings.environment}
|
||||
|
||||
|
||||
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
||||
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
||||
# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только
|
||||
# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой
|
||||
# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам —
|
||||
# только pg_isready для postgres) — маршрут пока используется лишь тестами.
|
||||
# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder
|
||||
# (backend/app/main.py, за Caddyfile `handle /health`), не здесь.
|
||||
# media_type="application/json" — Content-Type совпадает с GET; Content-Length
|
||||
# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload)
|
||||
# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для
|
||||
# HEAD, требует совпадения только заголовков представления (Content-Type).
|
||||
# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это
|
||||
# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим
|
||||
# симметрично, чтобы схема двух бэкендов не разъезжалась.
|
||||
@app.head("/health", include_in_schema=False)
|
||||
def health_head() -> Response:
|
||||
return Response(status_code=200, media_type="application/json")
|
||||
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
|
|
@ -220,7 +277,14 @@ app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]
|
|||
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
|
||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||||
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
|
||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
||||
|
||||
# Публичный B2C-периметр МЕРЫ (meraocenka.ru). Префикс НЕ /api/v1/* намеренно —
|
||||
# на публичном домене он проксируется целиком, поэтому под ним по определению
|
||||
# не должно лежать ничего закрытого; разбор — в app/api/public/mera.py.
|
||||
# Обе ручки перечислены в rbac._PUBLIC_PATHS точными строками.
|
||||
app.include_router(public_mera.router, prefix="/api/public/mera", tags=["public-mera"])
|
||||
|
|
|
|||
|
|
@ -25,10 +25,40 @@ import re
|
|||
from typing import Any
|
||||
|
||||
from sentry_sdk.types import Event
|
||||
from tenacity import RetryError
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||
_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
|
||||
# PR-D2 (payments perimeter hardening): + платёжные поля Т-Банка (customer_email/
|
||||
# customer_phone из checkout, pan/expdate/cardid/rebillid/token/terminalkey из
|
||||
# notify) — belt-and-suspenders поверх `scrub_payment_request_body` ниже, которая
|
||||
# вырезает `request.data` для /payments/* целиком: этот словарь всё равно нужен
|
||||
# для extra/contexts И на случай, если платёжное поле когда-нибудь попадёт в
|
||||
# error event НЕ через request.data (напр. кто-то положит его в extra вручную).
|
||||
_PII_KEYS = frozenset(
|
||||
{
|
||||
"client_name",
|
||||
"client_phone",
|
||||
"client_email",
|
||||
"phone",
|
||||
"email",
|
||||
"name",
|
||||
"customer_email",
|
||||
"customer_phone",
|
||||
"pan",
|
||||
"expdate",
|
||||
"cardid",
|
||||
"rebillid",
|
||||
"token",
|
||||
"terminalkey",
|
||||
}
|
||||
)
|
||||
|
||||
# Сегмент пути платёжного периметра (notify + checkout + любой будущий
|
||||
# /payments/* суб-путь) — PR-D2, готовит почву под PR-D3 (эндпоинты ещё не
|
||||
# существуют). Матчим по сегменту, не по конкретному эндпоинту, чтобы не
|
||||
# требовать правки этого файла на каждый новый платёжный путь.
|
||||
_PAYMENTS_URL_SEGMENT = "/api/v1/trade-in/payments/"
|
||||
|
||||
# Telegram Bot API токен в пути URL: /bot<numeric_id>:<secret-part>/<method>.
|
||||
# Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query
|
||||
|
|
@ -76,6 +106,31 @@ _URL_SECRET_QUERY_RE = re.compile(
|
|||
)
|
||||
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
|
||||
|
||||
# httpx error-message URL query stabilization (GlitchTip-noise review round 2,
|
||||
# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`)
|
||||
# bakes the FULL request URL — INCLUDING query string — into the exception message:
|
||||
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
|
||||
# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response
|
||||
# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После
|
||||
# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError
|
||||
# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст
|
||||
# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть
|
||||
# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за
|
||||
# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто
|
||||
# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по
|
||||
# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue,
|
||||
# невозможные при группировке чисто по stacktrace/culprit).
|
||||
#
|
||||
# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных
|
||||
# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле
|
||||
# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет).
|
||||
# Режем query целиком (не только конкретные параметры) — host+path остаются
|
||||
# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт
|
||||
# regex (не трогает произвольные строки с `?`, см. тест
|
||||
# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched).
|
||||
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
|
||||
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
|
||||
|
||||
|
||||
def _scrub(obj: Any) -> None:
|
||||
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
|
||||
|
|
@ -90,32 +145,34 @@ def _scrub(obj: Any) -> None:
|
|||
_scrub(item)
|
||||
|
||||
|
||||
def _redact_url_secrets_inplace(obj: Any) -> None:
|
||||
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
|
||||
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
|
||||
КАЖДОЙ строке event — не ключ-based: секрет утекает через httpx span
|
||||
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
|
||||
request URL), а не только через известные PII-поля формы. Мутирует dict/list
|
||||
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`) —
|
||||
сохраняет identity верхнеуровневого `event`, на что опирается контракт
|
||||
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
|
||||
def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None:
|
||||
"""Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)`
|
||||
по КАЖДОЙ строке event (не ключ-based) — общий обход, переиспользуемый и для
|
||||
URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message
|
||||
URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают
|
||||
через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx
|
||||
содержит полный request URL), а не только через известные PII-поля формы.
|
||||
Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от
|
||||
`_redact_strings`) — сохраняет identity верхнеуровневого `event`, на что
|
||||
опирается контракт `scrub_pii_event`/`before_send` и существующие тесты
|
||||
(`out is event`).
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if isinstance(value, str):
|
||||
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||
redacted = pattern.sub(replacement, value)
|
||||
if redacted != value:
|
||||
obj[key] = redacted
|
||||
else:
|
||||
_redact_url_secrets_inplace(value)
|
||||
_regex_redact_inplace(value, pattern, replacement)
|
||||
elif isinstance(obj, list):
|
||||
for i, value in enumerate(obj):
|
||||
if isinstance(value, str):
|
||||
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||
redacted = pattern.sub(replacement, value)
|
||||
if redacted != value:
|
||||
obj[i] = redacted
|
||||
else:
|
||||
_redact_url_secrets_inplace(value)
|
||||
_regex_redact_inplace(value, pattern, replacement)
|
||||
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
|
||||
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
|
||||
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
|
||||
|
|
@ -123,16 +180,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None:
|
|||
|
||||
|
||||
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
|
||||
"""Redact consumer-PII + URL query-string секретов/nondeterministic-данных из
|
||||
error event перед отправкой.
|
||||
|
||||
Композиция (обе — in-place, сохраняют identity `event`): (1) ключ-based
|
||||
Композиция (все — in-place, сохраняют identity `event`): (1) ключ-based
|
||||
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
|
||||
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
|
||||
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
|
||||
changeip — #security-audit). Второй шаг не завязан на конкретные ключи полей —
|
||||
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
|
||||
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
|
||||
event (не None).
|
||||
changeip — #security-audit), (3) full-text regex-проход, стабилизирующий httpx
|
||||
error-message URL (`for url '...?...'`) — убирает переменный query string
|
||||
(адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на
|
||||
каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у
|
||||
`_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей —
|
||||
ловят секрет/переменные данные в frame locals, breadcrumb, exception message
|
||||
и т.д., где они могут оказаться независимо от
|
||||
include_local_variables/traces_sample_rate. Возвращает event (не None).
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
|
|
@ -141,7 +203,96 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
|||
_scrub(request.get("data"))
|
||||
_scrub(event.get("extra"))
|
||||
_scrub(event.get("contexts"))
|
||||
_redact_url_secrets_inplace(event)
|
||||
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
|
||||
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
|
||||
return event
|
||||
|
||||
|
||||
def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Вырезать `event['request']['data']` целиком для платёжных путей (PR-D2).
|
||||
|
||||
Ключ-based `scrub_pii_event` НЕ спасает платёжную нотификацию: sentry_sdk
|
||||
2.64 (`integrations/starlette.py`) кладёт ПОЛНОЕ тело запроса в
|
||||
`event.request.data`, и `send_default_pii=False` этот путь не гейтит — тот
|
||||
флаг управляет только куками, не телом запроса (проверено живьём на соседнем
|
||||
продукте). Тело нотификации Т-Банка несёт `Token`/`Pan`/`ExpDate`/`CardId`/
|
||||
`RebillId`/`DATA` — банк сам выбирает имена полей, перечислить их все заранее
|
||||
нельзя, поэтому единственная безопасная стратегия для этого пути — не
|
||||
отправлять тело целиком, а не пытаться вычистить отдельные ключи.
|
||||
|
||||
Матчим по сегменту `/api/v1/trade-in/payments/` (не по конкретному
|
||||
эндпоинту) — покрывает notify, checkout и любой будущий суб-путь одним
|
||||
фильтром, без правки этого файла на каждое расширение платёжного API.
|
||||
Сравнение регистронезависимое: `_PUBLIC_PATHS` (rbac) — точное множество без
|
||||
учёта регистра только у Caddy, не у Python, так что нестандартный регистр
|
||||
пути технически может долететь до обработчика и породить событие.
|
||||
|
||||
Композировать с `scrub_pii_event`/`redact_telegram_bot_token`, а не вместо
|
||||
них — этот шаг закрывает только `request.data`, extra/contexts и
|
||||
traceback-locals остаются на ответственности остальных шагов композиции.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
request = event.get("request")
|
||||
if isinstance(request, dict):
|
||||
url = request.get("url")
|
||||
if isinstance(url, str) and _PAYMENTS_URL_SEGMENT in url.lower():
|
||||
request.pop("data", None)
|
||||
return event
|
||||
|
||||
|
||||
_PUBLIC_API_URL_SEGMENT = "/api/public/"
|
||||
|
||||
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
|
||||
_GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru")
|
||||
|
||||
_ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$")
|
||||
|
||||
|
||||
def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Убрать введённый анонимом адрес из события GlitchTip.
|
||||
|
||||
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес
|
||||
нигде не сохраняется. GlitchTip — внешний сервис, и до этой правки адрес
|
||||
доезжал туда двумя путями (оба воспроизведены состязательным ревью
|
||||
16.08.2026, не выведены из чтения кода):
|
||||
|
||||
1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса,
|
||||
а `send_default_pii=False` этот путь не гейтит — он про куки и IP, не
|
||||
про тело. Тело публичной ручки — это ровно `{"q": "<адрес>"}`.
|
||||
Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет
|
||||
имена вроде `client_phone`, а поле здесь называется `q`.
|
||||
2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration`
|
||||
кладёт URL целиком, а адрес там в query (`?q=Малышева+30`).
|
||||
|
||||
Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а
|
||||
убирать целиком — состав полей задаёт не только наш код (у геокодеров свои
|
||||
параметры), поэтому перечислить безопасное заранее нельзя.
|
||||
|
||||
Композировать с остальными шагами, а не вместо них.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
|
||||
request = event.get("request")
|
||||
if isinstance(request, dict):
|
||||
url = request.get("url")
|
||||
if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower():
|
||||
request.pop("data", None)
|
||||
|
||||
crumbs = event.get("breadcrumbs")
|
||||
values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs
|
||||
if isinstance(values, list):
|
||||
for crumb in values:
|
||||
if not isinstance(crumb, dict):
|
||||
continue
|
||||
data = crumb.get("data")
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
url = data.get("url")
|
||||
if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS):
|
||||
data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
|
|
@ -174,3 +325,61 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
|
|||
if not isinstance(event, dict):
|
||||
return event
|
||||
return _redact_strings(event) # type: ignore[return-value]
|
||||
|
||||
|
||||
# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ────────
|
||||
# tenacity.RetryError.__str__() тащит repr() последнего Future
|
||||
# (`RetryError[<Future at 0x7f... state=finished raised HTTPStatusError>]`) —
|
||||
# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py
|
||||
# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim
|
||||
# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим
|
||||
# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ
|
||||
# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True`
|
||||
# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает
|
||||
# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само
|
||||
# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность
|
||||
# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в
|
||||
# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1).
|
||||
#
|
||||
# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через
|
||||
# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get`
|
||||
# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на
|
||||
# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query
|
||||
# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE`
|
||||
# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН
|
||||
# persistent issue per (culprit, класс исключения-причины) — culprit обязателен:
|
||||
# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр.
|
||||
# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError)
|
||||
# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2
|
||||
# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration`
|
||||
# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр.
|
||||
# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ
|
||||
# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от
|
||||
# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных
|
||||
# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде
|
||||
# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес,
|
||||
# IP, id объявления и т.п.), иначе проблема повторится в других терминах.
|
||||
def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None:
|
||||
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue per
|
||||
(источник, тип причины) — РАЗНЫЕ источники (geocoder / scraper_kit / будущий
|
||||
retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает.
|
||||
|
||||
Определяет тип exception через `hint["exc_info"]` (реальный объект
|
||||
исключения, тот же контракт что sentry_sdk передаёт в before_send) — не
|
||||
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
|
||||
`isinstance` (не сравнение `type(...).__name__` со строкой) — иначе любой
|
||||
посторонний класс с совпадающим именем ложно матчился бы, а подкласс
|
||||
`tenacity.RetryError` — промахивался бы. Не-RetryError события возвращает без
|
||||
изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает).
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
|
||||
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
|
||||
if not isinstance(exc_value, RetryError):
|
||||
return event
|
||||
cause = exc_value.__cause__ or exc_value.__context__
|
||||
cause_type = type(cause).__name__ if cause is not None else "Unknown"
|
||||
culprit = event.get("logger") or event.get("transaction") or "unknown"
|
||||
event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type]
|
||||
return event
|
||||
|
|
|
|||
|
|
@ -44,7 +44,35 @@ if settings.glitchtip_dsn:
|
|||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
from app.observability.sentry_scrub import (
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
|
||||
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
|
||||
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
|
||||
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда — belt-and-
|
||||
suspenders на случай, если платёжные данные когда-нибудь попадут в
|
||||
`request`/`extra`. Тот же обработчик на оба канала ниже — см.
|
||||
app/main.py._before_send (идентичный мотив, не дублировать без причины).
|
||||
|
||||
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
|
||||
следом за платёжным body-wipe: этот процесс гоняет
|
||||
`geocode_missing_listings` (ночной batch, сотни адресов за прогон) —
|
||||
@retry-декорированные Nominatim-хелперы (app/services/geocoder.py) на
|
||||
исчерпанных ретраях исторически плодили по отдельному GlitchTip issue
|
||||
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
|
||||
См. sentry_scrub docstring.
|
||||
"""
|
||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return stabilize_retry_error_fingerprint(scrubbed, hint)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
|
|
@ -52,7 +80,8 @@ if settings.glitchtip_dsn:
|
|||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||
traces_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=scrub_pii_event,
|
||||
before_send=_before_send,
|
||||
before_send_transaction=_before_send,
|
||||
integrations=[
|
||||
SqlalchemyIntegration(),
|
||||
HttpxIntegration(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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.72–0.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.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
|
||||
# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
|
||||
# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
|
||||
# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
|
||||
|
||||
|
||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
|
||||
|
|
@ -1158,14 +1135,39 @@ def _fetch_house_imv_anchor(
|
|||
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
|
||||
|
||||
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
|
||||
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
|
||||
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
|
||||
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
|
||||
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
|
||||
house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress).
|
||||
скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
|
||||
WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
|
||||
None при любой ошибке / отсутствии house_id / пустой таблице — estimator
|
||||
продолжает на гео-tier'ах (no regress).
|
||||
|
||||
СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
|
||||
~2951 домов, fresh» — неправда по обоим пунктам, #2674):
|
||||
2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) —
|
||||
поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из
|
||||
них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после
|
||||
фикса параметров 2026-08-06, всего 47.
|
||||
Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
|
||||
и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
|
||||
объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
|
||||
(46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
|
||||
«панель» вместо «монолита»).
|
||||
|
||||
ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
|
||||
· Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка
|
||||
находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
|
||||
224 из 224, то есть просто выключил бы механизм.
|
||||
· Массовая переоценка — не чинит корень и упирается в очередь: 1628 из
|
||||
2633 домов уже в imv_status='ok', а батч берёт только pending/
|
||||
transient_error, где сейчас 7144 дома при темпе ~25/прогон.
|
||||
· Корень — не свежесть, а разные базисы ремонта; починено в #2677
|
||||
(`_anchor_repair_factor`), см. ниже.
|
||||
|
||||
Returns dict {recommended_price, lower_price, higher_price, market_count,
|
||||
rooms, area_m2} или None.
|
||||
rooms, area_m2, renovation_type} или None.
|
||||
|
||||
#2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
|
||||
Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
|
||||
(см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
|
||||
"""
|
||||
if target_house_id is None:
|
||||
return None
|
||||
|
|
@ -1175,7 +1177,7 @@ def _fetch_house_imv_anchor(
|
|||
text(
|
||||
"""
|
||||
SELECT recommended_price, lower_price, higher_price,
|
||||
market_count, rooms, area_m2
|
||||
market_count, rooms, area_m2, renovation_type
|
||||
FROM house_imv_evaluations
|
||||
WHERE house_id = CAST(:hid AS bigint)
|
||||
AND recommended_price > 0
|
||||
|
|
@ -1357,6 +1359,80 @@ def _lookup_quarter_indexes(
|
|||
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
|
||||
|
||||
|
||||
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
|
||||
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
|
||||
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
|
||||
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
|
||||
# порог ответ выдан чужой квартал точность покрытие
|
||||
# 15 м 475 26 94.5% 19.8%
|
||||
# 25 м 748 59 92.1% 31.2%
|
||||
# 50 м 1341 271 79.8% 55.9%
|
||||
# 100 м 2090 644 69.2% 87.2%
|
||||
# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
|
||||
# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
|
||||
# точность 92%, и это ровно тот компромисс, который видно в замере, а не
|
||||
# заимствованная у соседнего модуля константа.
|
||||
_TARGET_QUARTER_RADIUS_M = 25.0
|
||||
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
|
||||
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
|
||||
_TARGET_QUARTER_DEG_GATE = 0.0015
|
||||
|
||||
|
||||
def _lookup_target_quarter_by_coords(
|
||||
db: Session,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
) -> str | None:
|
||||
"""Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м).
|
||||
|
||||
Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на
|
||||
проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда.
|
||||
Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number`
|
||||
(tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не
|
||||
к координатам аналога.
|
||||
|
||||
Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен
|
||||
только 3-й компонент кадастрового номера (квартал), а он у соседних зданий
|
||||
одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не
|
||||
полный кадастр, чтобы вызывающий код не принял его за кадастр дома.
|
||||
|
||||
None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет
|
||||
или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M.
|
||||
"""
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
try:
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH p AS (
|
||||
SELECT ST_SetSRID(
|
||||
ST_MakePoint(CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)), 4326) AS g
|
||||
)
|
||||
SELECT cb.cad_num
|
||||
FROM cad_buildings_local cb, p
|
||||
WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision))
|
||||
AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision)
|
||||
ORDER BY cb.geom <-> p.g
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"deg": _TARGET_QUARTER_DEG_GATE,
|
||||
"radius": _TARGET_QUARTER_RADIUS_M,
|
||||
},
|
||||
).first()
|
||||
except Exception as exc:
|
||||
logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc)
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
return _quarter_from_cadastre(str(row[0]))
|
||||
|
||||
|
||||
def _apply_quarter_index(
|
||||
*,
|
||||
base_median_ppm2: float,
|
||||
|
|
@ -1491,7 +1567,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
|
|||
"""#794: monthly {period_month: index_value} for region from sber_price_index.
|
||||
|
||||
Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error.
|
||||
#audit-5a: если latest месяц серии старее sber_index_max_age_days → warning.
|
||||
|
||||
#2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest
|
||||
периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по
|
||||
построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на
|
||||
закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых
|
||||
наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени —
|
||||
нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip
|
||||
(event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor,
|
||||
и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт),
|
||||
а не календарь.
|
||||
"""
|
||||
for dash in SBER_COEFF_DASHBOARDS:
|
||||
try:
|
||||
|
|
@ -1519,21 +1604,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
|
|||
series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
|
||||
if not series:
|
||||
continue
|
||||
# #audit-5a: data-age guard — предупреждаем о stale СберИндексе.
|
||||
latest = max(series)
|
||||
today = datetime.now(tz=UTC).date()
|
||||
age_days = (today - latest).days
|
||||
if age_days > settings.sber_index_max_age_days:
|
||||
logger.warning(
|
||||
"sber_index stale #audit-5a: latest=%s age=%d days"
|
||||
" (> sber_index_max_age_days=%d) region=%s dash=%s"
|
||||
" — time-adjustment may be outdated",
|
||||
latest.isoformat(),
|
||||
age_days,
|
||||
settings.sber_index_max_age_days,
|
||||
region,
|
||||
dash,
|
||||
)
|
||||
return series
|
||||
return {}
|
||||
|
||||
|
|
@ -1698,6 +1768,10 @@ def _fetch_dkp_corridor(
|
|||
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
|
||||
adjusted: list[float] = []
|
||||
factors_applied: list[float] = []
|
||||
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
|
||||
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
|
||||
# заявленную свежесть коридора, не участвуя в его границах.
|
||||
latest_deal: date | None = None
|
||||
for r in rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1709,6 +1783,8 @@ def _fetch_dkp_corridor(
|
|||
factor = _sber_time_factor(series, deal_month)
|
||||
adjusted.append(float(ppm2) * factor)
|
||||
factors_applied.append(factor)
|
||||
if dd is not None and (latest_deal is None or dd > latest_deal):
|
||||
latest_deal = dd
|
||||
ppm2_values = sorted(adjusted)
|
||||
|
||||
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
||||
|
|
@ -1761,6 +1837,7 @@ def _fetch_dkp_corridor(
|
|||
city_rows = []
|
||||
|
||||
city_adjusted: list[float] = []
|
||||
city_latest: date | None = None
|
||||
for r in city_rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1771,6 +1848,8 @@ def _fetch_dkp_corridor(
|
|||
deal_month = date(dd.year, dd.month, 1)
|
||||
factor = _sber_time_factor(series, deal_month)
|
||||
city_adjusted.append(float(ppm2) * factor)
|
||||
if dd is not None and (city_latest is None or dd > city_latest):
|
||||
city_latest = dd
|
||||
|
||||
if len(city_adjusted) > len(ppm2_values):
|
||||
logger.info(
|
||||
|
|
@ -1781,6 +1860,10 @@ def _fetch_dkp_corridor(
|
|||
city,
|
||||
)
|
||||
ppm2_values = sorted(city_adjusted)
|
||||
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||
# выборки, которую на витрине уже никто не видит.
|
||||
latest_deal = city_latest
|
||||
|
||||
if not ppm2_values:
|
||||
return None
|
||||
|
|
@ -1803,6 +1886,7 @@ def _fetch_dkp_corridor(
|
|||
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
||||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||
"period_months": period_months,
|
||||
"latest_deal_date": latest_deal,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2677,6 +2761,9 @@ def _price_from_inputs(
|
|||
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
|
||||
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
|
||||
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
|
||||
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
|
||||
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
|
||||
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
|
||||
target_house_cadnum: str | None,
|
||||
dadata_coarse: bool,
|
||||
geo: GeocodeResult,
|
||||
|
|
@ -3029,6 +3116,7 @@ def _price_from_inputs(
|
|||
anchor_total: int | None = None
|
||||
anchor_higher: int | None = None
|
||||
anchor_label: str | None = None
|
||||
anchor_repair_note: str = ""
|
||||
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
||||
anchor_total = int(imv_anchor["recommended_price"])
|
||||
anchor_higher = (
|
||||
|
|
@ -3047,6 +3135,22 @@ def _price_from_inputs(
|
|||
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
|
||||
),
|
||||
)
|
||||
# #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
|
||||
# не правим), а в blend уходит якорь, приведённый к базису ремонта target'а —
|
||||
# тому же, в котором median_price уже посчитан выше (repair_coef).
|
||||
_rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
|
||||
if _rep_k != 1.0:
|
||||
anchor_total = round(anchor_total * _rep_k)
|
||||
anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
|
||||
anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
|
||||
logger.info(
|
||||
"imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
|
||||
" → anchor %d",
|
||||
imv_anchor.get("renovation_type"),
|
||||
repair_state,
|
||||
_rep_k,
|
||||
anchor_total,
|
||||
)
|
||||
elif imv_eval is not None and imv_eval.recommended_price:
|
||||
anchor_total = int(imv_eval.recommended_price)
|
||||
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
|
||||
|
|
@ -3096,8 +3200,8 @@ def _price_from_inputs(
|
|||
median_price = new_median
|
||||
median_ppm2 = new_ppm2
|
||||
explanation = (explanation or "") + (
|
||||
f" Оценка скорректирована по {anchor_label} "
|
||||
f"({anchor_used / 1_000_000:.1f} млн ₽)."
|
||||
f" Оценка скорректирована по {anchor_label}"
|
||||
f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
|
||||
)
|
||||
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
|
||||
# Диапазон расширяем даже если медиану не двигали.
|
||||
|
|
@ -3128,13 +3232,16 @@ def _price_from_inputs(
|
|||
and median_price > 0
|
||||
and area_m2
|
||||
):
|
||||
# target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по
|
||||
# координатам цели (_lookup_target_quarter_by_coords, вызывающий код).
|
||||
# Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» —
|
||||
# снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал
|
||||
# цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м
|
||||
# от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза
|
||||
# по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой).
|
||||
# Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя
|
||||
# заменить чужим кварталом, он и есть предмет поправки.
|
||||
target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum)
|
||||
if target_quarter is None:
|
||||
for lot in listings_clean:
|
||||
cq = _quarter_from_cadastre(lot.get("building_cadastral_number"))
|
||||
if cq is not None:
|
||||
target_quarter = cq
|
||||
break
|
||||
|
||||
if target_quarter is not None:
|
||||
qindex_result = quarter_index_lookup(target_quarter)
|
||||
|
|
@ -4221,6 +4328,22 @@ async def estimate_quality(
|
|||
# ── Coarse-geo signals ────────────────────────────────────────────────────
|
||||
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
|
||||
|
||||
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
|
||||
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
|
||||
# квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
|
||||
# сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
|
||||
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
|
||||
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
|
||||
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
|
||||
if (
|
||||
settings.estimate_quarter_from_coords_enabled
|
||||
and not target_quarter_cadnum
|
||||
and geo is not None
|
||||
):
|
||||
target_quarter_cadnum = await asyncio.to_thread(
|
||||
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
|
||||
)
|
||||
|
||||
# ── DB-callable wrappers injected into pure pricing ───────────────────────
|
||||
def _ratio_resolver(
|
||||
appm2: float | None,
|
||||
|
|
@ -4265,7 +4388,7 @@ async def estimate_quality(
|
|||
ratio_resolver=_ratio_resolver,
|
||||
quarter_index_lookup=_qi_lookup,
|
||||
quarter_indexes_lookup=_qis_lookup,
|
||||
target_house_cadnum=dadata.house_cadnum if dadata else None,
|
||||
target_house_cadnum=target_quarter_cadnum,
|
||||
dadata_coarse=dadata_coarse,
|
||||
geo=geo,
|
||||
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
||||
|
|
@ -4660,6 +4783,7 @@ async def estimate_quality(
|
|||
target_lat=geo.lat,
|
||||
target_lon=geo.lon,
|
||||
target_city_ambiguous=geo.city_ambiguous,
|
||||
target_address_refined=geo.address_refined,
|
||||
sources_used=sources_used,
|
||||
data_freshness_minutes=freshness_min,
|
||||
last_scraped_at=last_scraped_at,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
6→4pt, padding-top 8→6pt, line-height мета/wordmark строк 1.35→1.15,
|
||||
градиент-разделитель margin 6pt 0→3pt 0 — экономия ~13pt), И минимально
|
||||
поднять @page margin-bottom (19mm→21mm, +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%» на обложке), а не хардкод «10–18%»,
|
||||
|
|
@ -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 19mm→21mm (#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; }}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,22 @@ class GeocodeResult:
|
|||
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
|
||||
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
|
||||
city_ambiguous: bool = False
|
||||
# #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses`
|
||||
# (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/
|
||||
# cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы
|
||||
# («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует
|
||||
# корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по
|
||||
# нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён
|
||||
# автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`.
|
||||
# Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный
|
||||
# источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4),
|
||||
# поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого
|
||||
# адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) —
|
||||
# если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined`
|
||||
# вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см.
|
||||
# `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не
|
||||
# из кэша).
|
||||
address_refined: bool = False
|
||||
|
||||
|
||||
# ── EKB bounding boxes ───────────────────────────────────────────────────────
|
||||
|
|
@ -372,6 +388,134 @@ def _names_unrecognized_locality(address: str) -> bool:
|
|||
return bool(_LOCALITY_MARKER_RE.search(normalized))
|
||||
|
||||
|
||||
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
|
||||
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
|
||||
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
|
||||
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
|
||||
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
|
||||
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
|
||||
# использовал, и если названный топоним туда не дожил — топоним подменён.
|
||||
#
|
||||
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
|
||||
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
|
||||
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
|
||||
# сам целевой город.
|
||||
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
|
||||
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
|
||||
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
|
||||
# списка городов области, ради ухода от которого всё и делается).
|
||||
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
|
||||
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
|
||||
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
|
||||
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
|
||||
_STREET_LEVEL_RE = re.compile(
|
||||
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
|
||||
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
|
||||
r"|корпус|лит|литера|снт|сад|гск)\b"
|
||||
)
|
||||
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
|
||||
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
|
||||
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
|
||||
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
|
||||
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
|
||||
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
|
||||
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
|
||||
_LOCALITY_TYPE_WORDS = frozenset(
|
||||
{
|
||||
"поселок",
|
||||
"пос",
|
||||
"село",
|
||||
"деревня",
|
||||
"дер",
|
||||
"город",
|
||||
"гор",
|
||||
"округ",
|
||||
"муниципальный",
|
||||
"городской",
|
||||
"сельское",
|
||||
"поселение",
|
||||
"тер",
|
||||
"территория",
|
||||
"станция",
|
||||
"пгт",
|
||||
"рп",
|
||||
}
|
||||
)
|
||||
_WORD_RE = re.compile(r"[а-я][а-я-]*")
|
||||
|
||||
|
||||
def _fold(value: str) -> str:
|
||||
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
|
||||
return " ".join(value.lower().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _claimed_locality(address: str) -> str | None:
|
||||
"""Имя населённого пункта, названное в тексте адреса, или None.
|
||||
|
||||
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
|
||||
(страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже
|
||||
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП.
|
||||
|
||||
None означает «НП не назван» — это основной трафик формы («Малышева 30»), и
|
||||
для него инвариант не применяется вовсе.
|
||||
"""
|
||||
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
|
||||
return None # улица/дом: будь НП назван, он шёл бы раньше
|
||||
if (
|
||||
_COUNTRY_RE.search(segment)
|
||||
or _REGION_LEVEL_RE.search(segment)
|
||||
or _DISTRICT_LEVEL_RE.search(segment)
|
||||
):
|
||||
continue
|
||||
name = " ".join(
|
||||
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
|
||||
)
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _city_substituted(address: str, result: GeocodeResult) -> bool:
|
||||
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
|
||||
|
||||
Три условия вместе:
|
||||
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
|
||||
2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не
|
||||
пережил геокодинг;
|
||||
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
|
||||
собственному ответу провайдера — он называет Екатеринбург либо не
|
||||
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
|
||||
тогда «внутри ЕКБ» подтверждают координаты).
|
||||
|
||||
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
|
||||
Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило,
|
||||
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
|
||||
корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex
|
||||
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
|
||||
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
|
||||
|
||||
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂
|
||||
«М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим —
|
||||
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
|
||||
приставки («пионерский, советская»), понижается до `locality` — честная
|
||||
деградация, координаты не теряются.
|
||||
"""
|
||||
claimed = _claimed_locality(address)
|
||||
if claimed is None or _EKATERINBURG_RE.search(claimed):
|
||||
return False
|
||||
answer = _fold(result.full_address or "")
|
||||
if any(word in answer for word in claimed.split()):
|
||||
return False # топоним дожил до ответа — провайдер искал там, где просили
|
||||
if not is_within_ekb_bbox(result.lat, result.lon):
|
||||
return False
|
||||
answer_locality = _claimed_locality(answer)
|
||||
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
|
||||
|
||||
|
||||
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
|
||||
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
|
||||
|
||||
|
|
@ -598,7 +742,23 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
|||
return oblast_fallback
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
|
||||
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
|
||||
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
|
||||
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
|
||||
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
|
||||
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
|
||||
# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError
|
||||
# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query
|
||||
# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на
|
||||
# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py
|
||||
# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет
|
||||
# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review
|
||||
# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion).
|
||||
# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай
|
||||
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
||||
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||||
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||
|
||||
|
|
@ -797,7 +957,8 @@ async def _nominatim_query_city_aware(
|
|||
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||||
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
|
||||
async def _nominatim_suggest(
|
||||
query: str, limit: int = 8, city_hint: str | None = None
|
||||
) -> list[GeocodeSuggestion]:
|
||||
|
|
@ -1060,12 +1221,13 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
|||
|
||||
ВНИМАНИЕ, цепочки различаются — не путать:
|
||||
* `geocode()` : geoportal → cadastral → `_cadastral_forward_sync`
|
||||
→ Nominatim → None. Тира DaData тут НЕТ.
|
||||
→ Nominatim → `_local_houses_match` (#2626, houses-фолбэк)
|
||||
→ None. Тира DaData тут НЕТ.
|
||||
* `suggest()` : cadastral → DaData → Nominatim (единственный вызов
|
||||
`_dadata_suggest`).
|
||||
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
|
||||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None —
|
||||
оценка не строится. Это сознательный выбор: честный отказ вместо
|
||||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку,
|
||||
даёт None — оценка не строится. Это сознательный выбор: честный отказ вместо
|
||||
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет —
|
||||
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` →
|
||||
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
|
||||
|
|
@ -1182,6 +1344,283 @@ def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
|||
)
|
||||
|
||||
|
||||
# ── Local `houses` fallback (#2626) — последний тир geocode() ───────────────
|
||||
# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов,
|
||||
# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses):
|
||||
# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1»
|
||||
# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»);
|
||||
# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР),
|
||||
# пользователь пишет только последнее слово имени.
|
||||
# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с
|
||||
# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не
|
||||
# резолвят, а houses чаще содержит именно то написание, которым реально пользуются
|
||||
# люди (агрегировано из объявлений, а не из официального ГАР).
|
||||
#
|
||||
# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/
|
||||
# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата
|
||||
# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`.
|
||||
# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем
|
||||
# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house`
|
||||
# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже).
|
||||
_LOCAL_HOUSE_TOKEN_RE = re.compile(
|
||||
r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _norm_local_house(raw: str) -> str:
|
||||
"""Канон номера дома для houses-фолбэка.
|
||||
|
||||
«49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»; «35А» → «35а».
|
||||
"""
|
||||
s = raw.strip().lower()
|
||||
s = re.sub(r"\s+", "", s)
|
||||
s = re.sub(r"корпус|корп\.?", "к", s)
|
||||
s = re.sub(r"-(к\d+)", r"\1", s)
|
||||
s = re.sub(r"-([а-яё])$", r"\1", s)
|
||||
return s
|
||||
|
||||
|
||||
# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж.
|
||||
# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут
|
||||
# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE`
|
||||
# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки
|
||||
# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер
|
||||
# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая,
|
||||
# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15».
|
||||
_RE_LOCAL_APT_TAIL = re.compile(
|
||||
r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_local_house_token(address: str) -> str | None:
|
||||
"""Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов,
|
||||
которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у
|
||||
`_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение — номер дома в русском
|
||||
адресе почти всегда в хвосте строки. None, если цифр нет вовсе.
|
||||
|
||||
Квартирный/этажный/подъездный хвост зачищается ДО поиска номера
|
||||
(`_RE_LOCAL_APT_TAIL`) — иначе «последнее число в строке» это номер
|
||||
квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`).
|
||||
"""
|
||||
s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.")
|
||||
if not s:
|
||||
return None
|
||||
s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.")
|
||||
if not s:
|
||||
return None
|
||||
matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s))
|
||||
if not matches:
|
||||
return None
|
||||
return _norm_local_house(matches[-1].group(1))
|
||||
|
||||
|
||||
# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением
|
||||
# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого
|
||||
# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё
|
||||
# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ
|
||||
# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа.
|
||||
# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом
|
||||
# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b`
|
||||
# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) —
|
||||
# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .»)
|
||||
# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses).
|
||||
_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _clean_local_house_street(segment: str) -> str:
|
||||
"""«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» /
|
||||
«начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы.
|
||||
|
||||
Общая нормализация и для запроса пользователя (уже typeless из
|
||||
`_parse_street_house`, но повторный проход — no-op), и для `houses.address`.
|
||||
"""
|
||||
s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower())
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _row_local_house(address: str) -> tuple[str, str] | None:
|
||||
"""Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm).
|
||||
|
||||
Номер дома — ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1»,
|
||||
«X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки) —
|
||||
покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор
|
||||
после номера отбрасывается). Известный неполный случай (не встретился в
|
||||
выборке): номер дома БЕЗ запятой перед ним — вернёт None, строка просто не
|
||||
станет кандидатом (не ложный матч).
|
||||
"""
|
||||
segments = [s.strip() for s in address.split(",") if s.strip()]
|
||||
if len(segments) < 2:
|
||||
return None
|
||||
m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1])
|
||||
if not m:
|
||||
return None
|
||||
house_norm = _norm_local_house(m.group(1))
|
||||
street_norm = _clean_local_house_street(" ".join(segments[:-1]))
|
||||
if not street_norm or not house_norm:
|
||||
return None
|
||||
return street_norm, house_norm
|
||||
|
||||
|
||||
def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool:
|
||||
"""True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы
|
||||
в `houses` — «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя),
|
||||
регистронезависимо. Точное равенство тоже проходит (частый случай — короткие
|
||||
однословные улицы, «Малышева» == «Малышева»)."""
|
||||
return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm)
|
||||
|
||||
|
||||
# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер,
|
||||
# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) —
|
||||
# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у
|
||||
# нас в вводе просто нет данных, какой именно».
|
||||
_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$")
|
||||
|
||||
|
||||
def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
|
||||
"""Последний локальный тир `geocode()` (#2626) — fallback на `houses`
|
||||
(скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW).
|
||||
|
||||
Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата.
|
||||
Допущения, все defensive (при неоднозначности — None, не гадаем):
|
||||
|
||||
1. Улица матчится «по хвосту» (`_street_tail_matches`) — ловит расхождение
|
||||
разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в
|
||||
houses («Начдива Онуфриева»).
|
||||
2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox
|
||||
(`is_within_ekb_bbox_wide`) — `houses` НЕ ЕКБ-only реестр (в отличие от
|
||||
geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ,
|
||||
местами вплоть до другого региона (#2626 review R2 #2 — прод-пример
|
||||
«улица Маяковского, 7» в houses это Серов, а не запрошенный
|
||||
Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС
|
||||
пользователя, не страхует от грязной строки-источника.
|
||||
3. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый
|
||||
случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но
|
||||
ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же
|
||||
номера («24к2», «24/2» и т.п.) — иначе «к1» такая же угадайка, как и
|
||||
любой другой корпус, и реальные дома могут быть в 250-400м друг от друга
|
||||
(#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3
|
||||
— три разных здания).
|
||||
4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым
|
||||
координатам — разные source-строки ОДНОГО дома не в счёт), возвращает
|
||||
None — угадывать нельзя.
|
||||
|
||||
SQL — дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на
|
||||
`houses.address`, но тир последний и редкий — не на каждый запрос) с
|
||||
детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо
|
||||
выбирал бы, какая из двух ~идентичных source-строк станет ответом —
|
||||
#2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство
|
||||
номера) — в Python, что и делает её юнит-тестируемой без реальной БД
|
||||
(см. `test_geocoder_local_houses_fallback.py`).
|
||||
|
||||
Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной
|
||||
(см. `geocode()`) — `houses`-координаты из скрейпленных объявлений менее
|
||||
надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный
|
||||
(#2626 review R2 #4).
|
||||
"""
|
||||
query_street_norm = _clean_local_house_street(street)
|
||||
if not query_street_norm:
|
||||
return None
|
||||
query_house_norm = _norm_local_house(house)
|
||||
if not query_house_norm:
|
||||
return None
|
||||
last_word = query_street_norm.split()[-1]
|
||||
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT address, lat, lon
|
||||
FROM houses
|
||||
WHERE address ILIKE CAST('%' || :w || '%' AS text)
|
||||
AND lat IS NOT NULL AND lon IS NOT NULL
|
||||
ORDER BY address, id
|
||||
"""),
|
||||
{"w": last_word},
|
||||
).fetchall()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"local houses fallback query failed for street=%r house=%r",
|
||||
street,
|
||||
house,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
# Street-tail + bbox фильтр — один проход, дальше переиспользуется и для
|
||||
# точного совпадения, и для corpus-1 догадки, и для sibling-guard.
|
||||
street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr)
|
||||
for r in rows:
|
||||
parsed = _row_local_house(str(r.address or ""))
|
||||
if parsed is None:
|
||||
continue
|
||||
row_street_norm, row_house_norm = parsed
|
||||
if not _street_tail_matches(row_street_norm, query_street_norm):
|
||||
continue
|
||||
lat, lon = float(r.lat), float(r.lon)
|
||||
if not is_within_ekb_bbox_wide(lat, lon):
|
||||
continue
|
||||
street_rows.append((row_house_norm, lat, lon, str(r.address)))
|
||||
|
||||
def _candidates(house_norm: str) -> list[tuple[str, float, float]]:
|
||||
out: list[tuple[str, float, float]] = []
|
||||
seen_coords: set[tuple[float, float]] = set()
|
||||
for row_house_norm, lat, lon, addr in street_rows:
|
||||
if row_house_norm != house_norm:
|
||||
continue
|
||||
coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников
|
||||
if coord_key in seen_coords:
|
||||
continue
|
||||
seen_coords.add(coord_key)
|
||||
out.append((addr, lat, lon))
|
||||
return out
|
||||
|
||||
exact = _candidates(query_house_norm)
|
||||
if len(exact) == 1:
|
||||
addr, lat, lon = exact[0]
|
||||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||||
if len(exact) > 1:
|
||||
logger.info(
|
||||
"local houses fallback: %d неоднозначных кандидата для %r %r — skip",
|
||||
len(exact),
|
||||
street,
|
||||
house,
|
||||
)
|
||||
return None
|
||||
|
||||
# Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО
|
||||
# если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»).
|
||||
if query_house_norm.isdigit():
|
||||
corpus1 = f"{query_house_norm}к1"
|
||||
siblings = {
|
||||
row_house_norm
|
||||
for row_house_norm, _lat, _lon, _addr in street_rows
|
||||
if row_house_norm != corpus1
|
||||
and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None
|
||||
and m.group(1) == query_house_norm
|
||||
}
|
||||
if siblings:
|
||||
logger.info(
|
||||
"local houses fallback: корпус-1 %r неоднозначен — есть другие "
|
||||
"корпуса/дроби %s — skip",
|
||||
corpus1,
|
||||
sorted(siblings),
|
||||
)
|
||||
return None
|
||||
guessed = _candidates(corpus1)
|
||||
if len(guessed) == 1:
|
||||
addr, lat, lon = guessed[0]
|
||||
logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr)
|
||||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||||
if len(guessed) > 1:
|
||||
logger.info(
|
||||
"local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip",
|
||||
corpus1,
|
||||
len(guessed),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
|
||||
"""Reverse lookup via gendesign_cad_buildings FDW.
|
||||
|
||||
|
|
@ -1287,6 +1726,42 @@ async def suggest(
|
|||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
||||
|
||||
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
||||
применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники,
|
||||
включая попадание в кэш: отравленная запись, записанная до этого фикса,
|
||||
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
|
||||
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
|
||||
|
||||
Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`.
|
||||
`locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его
|
||||
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
|
||||
оценки, (б) через `tasks.geocode_missing` проставляет листингу
|
||||
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
|
||||
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
|
||||
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
||||
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
||||
видимая, а не отказ.
|
||||
"""
|
||||
result = await _geocode_resolve(address, db, city_hint)
|
||||
if result is None or not _city_substituted(address, result):
|
||||
return result
|
||||
logger.warning(
|
||||
"geocode city substitution (#2590): %r → %r (%.5f, %.5f) provider=%s — "
|
||||
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
|
||||
address[:80],
|
||||
(result.full_address or "")[:80],
|
||||
result.lat,
|
||||
result.lon,
|
||||
result.provider,
|
||||
)
|
||||
return replace(result, confidence="locality", city_ambiguous=True)
|
||||
|
||||
|
||||
async def _geocode_resolve(
|
||||
address: str, db: Session, city_hint: str | None = None
|
||||
) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||||
|
||||
Args:
|
||||
|
|
@ -1422,6 +1897,45 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
|||
except Exception:
|
||||
logger.exception("nominatim geocoder failed")
|
||||
|
||||
# 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None.
|
||||
# 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом
|
||||
# был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не
|
||||
# резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий
|
||||
# в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у
|
||||
# geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск
|
||||
# коллизии улица+дом с другим городом региона, что и мотивировал #2582);
|
||||
# координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри
|
||||
# `_local_houses_match` (гейт здесь фильтрует только запрос пользователя,
|
||||
# не грязь в самой таблице — #2626 review R2 #2).
|
||||
if use_local_ekb and parsed is not None:
|
||||
local_street, _parsed_house = parsed
|
||||
local_house = _extract_local_house_token(address) or _parsed_house
|
||||
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
|
||||
if hit is not None:
|
||||
result = GeocodeResult(
|
||||
lat=hit.lat,
|
||||
lon=hit.lon,
|
||||
full_address=hit.full_address,
|
||||
provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal
|
||||
confidence="exact",
|
||||
city_ambiguous=city_ambiguous,
|
||||
address_refined=True,
|
||||
)
|
||||
# НЕ кэшируем: houses-координаты (скрейпленные листинги) менее
|
||||
# надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый
|
||||
# и локальный — кэш только продлевал бы жизнь возможной ошибке
|
||||
# источника (#2626 review R2 #4). Побочный эффект: `address_refined`
|
||||
# переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не
|
||||
# только первый (было известным пределом до этого фикса).
|
||||
logger.info(
|
||||
"geocode local houses fallback: %s → (%.5f, %.5f) [%s]",
|
||||
addr_norm,
|
||||
result.lat,
|
||||
result.lon,
|
||||
hit.full_address,
|
||||
)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -1490,7 +2004,8 @@ def _format_reverse_address(addr: dict) -> str | None:
|
|||
return ", ".join(parts)
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||||
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
|
||||
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
|
||||
|
||||
|
|
|
|||
|
|
@ -411,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
|||
UPDATE houses
|
||||
SET imv_status = 'ok',
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = NULL
|
||||
imv_error_reason = NULL,
|
||||
imv_transient_attempts = 0
|
||||
WHERE id = :hid
|
||||
"""),
|
||||
{"hid": house_id},
|
||||
|
|
@ -424,12 +425,19 @@ def _mark_status(
|
|||
status: str,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
# #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом
|
||||
# падал по временной причине», а не «сколько раз его трогали». no_params /
|
||||
# no_address / not_found счётчик не двигают: они не занимают retry-слот.
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE houses
|
||||
SET imv_status = :s,
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = :r
|
||||
imv_error_reason = :r,
|
||||
imv_transient_attempts = CASE
|
||||
WHEN :s = 'transient_error' THEN imv_transient_attempts + 1
|
||||
ELSE imv_transient_attempts
|
||||
END
|
||||
WHERE id = :hid
|
||||
"""),
|
||||
{"hid": house_id, "s": status, "r": reason},
|
||||
|
|
@ -443,6 +451,104 @@ _IMVStatus = Literal[
|
|||
"ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error"
|
||||
]
|
||||
|
||||
# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем
|
||||
# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08)
|
||||
# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039
|
||||
# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на
|
||||
# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует,
|
||||
# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно.
|
||||
# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status=
|
||||
# 'transient_error' и считаются как
|
||||
# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3.
|
||||
_MAX_TRANSIENT_ATTEMPTS = 3
|
||||
|
||||
# Доля пакета под повтор transient_error. Половина — потому что остальные слоты
|
||||
# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке
|
||||
# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер
|
||||
# головы очереди на 12.08), так что pending на половине пакета всё равно идёт
|
||||
# быстрее, чем на целом до правки.
|
||||
_RETRY_SLOTS_SHARE = 0.5
|
||||
|
||||
# Дом без пригодных параметров backfill всё равно пометит no_params — но только
|
||||
# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним
|
||||
# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ
|
||||
# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»).
|
||||
# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по
|
||||
# imv_error_reason продолжают работать.
|
||||
# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё
|
||||
# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом.
|
||||
# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы
|
||||
# поштучный путь.
|
||||
_PREMARK_UNUSABLE_SQL = text("""
|
||||
WITH unusable AS (
|
||||
SELECT h.id,
|
||||
CASE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM listings l
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.rooms IS NOT NULL
|
||||
AND l.area_m2 IS NOT NULL)
|
||||
THEN 'no listings with rooms+area'
|
||||
ELSE 'unknown house_type'
|
||||
END AS reason
|
||||
FROM houses h
|
||||
WHERE h.imv_status = ANY(CAST(:statuses AS text[]))
|
||||
AND h.lat IS NOT NULL
|
||||
AND h.lon IS NOT NULL
|
||||
AND h.address IS NOT NULL
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM listings l
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.rooms IS NOT NULL
|
||||
AND l.area_m2 IS NOT NULL)
|
||||
OR COALESCE(
|
||||
NULLIF(TRIM((
|
||||
SELECT mode() WITHIN GROUP (ORDER BY l.house_type)
|
||||
FROM listings l
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.rooms IS NOT NULL
|
||||
AND l.area_m2 IS NOT NULL)), ''),
|
||||
NULLIF(TRIM(h.house_type), '')
|
||||
) IS NULL
|
||||
)
|
||||
)
|
||||
UPDATE houses
|
||||
SET imv_status = 'no_params',
|
||||
last_imv_attempt_at = NOW(),
|
||||
imv_error_reason = unusable.reason
|
||||
FROM unusable
|
||||
WHERE houses.id = unusable.id
|
||||
""")
|
||||
|
||||
# Основная очередь: один статус, как и было (only_status — публичный параметр
|
||||
# admin-API, семантику не трогаем).
|
||||
_QUEUE_SQL = text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE imv_status = :status
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND address IS NOT NULL
|
||||
ORDER BY last_imv_attempt_at NULLS FIRST, id
|
||||
LIMIT :batch
|
||||
""")
|
||||
|
||||
# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending
|
||||
# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST
|
||||
# transient_error не попал бы в пакет, пока не кончится pending (по замеру
|
||||
# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход.
|
||||
_RETRY_QUEUE_SQL = text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE imv_status = 'transient_error'
|
||||
AND imv_transient_attempts < :max_attempts
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND address IS NOT NULL
|
||||
ORDER BY last_imv_attempt_at NULLS FIRST, id
|
||||
LIMIT :batch
|
||||
""")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HouseIMVBackfillResult:
|
||||
|
|
@ -452,6 +558,23 @@ class HouseIMVBackfillResult:
|
|||
errors: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
status_counts: dict[str, int] = field(default_factory=dict)
|
||||
# #2674: сколько домов пакета пришло из retry-очереди transient_error и
|
||||
# сколько помечено no_params до пакета (без запроса к площадке).
|
||||
retried: int = 0
|
||||
premarked: int = 0
|
||||
|
||||
|
||||
def _premark_unusable(db: Session, statuses: list[str]) -> int:
|
||||
"""Пометить no_params дома, по которым запрос к площадке невозможен. → сколько.
|
||||
|
||||
Не новое поведение, а тот же вердикт _process_one_house одним запросом: на
|
||||
12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area,
|
||||
1812 без house_type) — каждый занимал слот пакета и паузу, чтобы получить
|
||||
ответ, который виден в SQL.
|
||||
"""
|
||||
res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses})
|
||||
db.commit()
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
|
||||
def _beat(heartbeat: Callable[[], None] | None) -> None:
|
||||
|
|
@ -480,7 +603,11 @@ async def backfill_house_imv(
|
|||
batch_size: max houses to process (ignored when house_id given).
|
||||
request_delay_sec: sleep between Avito API calls (default 5s — anti-bot).
|
||||
only_status: process houses with this imv_status (default 'pending').
|
||||
Use 'transient_error' to retry failures.
|
||||
Use 'transient_error' to retry failures. При значении по умолчанию
|
||||
часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор
|
||||
transient_error с непотраченным лимитом попыток (#2674) — явно
|
||||
переданный only_status этот проход отключает, оператор получает
|
||||
ровно то, что попросил, включая исчерпавшие лимит дома.
|
||||
house_id: process a single specific house (debug).
|
||||
heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES
|
||||
домов — caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies
|
||||
|
|
@ -509,23 +636,45 @@ async def backfill_house_imv(
|
|||
.all()
|
||||
)
|
||||
else:
|
||||
rows = (
|
||||
# Повторный проход только на расписании (only_status по умолчанию): явный
|
||||
# only_status от оператора — это ручной запрос ровно одного статуса.
|
||||
retry_lane = only_status == "pending"
|
||||
|
||||
statuses = [only_status] + (["transient_error"] if retry_lane else [])
|
||||
result.premarked = _premark_unusable(db, statuses)
|
||||
if result.premarked:
|
||||
logger.info(
|
||||
"house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area "
|
||||
"или house_type) — слоты пакета не потрачены",
|
||||
result.premarked,
|
||||
)
|
||||
|
||||
retry_rows: list = []
|
||||
if retry_lane:
|
||||
retry_rows = (
|
||||
db.execute(
|
||||
_RETRY_QUEUE_SQL,
|
||||
{
|
||||
"max_attempts": _MAX_TRANSIENT_ATTEMPTS,
|
||||
"batch": int(batch_size * _RETRY_SLOTS_SHARE),
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
result.retried = len(retry_rows)
|
||||
|
||||
# Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на
|
||||
# 12.08) возвращается pending — пакет не простаивает.
|
||||
fresh_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT id, address, full_address, lat, lon
|
||||
FROM houses
|
||||
WHERE imv_status = :status
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
AND address IS NOT NULL
|
||||
ORDER BY last_imv_attempt_at NULLS FIRST, id
|
||||
LIMIT :batch
|
||||
"""),
|
||||
{"status": only_status, "batch": batch_size},
|
||||
_QUEUE_SQL,
|
||||
{"status": only_status, "batch": max(batch_size - result.retried, 0)},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
rows = list(fresh_rows) + list(retry_rows)
|
||||
|
||||
result.checked = len(rows)
|
||||
if not rows:
|
||||
|
|
@ -534,9 +683,11 @@ async def backfill_house_imv(
|
|||
return result
|
||||
|
||||
logger.info(
|
||||
"house_imv_backfill: %d houses (status=%r delay=%.1fs)",
|
||||
"house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)",
|
||||
result.checked,
|
||||
only_status,
|
||||
result.retried,
|
||||
result.premarked,
|
||||
request_delay_sec,
|
||||
)
|
||||
|
||||
|
|
@ -610,11 +761,14 @@ async def backfill_house_imv(
|
|||
|
||||
result.duration_sec = time.time() - t0
|
||||
logger.info(
|
||||
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s",
|
||||
"house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d "
|
||||
"retried=%d premarked=%d %.1fs %s",
|
||||
result.checked,
|
||||
result.saved,
|
||||
result.skipped,
|
||||
result.errors,
|
||||
result.retried,
|
||||
result.premarked,
|
||||
result.duration_sec,
|
||||
result.status_counts,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ async def _job_deactivate_stale(
|
|||
) -> None:
|
||||
from app.core.config import settings as _settings
|
||||
from app.tasks.deactivate_stale_avito import (
|
||||
CAP_MULT,
|
||||
DEFAULT_MIN_CONFIRMATIONS,
|
||||
DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
deactivate_stale_listings,
|
||||
|
|
@ -236,6 +237,16 @@ async def _job_deactivate_stale(
|
|||
revisit_floor_quantile: float = params.get(
|
||||
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
|
||||
)
|
||||
# Пустой (NULL) listing_segment -- легаси-строки до миграции 011 + жертвы
|
||||
# отсутствующего COALESCE в ON CONFLICT (base.py upsert никогда не перезаписывает
|
||||
# listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не
|
||||
# элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py.
|
||||
null_segment_only: bool = params.get("null_segment_only", False)
|
||||
# Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель,
|
||||
# а не голая константа: источник с непропорционально длинным хвостом переобхода
|
||||
# относительно своего ttl_days переопределяет его через default_params (ключ
|
||||
# "cap_mult"), не трогая дефолт для остальных источников.
|
||||
cap_mult: float = params.get("cap_mult", CAP_MULT)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
|
|
@ -249,6 +260,8 @@ async def _job_deactivate_stale(
|
|||
staleness_column=staleness_column,
|
||||
min_confirmations=min_confirmations,
|
||||
revisit_floor_quantile=revisit_floor_quantile,
|
||||
null_segment_only=null_segment_only,
|
||||
cap_mult=cap_mult,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -447,6 +460,12 @@ async def _job_house_imv_backfill(
|
|||
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
|
||||
"total_seen": result.checked,
|
||||
"new_count": result.saved,
|
||||
# #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error)
|
||||
# и сколько домов ушло в no_params до пакета одним запросом. Без этих
|
||||
# двух счётчиков в scrape_runs.counters проверить, что застрявшие
|
||||
# действительно возвращаются в очередь, можно только по houses.
|
||||
"retried": result.retried,
|
||||
"premarked": result.premarked,
|
||||
}
|
||||
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
|
||||
# «сделали то, что собирались», а не «не поймали известное исключение».
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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): она = «впервые увидели период».
|
||||
"""
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-31→06-15 и 07-17→08-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
|
||||
|
|
|
|||
|
|
@ -22,8 +22,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
|
|||
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
|
||||
такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они
|
||||
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
|
||||
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
|
||||
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
|
||||
целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать.
|
||||
|
||||
Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий
|
||||
у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по
|
||||
собранному из него каноническому URL страница отдаётся и парсится. Поэтому в
|
||||
очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены:
|
||||
url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько
|
||||
не адресуемо вообще (ни offer-URL, ни числового source_id).
|
||||
|
||||
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||||
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
||||
|
|
@ -51,6 +57,8 @@ from app.services.proxy_egress import resolve_proxy_url
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"CANONICAL_URL_SQL",
|
||||
"OFFER_ID_PATTERN",
|
||||
"OFFER_URL_PATTERN",
|
||||
"YandexDetailBackfillResult",
|
||||
"run_yandex_detail_backfill",
|
||||
|
|
@ -63,8 +71,7 @@ __all__ = [
|
|||
#
|
||||
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
|
||||
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
|
||||
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
|
||||
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
|
||||
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса.
|
||||
#
|
||||
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
|
||||
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
|
||||
|
|
@ -72,6 +79,39 @@ __all__ = [
|
|||
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
|
||||
OFFER_URL_PATTERN = "/offer/[0-9]+"
|
||||
|
||||
# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ────────────────
|
||||
# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на
|
||||
# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде:
|
||||
#
|
||||
# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная;
|
||||
# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк,
|
||||
# вставленных после самой свежей его строки, id 2583989), и выйти из него
|
||||
# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое
|
||||
# множество и обязано быть константой — вопрос был не «почему не растёт», а
|
||||
# «правда ли они непригодны».
|
||||
#
|
||||
# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId
|
||||
# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из
|
||||
# него собирается — это инвариант #2235 (`_canonical_source_url` в
|
||||
# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
|
||||
# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
|
||||
# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
|
||||
# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
|
||||
#
|
||||
# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
|
||||
# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
|
||||
# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ
|
||||
# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной
|
||||
# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес,
|
||||
# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки.
|
||||
#
|
||||
# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой
|
||||
# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией:
|
||||
# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят
|
||||
# на сайты застройщиков вместо Яндекса.
|
||||
OFFER_ID_PATTERN = "^[0-9]+$"
|
||||
CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'"
|
||||
|
||||
|
||||
@dataclass
|
||||
class YandexDetailBackfillResult:
|
||||
|
|
@ -80,6 +120,11 @@ class YandexDetailBackfillResult:
|
|||
attempted: int = 0
|
||||
enriched: int = 0
|
||||
failed: int = 0
|
||||
# Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из
|
||||
# source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к
|
||||
# прогону; замер на месте = очередь снова читается не тем признаком.
|
||||
url_from_offer_id: int = 0
|
||||
# Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id.
|
||||
unenrichable_pending: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -88,6 +133,7 @@ class YandexDetailBackfillResult:
|
|||
"attempted": self.attempted,
|
||||
"enriched": self.enriched,
|
||||
"failed": self.failed,
|
||||
"url_from_offer_id": self.url_from_offer_id,
|
||||
"unenrichable_pending": self.unenrichable_pending,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -132,51 +178,86 @@ async def run_yandex_detail_backfill(
|
|||
|
||||
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||||
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
|
||||
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
|
||||
# (см. комментарий у константы): в очередь не берём то, что заведомо
|
||||
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
|
||||
# В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо
|
||||
# сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес
|
||||
# собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире
|
||||
# этого условия — гарантированный parse→None пачкой и обрыв по брейкеру.
|
||||
snapshot = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source_url
|
||||
f"""
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN source_url ~ CAST(:offer_url_pattern AS text)
|
||||
THEN source_url
|
||||
ELSE {CANONICAL_URL_SQL}
|
||||
END AS source_url
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND source_url ~ CAST(:offer_url_pattern AS text)
|
||||
AND (
|
||||
(
|
||||
source_url IS NOT NULL
|
||||
AND source_url ~ CAST(:offer_url_pattern AS text)
|
||||
)
|
||||
OR source_id ~ CAST(:offer_id_pattern AS text)
|
||||
)
|
||||
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
"""
|
||||
# f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля,
|
||||
# не пользовательский ввод. Всё изменяемое — bind-параметры.
|
||||
),
|
||||
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
|
||||
{
|
||||
"batch_size": batch_size,
|
||||
"offer_url_pattern": OFFER_URL_PATTERN,
|
||||
"offer_id_pattern": OFFER_ID_PATTERN,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
|
||||
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
|
||||
counters.unenrichable_pending = int(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND source_url IS NOT NULL
|
||||
AND source_url !~ CAST(:offer_url_pattern AS text)
|
||||
"""
|
||||
),
|
||||
{"offer_url_pattern": OFFER_URL_PATTERN},
|
||||
).scalar_one()
|
||||
)
|
||||
if counters.unenrichable_pending:
|
||||
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно
|
||||
# разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как
|
||||
# «тут делать нечего» и держало 3535 квартир вне обогащения неделю.
|
||||
pending = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
count(*) FILTER (
|
||||
WHERE source_id ~ CAST(:offer_id_pattern AS text)
|
||||
) AS url_from_offer_id,
|
||||
count(*) FILTER (
|
||||
WHERE source_id IS NULL
|
||||
OR source_id !~ CAST(:offer_id_pattern AS text)
|
||||
) AS unenrichable_pending
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND detail_enriched_at IS NULL
|
||||
AND (
|
||||
source_url IS NULL
|
||||
OR source_url !~ CAST(:offer_url_pattern AS text)
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN},
|
||||
).one()
|
||||
counters.url_from_offer_id = int(pending.url_from_offer_id)
|
||||
counters.unenrichable_pending = int(pending.unenrichable_pending)
|
||||
if counters.url_from_offer_id:
|
||||
logger.info(
|
||||
"yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: "
|
||||
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
|
||||
"до сети",
|
||||
"yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый "
|
||||
"source_url не ведёт на карточку Яндекса; адресуем их по offerId из "
|
||||
"source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)",
|
||||
run_id,
|
||||
counters.url_from_offer_id,
|
||||
)
|
||||
if counters.unenrichable_pending:
|
||||
logger.warning(
|
||||
"yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни "
|
||||
"offer-URL (%s), ни числового source_id — адресовать их нечем",
|
||||
run_id,
|
||||
counters.unenrichable_pending,
|
||||
OFFER_URL_PATTERN,
|
||||
|
|
|
|||
|
|
@ -58,12 +58,17 @@ if settings.glitchtip_dsn:
|
|||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
|
||||
from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event
|
||||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
)
|
||||
|
||||
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
|
||||
"""Композиция PII-scrub (form-данные) + Telegram bot-токен redaction
|
||||
(#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые
|
||||
`include_local_variables=False` ниже и этот хук закрывают вместе:
|
||||
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
|
||||
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
|
||||
независимыми векторами, которые `include_local_variables=False` ниже и
|
||||
этот хук закрывают вместе:
|
||||
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
||||
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
||||
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
||||
|
|
@ -72,8 +77,16 @@ if settings.glitchtip_dsn:
|
|||
перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор
|
||||
— belt-and-suspenders на случай #1 (если include_local_variables
|
||||
случайно вернут) И на span data.
|
||||
|
||||
Платёжный body-wipe — belt-and-suspenders: этот процесс не держит ASGI-
|
||||
приложения (нет `request` в event сегодня), но тот же обработчик передан
|
||||
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
|
||||
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
|
||||
"""
|
||||
scrubbed = scrub_pii_event(event, hint)
|
||||
scrubbed = scrub_payment_request_body(event, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint)
|
||||
|
|
@ -86,6 +99,7 @@ if settings.glitchtip_dsn:
|
|||
send_default_pii=False,
|
||||
include_local_variables=False,
|
||||
before_send=_before_send,
|
||||
before_send_transaction=_before_send,
|
||||
integrations=[
|
||||
HttpxIntegration(),
|
||||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
-- 257_listings_backfill_yandex_source_url.sql
|
||||
-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838).
|
||||
--
|
||||
-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`,
|
||||
-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235,
|
||||
-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки,
|
||||
-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared`
|
||||
-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку
|
||||
-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки.
|
||||
-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/`
|
||||
-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются
|
||||
-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты.
|
||||
-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция.
|
||||
--
|
||||
-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»):
|
||||
--
|
||||
-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк
|
||||
-- ------------------------------------------------------- -----
|
||||
-- всего 3535
|
||||
-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535
|
||||
-- из них source_id NULL/нечисловой (нечем адресовать) 0
|
||||
-- из них source_url IS NULL 0
|
||||
-- из них is_active 3522
|
||||
--
|
||||
-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356,
|
||||
-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167,
|
||||
-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114,
|
||||
-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост.
|
||||
--
|
||||
-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем):
|
||||
-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк,
|
||||
-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет.
|
||||
-- Множество может только уменьшаться (удаление строк), не расти.
|
||||
--
|
||||
-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь.
|
||||
-- scraper_kit/providers/yandex/serp.py::_canonical_source_url:
|
||||
-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex»
|
||||
-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL
|
||||
-- "'https://realty.yandex.ru/offer/' || source_id || '/'"
|
||||
-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно;
|
||||
-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление
|
||||
-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою
|
||||
-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py).
|
||||
-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+')
|
||||
-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки.
|
||||
--
|
||||
-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО:
|
||||
-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0;
|
||||
-- * два кандидата с одинаковым новым URL внутри самого множества: 0
|
||||
-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql);
|
||||
-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0.
|
||||
--
|
||||
-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже):
|
||||
-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0
|
||||
-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0
|
||||
-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0
|
||||
--
|
||||
-- было → стало (10 строк, взяты по id DESC):
|
||||
-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/
|
||||
-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/
|
||||
-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/
|
||||
-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/
|
||||
-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/
|
||||
-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/
|
||||
-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/
|
||||
-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/
|
||||
-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/
|
||||
-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/
|
||||
-- (префикс «стало» везде один: https://realty.yandex.ru/offer/<source_id>/)
|
||||
-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же
|
||||
-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK.
|
||||
--
|
||||
-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено).
|
||||
-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был
|
||||
-- raw_payload->>'address') здесь НЕ работает:
|
||||
-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там
|
||||
-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type,
|
||||
-- site_name — адреса нет ни под одним именем;
|
||||
-- * listings.house_url / newbuilding_url у всех 3535 = NULL;
|
||||
-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE
|
||||
-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить
|
||||
-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не
|
||||
-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его
|
||||
-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки,
|
||||
-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика
|
||||
-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе
|
||||
-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес).
|
||||
-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат:
|
||||
--
|
||||
-- UPDATE listings l
|
||||
-- SET source_url = b.old_source_url
|
||||
-- FROM yandex_source_url_backfill_257 b
|
||||
-- WHERE l.id = b.listing_id
|
||||
-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/';
|
||||
--
|
||||
-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал
|
||||
-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая
|
||||
-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто
|
||||
-- удалить — на приложение она не влияет, читателей у неё нет.
|
||||
--
|
||||
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
|
||||
-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py —
|
||||
-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там
|
||||
-- COALESCE в апсерте так же намеренно не трогали);
|
||||
-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по
|
||||
-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая
|
||||
-- история того, что отдал gate-API;
|
||||
-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай»
|
||||
-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт);
|
||||
-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка.
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql
|
||||
-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула).
|
||||
-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL →
|
||||
-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT
|
||||
-- DO NOTHING делают повтор безопасным и при частичном откате.
|
||||
-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py
|
||||
-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и
|
||||
-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед
|
||||
-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 (
|
||||
listing_id bigint PRIMARY KEY,
|
||||
old_source_url text NOT NULL,
|
||||
changed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE yandex_source_url_backfill_257 IS
|
||||
'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). '
|
||||
'Только для отката; читателей в приложении нет, удаляется без последствий.';
|
||||
|
||||
INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url)
|
||||
SELECT id, source_url
|
||||
FROM listings
|
||||
WHERE source = 'yandex'
|
||||
AND source_url !~ '/offer/[0-9]+'
|
||||
AND source_id ~ '^[0-9]+$'
|
||||
ON CONFLICT (listing_id) DO NOTHING;
|
||||
|
||||
UPDATE listings
|
||||
SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/'
|
||||
WHERE source = 'yandex'
|
||||
AND source_url !~ '/offer/[0-9]+'
|
||||
AND source_id ~ '^[0-9]+$';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
-- 258_houses_imv_transient_attempts.sql
|
||||
-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674).
|
||||
--
|
||||
-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь
|
||||
-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию
|
||||
-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен
|
||||
-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из
|
||||
-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All
|
||||
-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой
|
||||
-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе
|
||||
-- 108 из 108 POST /fetch-json = 200).
|
||||
--
|
||||
-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически
|
||||
-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из
|
||||
-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт
|
||||
-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд.
|
||||
--
|
||||
-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error',
|
||||
-- прежние разрезы по imv_status/imv_error_reason работают как работали, а
|
||||
-- «застряли окончательно» — это
|
||||
-- SELECT count(*) FROM houses
|
||||
-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3;
|
||||
--
|
||||
-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по
|
||||
-- imv_status IN ('pending','transient_error') с сортировкой по
|
||||
-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на
|
||||
-- выборке в тысячи строк.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE houses
|
||||
ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN houses.imv_transient_attempts IS
|
||||
'Сколько раз подряд домовая IMV-оценка падала в transient_error. '
|
||||
'Растёт только на transient_error, обнуляется успехом. '
|
||||
'>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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;
|
||||
179
tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
Normal file
179
tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql
Normal 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.05–12.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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
# _manifest_applied.txt — CONTRACT (issue #2216)
|
||||
#
|
||||
# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/,
|
||||
# которые на момент коммита уже применены/забейслайнены на проде.
|
||||
# Прод трекает миграции по bare-filename в public._schema_migrations —
|
||||
# переименование или удаление применённого файла => повторный прогон на
|
||||
# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки.
|
||||
#
|
||||
# ПРАВИЛА (enforced tests/test_migrations_manifest.py):
|
||||
# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied).
|
||||
# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей).
|
||||
# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован).
|
||||
#
|
||||
# Комментарии (# ...) и пустые строки тест игнорирует.
|
||||
001_trade_in_estimates.sql
|
||||
002_core_tables.sql
|
||||
003_seed_deals.sql
|
||||
004_extend_trade_in_estimates.sql
|
||||
005_geocode_tracking.sql
|
||||
007_estimate_photos.sql
|
||||
008_crm_fields.sql
|
||||
009_houses.sql
|
||||
010_houses_alter.sql
|
||||
011_listings_alter.sql
|
||||
012_sellers.sql
|
||||
013_listings_alter_seller.sql
|
||||
014_house_reviews.sql
|
||||
015_scrape_runs.sql
|
||||
016_listings_snapshots.sql
|
||||
017_house_placement_history.sql
|
||||
018_avito_imv_evaluations.sql
|
||||
019_listings_alter_cian.sql
|
||||
020_houses_alter_cian.sql
|
||||
021_management_companies.sql
|
||||
022_agents_table.sql
|
||||
023_offer_price_history.sql
|
||||
024_houses_price_dynamics.sql
|
||||
025_house_reliability_checks.sql
|
||||
026_external_valuations.sql
|
||||
027_cian_session_cookies.sql
|
||||
028_matching_tables.sql
|
||||
029_extend_matching_valuation_dynamics.sql
|
||||
030_avito_imv_cache_key_unique.sql
|
||||
031_houses_alter_yandex.sql
|
||||
032_yandex_history.sql
|
||||
033_listings_alter_yandex.sql
|
||||
034_trade_in_estimates_geom.sql
|
||||
035_drop_duplicate_indexes.sql
|
||||
040_houses_extend.sql
|
||||
041_house_sources_noop.sql
|
||||
042_listing_sources_price_divergence_idx.sql
|
||||
043_house_reviews_extend.sql
|
||||
044_external_valuations_link.sql
|
||||
045_house_placement_history_extend.sql
|
||||
046_views.sql
|
||||
047_cian_history_sanitize.sql
|
||||
050_search_optimization.sql
|
||||
051_scrape_runs_extend.sql
|
||||
052_scrape_schedules.sql
|
||||
053_scraper_settings.sql
|
||||
054_scraper_settings_global.sql
|
||||
060_postgres_fdw_extension.sql
|
||||
061_drop_legacy_cad_buildings.sql
|
||||
062_clean_avito_addresses.sql
|
||||
063_backfill_houses_and_link_listings.sql
|
||||
064_house_imv_phase_c.sql
|
||||
065_trade_in_estimates_floor_optional.sql
|
||||
066_address_mismatch_audit.sql
|
||||
067_v_street_sales_vs_listings.sql
|
||||
068_drop_v_street_sales_vs_listings.sql
|
||||
069_trade_in_estimates_dadata_fields.sql
|
||||
070_houses_dadata_enrichment.sql
|
||||
071_houses_cian_zhk_url.sql
|
||||
072_scrape_schedules_seed_cian_rosreestr.sql
|
||||
073_normalize_repair_state.sql
|
||||
075_backfill_repair_state_from_description.sql
|
||||
076_account_estimate_quota.sql
|
||||
077_dedup_hash_plain_key_backfill.sql
|
||||
078_scrape_schedules_seed_yandex_sweep.sql
|
||||
079_listing_source_history.sql
|
||||
080_asking_to_sold_ratios.sql
|
||||
081_trade_in_estimates_expected_sold.sql
|
||||
082_scrape_schedules_seed_ratio_refresh.sql
|
||||
083_trade_in_estimates_created_by.sql
|
||||
084_brand_praktika_fill.sql
|
||||
084_scrape_schedules_seed_n1_sweep.sql
|
||||
085_quarter_price_index_fdw.sql
|
||||
086_deals_address_trgm_index.sql
|
||||
087_fdw_server_options.sql
|
||||
088_scrape_schedules_seed_search_matview_refresh.sql
|
||||
089_listings_geo_precision.sql
|
||||
090_scrape_schedules_seed_deactivate_stale_avito.sql
|
||||
091_scrape_schedules_seed_yandex_address_backfill.sql
|
||||
092_sber_price_index.sql
|
||||
093_scrape_schedules_seed_sber_index_pull.sql
|
||||
094_cadastral_unify.sql
|
||||
095_dead_schema.sql
|
||||
096_scrape_schedules_seed_rosreestr_quarter_poll.sql
|
||||
097_index_hygiene.sql
|
||||
098_asking_to_sold_ratios_tiered.sql
|
||||
099_brand_praktika_logo_wordmark.sql
|
||||
100_enable_deactivate_stale_avito.sql
|
||||
101_gendesign_reader_role.sql
|
||||
102_grant_listings_gendesign_reader.sql
|
||||
103_scrape_schedules_seed_newbuilding_enrich.sql
|
||||
104_index_hygiene_geom_dedup.sql
|
||||
105_market_schema_yandex_enrichment.sql
|
||||
106_scrape_schedules_seed_yandex_newbuilding_sweep.sql
|
||||
107_scrape_schedules_seed_cian_city_sweep.sql
|
||||
108_clean_avito_addresses_v2.sql
|
||||
108_merge_duplicate_houses.sql
|
||||
109_asking_to_sold_ratio_segment_filter.sql
|
||||
110_scrape_schedules_seed_geocode_missing_listings.sql
|
||||
111_listings_avito_detail_fields.sql
|
||||
112_scrape_schedules_seed_avito_detail_backfill.sql
|
||||
113_deactivate_ghost_duplicate_listings.sql
|
||||
113_yandex_detail_backfill.sql
|
||||
114_disable_n1_sweep.sql
|
||||
115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql
|
||||
116_offer_price_history_change_trigger.sql
|
||||
117_listings_last_seen_deactivate_index.sql
|
||||
118_enable_cian_city_sweep.sql
|
||||
119_yandex_city_sweep_center_combos.sql
|
||||
120_restore_partial_active_indexes.sql
|
||||
121_remove_brand_praktika.sql
|
||||
121_yandex_rich_fields.sql
|
||||
122_enable_domclick_city_sweep.sql
|
||||
123_avito_newbuilding_sweep_schedule.sql
|
||||
124_cad_buildings_local.sql
|
||||
124_deglue_avito_addresses.sql
|
||||
125_scrape_schedules_seed_cadastral_geo_match.sql
|
||||
126_scrape_schedules_seed_cian_full_load.sql
|
||||
127_scrape_schedules_seed_avito_full_load.sql
|
||||
128_listings_card_hash.sql
|
||||
129_avito_full_load_incremental_split.sql
|
||||
130_backfill_listings_house_id_fk.sql
|
||||
130_ekb_geoportal_buildings.sql
|
||||
131_fix_diff_percent_overflow.sql
|
||||
132_scrape_schedules_seed_house_imv.sql
|
||||
133_listings_uq_source_source_id.sql
|
||||
134_listings_geom_geography_gist.sql
|
||||
135_scrape_schedules_seed_house_dedup_merge.sql
|
||||
136_backfill_listings_house_id_fk_source_identity.sql
|
||||
137_listings_addr_norm_trgm.sql
|
||||
138_domclick_bff_rewrite_schedule.sql
|
||||
139_premium_houses.sql
|
||||
140_yandex_house_type_backfill.sql
|
||||
141_cian_promote_house_type.sql
|
||||
142_premium_buildings_curated.sql
|
||||
143_building_sale_share_schema.sql
|
||||
144_gar_canon_addr_match.sql
|
||||
145_building_sale_share_plausible_denom.sql
|
||||
146_sale_share_45d_and_zhkh_denom.sql
|
||||
147_canon_strip_geo_prefixes.sql
|
||||
148_dedup_apartments_in_sale_share.sql
|
||||
149_zhkh_priority_denominator.sql
|
||||
150_sale_share_listing_geo_filter.sql
|
||||
151_clean_bare_street_aliases.sql
|
||||
152_sale_share_floors_guard.sql
|
||||
153_sale_share_listings_floors_plausibility.sql
|
||||
154_market_contract_views.sql
|
||||
155_reader_grants_to_contract_views.sql
|
||||
156_revoke_raw_from_reader.sql
|
||||
157_scrape_proxies.sql
|
||||
158_seed_proxy_healthcheck_schedule.sql
|
||||
159_houses_fias_idx.sql
|
||||
160_seed_deactivate_stale_domklik_n1.sql
|
||||
161_backfill_scraped_at_active_recent.sql
|
||||
162_seed_deals_freshness_monitor.sql
|
||||
163_disable_deactivate_stale_domklik.sql
|
||||
164_yandex_url_canonicalize_active_dups.sql
|
||||
165_remove_n1_source.sql
|
||||
166_purge_listings_phones.sql
|
||||
167_drop_client_pii.sql
|
||||
168_fdw_osm_poi_ekb.sql
|
||||
169_osm_poi_ekb_local.sql
|
||||
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
|
||||
171_scrape_schedules_seed_geoportal_coords_backfill.sql
|
||||
172_trade_in_leads.sql
|
||||
173_scrape_proxies_add_domclick_affinity.sql
|
||||
174_domclick_session_cookies.sql
|
||||
175_scrape_schedules_seed_domclick_detail_backfill.sql
|
||||
176_domrf_kapremont.sql
|
||||
177_deals_city_region.sql
|
||||
178_deal_city_price_bands.sql
|
||||
179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||
180_seed_sber_freshness_monitor.sql
|
||||
181_clamp_bad_listing_dates.sql
|
||||
182_trade_in_leads_consent_proof.sql
|
||||
183_reenable_deactivate_stale_domklik.sql
|
||||
184_user_events.sql
|
||||
185_account_quota_overrides.sql
|
||||
186_tg_support.sql
|
||||
187_web_support_chat.sql
|
||||
188_tg_support_chat_id_scope.sql
|
||||
189_account_estimate_usage_nonnegative.sql
|
||||
190_sale_share_price_bucket_signature.sql
|
||||
191_account_quota_unlimited_flag.sql
|
||||
192_tradein_users_auth.sql
|
||||
193_tradein_users_seed.sql
|
||||
194_deal_city_price_bands_tiers.sql
|
||||
195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
|
||||
196_listings_city.sql
|
||||
197_backfill_listings_city_from_url.sql
|
||||
198_scrape_proxy_rotations.sql
|
||||
199_scrape_proxies_asocks_rotate_url.sql
|
||||
200_region_code_foreign_cities.sql
|
||||
201_purge_dead_mobileproxy_proxies.sql
|
||||
202_listing_source_snapshot_budget_sec.sql
|
||||
203_purge_geocode_cache_house_letter.sql
|
||||
204_cian_oblast_sweeps_secondary.sql
|
||||
205_sales_vs_listings_city_filter.sql
|
||||
206_scrape_schedules_cut_wasteful_load.sql
|
||||
207_backfill_yandex_cian_city_geo_cleanup.sql
|
||||
208_reenable_domclick_detail_backfill.sql
|
||||
209_scrape_proxies_disabled_reason.sql
|
||||
210_scrape_proxy_source_bans.sql
|
||||
211_sales_vs_listings_segment_guard.sql
|
||||
212_sber_index_pull_weekly.sql
|
||||
213_listings_snapshots_status_vocab.sql
|
||||
214_drop_dead_run_metrics.sql
|
||||
215_avito_full_load_window_matches_cadence.sql
|
||||
216_dead_code_sweep.sql
|
||||
#
|
||||
# 2026-08-06: список догнан до факта прода. Проверка перед правкой —
|
||||
# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было
|
||||
# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено).
|
||||
# Обратного расхождения нет: ни одной строки, которой не было бы на проде.
|
||||
#
|
||||
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
|
||||
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
|
||||
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
|
||||
#
|
||||
# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
|
||||
# правки они уже слиты в main и применены на проде (см. _schema_migrations),
|
||||
# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
|
||||
# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
|
||||
# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
|
||||
# 222/225 по прецеденту 233_payments.sql.
|
||||
222_db_audit_cleanup.sql
|
||||
225_listing_source_snapshots_run_id_idx.sql
|
||||
233_payments.sql
|
||||
234_scrape_runs_ban_kind_unknown.sql
|
||||
240_trade_in_estimates_retain_until.sql
|
||||
250_drop_duplicate_expires_at_index.sql
|
||||
251_listings_drop_ceiling_height.sql
|
||||
254_listings_backfill_avito_rating_glued_address.sql
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
},
|
||||
"low": {
|
||||
"coverage_pct": 82.09,
|
||||
"mape_pct": 13.2,
|
||||
"mape_pct": 12.67,
|
||||
"n": 276,
|
||||
"n_covered": 220
|
||||
},
|
||||
|
|
@ -26,22 +26,22 @@
|
|||
],
|
||||
"expected_sold": {
|
||||
"overall": {
|
||||
"mape_pct": 13.18,
|
||||
"median_bias_pct": -3.71,
|
||||
"mape_pct": 12.63,
|
||||
"median_bias_pct": -3.74,
|
||||
"n": 269,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -16.11,
|
||||
"p75_pct": 8.92
|
||||
"p25_pct": -15.17,
|
||||
"p75_pct": 8.67
|
||||
},
|
||||
"per_rooms": {
|
||||
"0": {
|
||||
"label": "студия",
|
||||
"mape_pct": 19.38,
|
||||
"median_bias_pct": 18.1,
|
||||
"mape_pct": 16.96,
|
||||
"median_bias_pct": 16.96,
|
||||
"n": 35,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": 1.5,
|
||||
"p75_pct": 34.32
|
||||
"p75_pct": 38.82
|
||||
},
|
||||
"1": {
|
||||
"label": "1к",
|
||||
|
|
@ -50,50 +50,50 @@
|
|||
"n": 93,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.43,
|
||||
"p75_pct": 6.98
|
||||
"p75_pct": 8.15
|
||||
},
|
||||
"2": {
|
||||
"label": "2к",
|
||||
"mape_pct": 18.26,
|
||||
"median_bias_pct": -12.2,
|
||||
"mape_pct": 17.39,
|
||||
"median_bias_pct": -11.71,
|
||||
"n": 74,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -24.35,
|
||||
"p25_pct": -23.07,
|
||||
"p75_pct": -0.36
|
||||
},
|
||||
"3": {
|
||||
"label": "3к",
|
||||
"mape_pct": 9.34,
|
||||
"median_bias_pct": -3.08,
|
||||
"mape_pct": 7.79,
|
||||
"median_bias_pct": -4.05,
|
||||
"n": 43,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -10.26,
|
||||
"p75_pct": 4.68
|
||||
"p75_pct": 3.82
|
||||
},
|
||||
"4": {
|
||||
"label": "4+",
|
||||
"mape_pct": 16.3,
|
||||
"median_bias_pct": 3.34,
|
||||
"mape_pct": 16.38,
|
||||
"median_bias_pct": -1.32,
|
||||
"n": 24,
|
||||
"n_no_analogs": 0,
|
||||
"p25_pct": -14.91,
|
||||
"p75_pct": 15.11
|
||||
"p75_pct": 15.41
|
||||
}
|
||||
},
|
||||
"per_segment": {
|
||||
"бизнес": {
|
||||
"mape_pct": 14.65,
|
||||
"mape_pct": 13.65,
|
||||
"median_bias_pct": -10.54,
|
||||
"n": 46,
|
||||
"p25_pct": -22.93,
|
||||
"p25_pct": -27.15,
|
||||
"p75_pct": -1.31
|
||||
},
|
||||
"комфорт": {
|
||||
"mape_pct": 11.8,
|
||||
"median_bias_pct": -4.61,
|
||||
"mape_pct": 10.14,
|
||||
"median_bias_pct": -5.01,
|
||||
"n": 101,
|
||||
"p25_pct": -17.07,
|
||||
"p75_pct": 6.04
|
||||
"p25_pct": -15.49,
|
||||
"p75_pct": 4.37
|
||||
},
|
||||
"премиум": {
|
||||
"mape_pct": 68.92,
|
||||
|
|
@ -103,11 +103,11 @@
|
|||
"p75_pct": -68.92
|
||||
},
|
||||
"эконом": {
|
||||
"mape_pct": 13.71,
|
||||
"median_bias_pct": 2.54,
|
||||
"mape_pct": 14.2,
|
||||
"median_bias_pct": 3.33,
|
||||
"n": 115,
|
||||
"p25_pct": -9.8,
|
||||
"p75_pct": 25.5
|
||||
"p25_pct": -8.69,
|
||||
"p75_pct": 27.3
|
||||
},
|
||||
"элит": {
|
||||
"mape_pct": 33.2,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -59,3 +59,37 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy
|
|||
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
|
||||
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
|
||||
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
|
||||
|
||||
# Гейт номеров миграций (#2683) сверяется с origin/main и точкой ветвления. Где
|
||||
# git-эталона нет — прогон внутри prod-образа, экспорт исходников без .git —
|
||||
# проверять не с чем, и тест это ГОВОРИТ вслух вместо тихого зелёного.
|
||||
# В CI пропуска не бывает: при CI/GITHUB_ACTIONS та же ветка делает pytest.fail
|
||||
# (отсутствие эталона в пайплайне — сломанный гейт, а не «нечего проверять»),
|
||||
# а ci-tradein.yml/deploy-tradein.yml берут checkout с fetch-depth: 0 — при нём
|
||||
# checkout сам приносит refs/remotes/origin/*, отдельный git fetch не нужен и
|
||||
# из job-контейнера всё равно не проходит (run 6977, connection refused).
|
||||
tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
|
||||
tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
|
||||
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
|
||||
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
|
||||
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
|
||||
# по-настоящему (Postgres + схема из data/sql) — там прогон и был красным на
|
||||
# origin/main и зелёным на ветке. Статическая половина файла
|
||||
# (test_retry_queue_is_not_bound_to_only_status) БД не требует и идёт в обоих лэйнах.
|
||||
tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
|
||||
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
|
||||
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
|
||||
|
||||
# MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `live_session` fixture
|
||||
# (self-skip через `_live_db_available()`, живёт только при реальном Postgres DSN).
|
||||
# Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка
|
||||
# физически не попадают в когорту (не только SQL-текст, который проверяется отдельным
|
||||
# статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах).
|
||||
tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live
|
||||
|
||||
# MAJOR-2 поведенческий пин (повторная проверка #2894) — та же `live_session` fixture.
|
||||
# Ловит мутацию «убрать FILTER у percentile_cont, оставив у count(*)», которую
|
||||
# текстовый тест test_max_age_outlier_days_passed_to_sql пропускал (подстрока
|
||||
# `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне
|
||||
# (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip.
|
||||
tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
|
@ -25,6 +26,9 @@ sys.modules.setdefault("weasyprint", _wp_mock)
|
|||
import pytest # noqa: E402
|
||||
|
||||
from app.tasks.yandex_detail_backfill import ( # noqa: E402
|
||||
CANONICAL_URL_SQL,
|
||||
OFFER_ID_PATTERN,
|
||||
OFFER_URL_PATTERN,
|
||||
YandexDetailBackfillResult,
|
||||
run_yandex_detail_backfill,
|
||||
)
|
||||
|
|
@ -55,13 +59,21 @@ def _make_snapshot(n: int) -> list[dict]:
|
|||
]
|
||||
|
||||
|
||||
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
|
||||
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
|
||||
.scalar_one() — размер отброшенной (непарсимой) части очереди."""
|
||||
def _mock_db(
|
||||
snapshot: list[dict],
|
||||
unenrichable: int = 0,
|
||||
url_from_offer_id: int = 0,
|
||||
) -> MagicMock:
|
||||
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а .one() —
|
||||
остаток очереди вне снапшота, РАЗБИТЫЙ по причине (адрес восстановим из
|
||||
source_id / адресовать нечем)."""
|
||||
db = MagicMock()
|
||||
sel = MagicMock()
|
||||
sel.mappings.return_value.all.return_value = snapshot
|
||||
sel.scalar_one.return_value = unenrichable
|
||||
sel.one.return_value = SimpleNamespace(
|
||||
url_from_offer_id=url_from_offer_id,
|
||||
unenrichable_pending=unenrichable,
|
||||
)
|
||||
db.execute.return_value = sel
|
||||
return db
|
||||
|
||||
|
|
@ -510,3 +522,72 @@ async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
|
|||
|
||||
assert result.unenrichable_pending == 3535
|
||||
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# «Непригодно» — ярлык, а не диагноз (2026-08-12)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_canonical_sql(offer_id: str) -> str:
|
||||
"""Считает CANONICAL_URL_SQL как строку: '||' — конкатенация, source_id — значение."""
|
||||
parts = [p.strip() for p in CANONICAL_URL_SQL.split("||")]
|
||||
return "".join(offer_id if p == "source_id" else p.strip("'") for p in parts)
|
||||
|
||||
|
||||
def test_recovered_url_equals_producer_canonical_form() -> None:
|
||||
"""Адрес, вычисленный из source_id, — тот же, что пишет продюсер, и парсер его примет.
|
||||
|
||||
Шесть прогонов подряд unenrichable_pending равнялся ровно 3535 не потому, что
|
||||
счётчик застыл (SELECT живой), а потому что множество замкнуто: продюсер после
|
||||
#2235 таких строк больше не создаёт, а выйти оттуда нельзя — source_url пишется
|
||||
только при вставке. Ярлык «непригодны» был неверен: у всех есть offerId, и по
|
||||
собранному из него URL страница парсится (прод-проба 2026-08-12, 6/6).
|
||||
|
||||
Сторож держит ровно это: формула восстановления в SQL не должна разъехаться с
|
||||
`_canonical_source_url` продюсера, а результат — пройти гейт парсера.
|
||||
"""
|
||||
from scraper_kit.providers.yandex.serp import _canonical_source_url
|
||||
|
||||
offer_id = "7416316697684470413" # реальный source_id прод-строки с macroserver.ru
|
||||
recovered = _render_canonical_sql(offer_id)
|
||||
|
||||
assert re.match(OFFER_ID_PATTERN, offer_id)
|
||||
assert re.search(OFFER_URL_PATTERN, recovered), recovered
|
||||
for stored_url, is_offer_url in _PROD_QUEUE_HEAD:
|
||||
if is_offer_url:
|
||||
continue # у этих сохранённый адрес уже канонический, чинить нечего
|
||||
assert _canonical_source_url(stored_url, offer_id) == recovered, stored_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_counter_split_by_reason() -> None:
|
||||
"""Остаток очереди делится по ПРИЧИНЕ, и восстановимое не зовётся непригодным.
|
||||
|
||||
Одно число на две разные судьбы («адрес чиним» и «адресовать нечем») читается
|
||||
как «тут делать нечего» — так 3535 квартир простояли неделю вне обогащения.
|
||||
"""
|
||||
db = _mock_db([], unenrichable=0, url_from_offer_id=3535)
|
||||
runs = MagicMock()
|
||||
session_cls, _session = _make_session_ctx([])
|
||||
|
||||
with (
|
||||
patch(_ASYNC_SESSION, session_cls),
|
||||
patch(_RUNS, runs),
|
||||
patch(_RESOLVE_PROXY_URL, _mock_resolve_proxy_url()),
|
||||
):
|
||||
result = await run_yandex_detail_backfill(
|
||||
db, run_id=43, params={"batch_size": 10, "budget_sec": 60}
|
||||
)
|
||||
|
||||
assert result.url_from_offer_id == 3535
|
||||
assert result.unenrichable_pending == 0
|
||||
counters = runs.mark_done.call_args.args[2]
|
||||
assert counters["url_from_offer_id"] == 3535
|
||||
assert counters["unenrichable_pending"] == 0
|
||||
|
||||
# Снапшот-SELECT берёт такие строки в работу по вычисленному адресу, а не
|
||||
# выбрасывает: без этой ветки они не попадут в очередь никогда.
|
||||
snapshot_sql = str(db.execute.call_args_list[0].args[0])
|
||||
assert "OR source_id ~ CAST(:offer_id_pattern AS text)" in snapshot_sql
|
||||
assert CANONICAL_URL_SQL in snapshot_sql
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
226
tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
Normal file
226
tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
Normal 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.3–9.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
|
||||
86
tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
Normal file
86
tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
Normal 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"
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 без него невозможен."
|
||||
)
|
||||
|
|
@ -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, "лоты частичного бакета обязаны быть сохранены"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -500,7 +500,11 @@ async def test_backfill_house_imv_ok_path():
|
|||
patch("app.services.house_imv_backfill.save_imv_result") as mock_save,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||||
mock_mappings.all.side_effect = [[], houses]
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
|
@ -539,7 +543,11 @@ async def test_backfill_house_imv_no_params():
|
|||
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||||
mock_mappings.all.side_effect = [[], houses]
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
|
@ -593,7 +601,11 @@ async def test_backfill_house_imv_not_found():
|
|||
patch("app.services.house_imv_backfill._mark_status") as mock_mark,
|
||||
):
|
||||
mock_mappings = MagicMock()
|
||||
mock_mappings.all.return_value = houses
|
||||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||||
mock_mappings.all.side_effect = [[], houses]
|
||||
mock_db.execute.return_value.mappings.return_value = mock_mappings
|
||||
|
||||
result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0)
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
665
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal file
665
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
"""Tests for POST /api/v1/trade-in/coverage (issue #2894).
|
||||
|
||||
Бесплатная проба покрытия для публичного лэндинга «МЕРА» — до оплаты человек
|
||||
видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без
|
||||
единой рублёвой цифры в ответе. Covers:
|
||||
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
|
||||
- пустая когорта (n=0) → not_covered даже в поддерживаемом городе; threshold
|
||||
принудительно 0 в этом случае (nit-fix, повторная проверка #2894)
|
||||
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
|
||||
- MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
|
||||
estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city',
|
||||
price_rub > 0), не шире когорты платного эстиматора
|
||||
- MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке,
|
||||
выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану — запинено ЖИВЫМ SQL
|
||||
(см. test_max_age_outlier_excluded_from_median_live), не только подстрокой
|
||||
- Повторная проверка #2894 (2026-08): город резолвится ИСКЛЮЧИТЕЛЬНО по
|
||||
lat/lon (ближайший центроид), НЕ по моде `listings.city` (город
|
||||
свип-контекста скрейпера, не адреса объявления) и НЕ по `city_hint`
|
||||
(непроверенный клиентский вход) — см. app.api.v1.trade_in._resolve_coverage_city
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
|
||||
# (trade_in.py imports generate_trade_in_pdf at module load).
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def trade_in_app() -> FastAPI:
|
||||
"""Minimal FastAPI app mounting only the trade-in router with DB overridden."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield MagicMock()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def _row(
|
||||
n_listings: int,
|
||||
median_age_days: float | None,
|
||||
n_with_age: int | None = None,
|
||||
) -> dict:
|
||||
"""Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone().
|
||||
|
||||
n_with_age по умолчанию = n_listings, если не задан явно (большинство старых
|
||||
тестов не проверяют MAJOR-2 отдельно — сохраняем их поведение).
|
||||
|
||||
Повторная проверка #2894: строка больше не несёт cohort_city — город
|
||||
резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг).
|
||||
"""
|
||||
return {
|
||||
"n_listings": n_listings,
|
||||
"median_age_days": median_age_days,
|
||||
"n_with_age": n_with_age if n_with_age is not None else n_listings,
|
||||
}
|
||||
|
||||
|
||||
def _db_mock_returning(row: dict | None) -> MagicMock:
|
||||
"""DB session mock — coverage_probe reads db.execute(...).mappings().fetchone()."""
|
||||
db = MagicMock()
|
||||
mapping_result = MagicMock()
|
||||
mapping_result.fetchone.return_value = row
|
||||
execute_result = MagicMock()
|
||||
execute_result.mappings.return_value = mapping_result
|
||||
db.execute.return_value = execute_result
|
||||
return db
|
||||
|
||||
|
||||
def _override(app: FastAPI, db: MagicMock) -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: (yield db)
|
||||
|
||||
|
||||
# Екатеринбург — совпадает (с точностью до сотен метров) с центроидом
|
||||
# _CITY_CENTROIDS_DEG["Екатеринбург"], поэтому дефолтный payload детерминированно
|
||||
# резолвится в зелёный город без доп. настройки координат в каждом тесте.
|
||||
_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0}
|
||||
|
||||
# Координаты других городов из COVERAGE_GREEN/YELLOW_CITIES (те же значения, что
|
||||
# _CITY_CENTROIDS_DEG в trade_in.py) — используются, когда тесту нужен НЕ ЕКБ.
|
||||
_NIZHNY_TAGIL = {"lat": 57.9099, "lon": 59.9819}
|
||||
_REVDA = {"lat": 56.7986, "lon": 59.9298}
|
||||
_BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034}
|
||||
|
||||
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
|
||||
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
|
||||
# "город не определён", без совпадения ни с одним из 8 центроидов.
|
||||
# Тавда: ближайший из центроидов (Каменск-Уральский) в 271 км, то есть точка
|
||||
# заведомо вне 25-км радиуса любого поддержанного города — при этом всё ещё
|
||||
# Свердловская область, как и задумано тестами ниже.
|
||||
#
|
||||
# Раньше здесь стояла точка 59.6047/60.1970 — окрестности Серова, в 21 км от
|
||||
# его центра. Она работала как «далеко от всех» лишь потому, что Серов не был
|
||||
# поддержан: город предлагался в дропдауне на сайте, но отсутствовал в списках
|
||||
# покрытия, и его житель получал «этот адрес вне области». Серов добавлен
|
||||
# 16.08.2026, и фикстура переехала туда, где действительно далеко.
|
||||
_FAR_AWAY_CITY = {"lat": 58.0424, "lon": 65.2711}
|
||||
|
||||
|
||||
# ── Response schema: NO price anywhere (issue #2894 hard rule) ────────────────
|
||||
|
||||
_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "₽", "cost")
|
||||
|
||||
|
||||
def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
|
||||
"""Regression guard: response schema must never grow a price-shaped field."""
|
||||
from app.schemas.trade_in import CoverageProbeResponse
|
||||
|
||||
field_names = set(CoverageProbeResponse.model_fields.keys())
|
||||
offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||||
assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}"
|
||||
|
||||
|
||||
def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
|
||||
"""Same guard but on a live serialized response (belt-and-suspenders)."""
|
||||
db = _db_mock_returning(_row(10, 21.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||||
assert not offending, f"response body must not carry price fields: {offending} in {data}"
|
||||
|
||||
|
||||
# ── Thresholds: green city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
|
||||
db = _db_mock_returning(_row(8, 15.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["n_listings"] == 8
|
||||
assert data["threshold"] == 8
|
||||
assert data["city"] == "Екатеринбург"
|
||||
assert data["radius_m"] == 1000
|
||||
assert data["median_listing_age_days"] == 15
|
||||
assert data["n_with_age"] == 8
|
||||
|
||||
|
||||
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
|
||||
db = _db_mock_returning(_row(7, 10.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["status"] == "thin"
|
||||
assert data["n_listings"] == 7
|
||||
assert data["threshold"] == 8
|
||||
|
||||
|
||||
# ── Thresholds: yellow city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok. Город резолвится из lat/lon."""
|
||||
db = _db_mock_returning(_row(12, 30.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL})
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["threshold"] == 12
|
||||
assert data["city"] == "Нижний Тагил"
|
||||
|
||||
|
||||
def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Ревда, n=11 (< порог 12) → thin."""
|
||||
db = _db_mock_returning(_row(11, 40.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA})
|
||||
data = resp.json()
|
||||
assert data["status"] == "thin"
|
||||
assert data["threshold"] == 12
|
||||
|
||||
|
||||
# ── City outside all centroids → always not_covered ─────────────────────────────
|
||||
|
||||
|
||||
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
|
||||
"""Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings
|
||||
(даже n=500)."""
|
||||
db = _db_mock_returning(_row(500, 5.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["threshold"] == 0
|
||||
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
|
||||
assert data["city"] == "" # город не определён — не эхуется сырой строкой
|
||||
|
||||
|
||||
# ── Empty cohort ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
|
||||
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.
|
||||
|
||||
Nit-fix (повторная проверка #2894): threshold обязан быть 0, а не реальным
|
||||
порогом города (8) — при not_covered threshold "неприменим" по докстрингу
|
||||
CoverageProbeResponse, независимо от ПРИЧИНЫ not_covered.
|
||||
"""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["n_listings"] == 0
|
||||
assert data["median_listing_age_days"] is None
|
||||
assert data["n_with_age"] == 0
|
||||
assert data["city"] == "Екатеринбург" # город резолвится по координатам всегда
|
||||
assert data["threshold"] == 0 # nit: не 8, хотя город поддерживаемый
|
||||
|
||||
|
||||
def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
|
||||
"""DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но
|
||||
coverage_probe обязан не падать, даже если mock/driver вернул пусто)."""
|
||||
db = _db_mock_returning(None)
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["n_listings"] == 0
|
||||
assert data["median_listing_age_days"] is None
|
||||
assert data["n_with_age"] == 0
|
||||
assert data["threshold"] == 0
|
||||
|
||||
|
||||
# ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ──
|
||||
|
||||
|
||||
def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) -> None:
|
||||
"""Точка в Берёзовском → city='Берёзовский' (а не 'Екатеринбург').
|
||||
|
||||
Регресс на прод-замер (повторная проверка #2894): в радиусе 1000м вокруг
|
||||
Берёзовского 90/90 строк listings имеют city='Екатеринбург' (город
|
||||
свип-контекста скрейпера, миграция 196) — старая логика (мода когорты)
|
||||
отдала бы 'Екатеринбург'. Ручка больше НЕ читает cohort city из SQL вовсе.
|
||||
"""
|
||||
db = _db_mock_returning(_row(8, 5.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY})
|
||||
data = resp.json()
|
||||
assert data["city"] == "Берёзовский"
|
||||
assert data["status"] == "ok"
|
||||
assert data["threshold"] == 8
|
||||
|
||||
|
||||
def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
|
||||
"""Точка за пределами 25 км от всех центроидов → not_covered, city=""."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["city"] == ""
|
||||
assert data["threshold"] == 0
|
||||
|
||||
|
||||
def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None:
|
||||
"""city_hint — чисто информационное поле (повторная проверка #2894): точка в
|
||||
Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский
|
||||
(threshold=8, зелёный порог — оба города зелёные, поэтому дополнительно
|
||||
проверяем n=8 → ok именно для Берёзовского, а не подмену клиентом города).
|
||||
"""
|
||||
db_with_hint = _db_mock_returning(_row(8, 5.0))
|
||||
_override(trade_in_app, db_with_hint)
|
||||
client = TestClient(trade_in_app)
|
||||
resp_with_hint = client.post(
|
||||
"/api/v1/trade-in/coverage",
|
||||
json={**_BASE_PAYLOAD, **_BEREZOVSKY, "city_hint": "Екатеринбург"},
|
||||
)
|
||||
|
||||
db_without_hint = _db_mock_returning(_row(8, 5.0))
|
||||
_override(trade_in_app, db_without_hint)
|
||||
resp_without_hint = client.post(
|
||||
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY}
|
||||
)
|
||||
|
||||
data_with, data_without = resp_with_hint.json(), resp_without_hint.json()
|
||||
assert data_with["city"] == data_without["city"] == "Берёзовский"
|
||||
assert data_with["threshold"] == data_without["threshold"] == 8
|
||||
assert data_with["status"] == data_without["status"] == "ok"
|
||||
|
||||
|
||||
# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ─────────────────
|
||||
|
||||
|
||||
def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
|
||||
"""n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null,
|
||||
даже если SQL посчитал percentile — "медиана" по 1-2 объявлениям не медиана."""
|
||||
from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES
|
||||
|
||||
assert COVERAGE_MIN_AGE_SAMPLES == 5
|
||||
db = _db_mock_returning(_row(20, 40.0, n_with_age=2))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром
|
||||
assert data["n_with_age"] == 2
|
||||
assert data["median_listing_age_days"] is None
|
||||
|
||||
|
||||
def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""n_with_age=5 (== порог) → median_listing_age_days отдаётся."""
|
||||
db = _db_mock_returning(_row(20, 40.0, n_with_age=5))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["n_with_age"] == 5
|
||||
assert data["median_listing_age_days"] == 40
|
||||
|
||||
|
||||
def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
|
||||
"""COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые
|
||||
объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python.
|
||||
|
||||
Слабая (текстовая) проверка — подстрока встречается в SQL ДВАЖДЫ (count и
|
||||
percentile_cont), поэтому `assert "..." in sql_text` одна ловит только
|
||||
"убрали оба FILTER", не "убрали один из двух". Реальный поведенческий пин —
|
||||
test_max_age_outlier_excluded_from_median_live ниже (живой Postgres).
|
||||
"""
|
||||
from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS
|
||||
|
||||
assert COVERAGE_MAX_AGE_DAYS == 365
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
call_args = db.execute.call_args
|
||||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
|
||||
assert params["max_age_days"] == 365
|
||||
|
||||
sql_text = str(call_args[0][0])
|
||||
# count==2: и в count(*) FILTER, и в percentile_cont(...) FILTER — обе нужны,
|
||||
# чтобы n_with_age и median_listing_age_days считались по ОДНОМУ и тому же
|
||||
# предикату (иначе честный n_with_age маскирует нечестный медианный расчёт).
|
||||
assert sql_text.count("days_on_market <= :max_age_days") == 2
|
||||
|
||||
|
||||
# ── DB dedup / cap params passed through ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
|
||||
"""SQL params: radius=1000 (строго), area ±15%, rooms exact."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
assert db.execute.called
|
||||
call_args = db.execute.call_args
|
||||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
|
||||
assert params["radius"] == 1000
|
||||
assert params["rooms"] == 2
|
||||
assert params["area_min"] == pytest.approx(50.0 * 0.85)
|
||||
assert params["area_max"] == pytest.approx(50.0 * 1.15)
|
||||
assert params["fresh_days"] == 14
|
||||
|
||||
|
||||
# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ────────
|
||||
#
|
||||
# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба
|
||||
# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м)
|
||||
# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid
|
||||
# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит
|
||||
# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL —
|
||||
# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py
|
||||
# для опционального real-Postgres-варианта аналогичной проверки в этом репо).
|
||||
|
||||
|
||||
def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
|
||||
"""Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932)."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text
|
||||
|
||||
|
||||
def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None:
|
||||
"""geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E)."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
|
||||
|
||||
|
||||
def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
|
||||
"""price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916)."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "price_rub > 0" in sql_text
|
||||
|
||||
|
||||
# ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ────────────────────
|
||||
#
|
||||
# Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что
|
||||
# test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/
|
||||
# DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test-
|
||||
# заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+
|
||||
# PostGIS-контейнер.
|
||||
#
|
||||
# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в
|
||||
# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и
|
||||
# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла),
|
||||
# И повторно внутри тела единственного live-теста. Теперь доступность БД
|
||||
# проверяется отдельной дешёвой функцией с явным закрытием соединения
|
||||
# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой
|
||||
# (`live_session`) с гарантированным close() в finally, а не ручным вызовом.
|
||||
|
||||
|
||||
def _live_db_available() -> bool:
|
||||
"""Дешёвая проверка доступности live-Postgres — соединение открывается и
|
||||
СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session.
|
||||
|
||||
Используется только в `pytest.mark.skipif(...)`, который вычисляется на
|
||||
этапе сбора тестов — до фикстур.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return False
|
||||
engine = create_engine(dsn, future=True)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(sa_text("SELECT 1"))
|
||||
return True
|
||||
finally:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_session(): # type: ignore[no-untyped-def]
|
||||
"""Session для live-Postgres тестов — гарантированно закрывается после теста
|
||||
(rollback + close + dispose в finally), в отличие от прежнего ручного вызова
|
||||
`_live_session()` внутри тела каждого теста."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
engine = create_engine(dsn, future=True)
|
||||
session_factory = sessionmaker(bind=engine, future=True)
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют
|
||||
# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх
|
||||
# чужой транзакции.
|
||||
_LIVE_LAT, _LIVE_LON = 1.111, 2.222
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
|
||||
def test_major1_cohort_excludes_novostroyki_and_city_precision_live(live_session) -> None: # type: ignore[no-untyped-def]
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.api.v1.trade_in import coverage_probe
|
||||
from app.schemas.trade_in import CoverageProbeInput
|
||||
|
||||
db = live_session
|
||||
rows = [
|
||||
# (source_url suffix, listing_segment, geo_precision, price_rub) — все
|
||||
# остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW().
|
||||
("ok-vtorichka", None, None, 5_000_000), # counted
|
||||
("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded
|
||||
("bad-city-precision", None, "city", 5_000_000), # excluded
|
||||
("bad-zero-price", None, None, 0), # excluded
|
||||
]
|
||||
for suffix, segment, geo_precision, price in rows:
|
||||
url = f"https://test.invalid/coverage-major1-{suffix}"
|
||||
db.execute(
|
||||
sa_text(
|
||||
"""
|
||||
INSERT INTO listings
|
||||
(source, source_url, source_id, dedup_hash, address, lat, lon,
|
||||
rooms, area_m2, price_rub, is_active, scraped_at,
|
||||
listing_segment, geo_precision)
|
||||
VALUES
|
||||
('test', :url, :url, :url, 'test addr', :lat, :lon,
|
||||
2, 50.0, :price, true, NOW(), :segment, :geo_precision)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"url": url,
|
||||
"lat": _LIVE_LAT,
|
||||
"lon": _LIVE_LON,
|
||||
"price": price,
|
||||
"segment": segment,
|
||||
"geo_precision": geo_precision,
|
||||
},
|
||||
)
|
||||
|
||||
result = coverage_probe(
|
||||
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
|
||||
)
|
||||
# Только первая (ok-vtorichka) строка должна попадать в когорту —
|
||||
# каждая следующая вставка не должна сдвигать счётчик.
|
||||
assert result.n_listings == 1, (
|
||||
f"predicate regression: n_listings={result.n_listings} after inserting "
|
||||
f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} "
|
||||
f"price={price}) — expected still 1 (only ok-vtorichka counted)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
|
||||
def test_max_age_outlier_excluded_from_median_live(live_session) -> None: # type: ignore[no-untyped-def]
|
||||
"""MAJOR-2 поведенческий пин (повторная проверка #2894).
|
||||
|
||||
Текстовый тест (test_max_age_outlier_days_passed_to_sql) проверял, что
|
||||
подстрока `days_on_market <= :max_age_days` встречается в SQL — но она там
|
||||
ДВАЖДЫ (count и percentile_cont), и мутация «убрать FILTER у
|
||||
percentile_cont, оставив у count» проходила зелёной: n_with_age (из count)
|
||||
оставался честным, а percentile_cont без FILTER считал медиану по ВСЕМ
|
||||
days_on_market, включая выбросы.
|
||||
|
||||
Вставляет когорту из 5 "нормальных" объявлений (days_on_market
|
||||
4/6/8/10/12, честная медиана — 8) и один выброс (days_on_market=4000,
|
||||
> COVERAGE_MAX_AGE_DAYS=365). Проверяет, что после вставки выброса
|
||||
n_with_age и median_listing_age_days НЕ меняются (выброс попадает только
|
||||
в n_listings) — с правильными двумя FILTER это так; без FILTER у
|
||||
percentile_cont медиана сдвинулась бы 8 → 9 (percentile_cont(0.5) по
|
||||
[4,6,8,10,12,4000] = среднее 3-го и 4-го отсортированных значений = 9).
|
||||
"""
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.api.v1.trade_in import coverage_probe
|
||||
from app.schemas.trade_in import CoverageProbeInput
|
||||
|
||||
db = live_session
|
||||
normal_ages = [4, 6, 8, 10, 12]
|
||||
for i, age in enumerate(normal_ages):
|
||||
url = f"https://test.invalid/coverage-major2-normal-{i}"
|
||||
db.execute(
|
||||
sa_text(
|
||||
"""
|
||||
INSERT INTO listings
|
||||
(source, source_url, source_id, dedup_hash, address, lat, lon,
|
||||
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
|
||||
VALUES
|
||||
('test', :url, :url, :url, :addr, :lat, :lon,
|
||||
2, 50.0, 5000000, true, NOW(), :age)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"url": url,
|
||||
"addr": f"test addr coverage-major2-{i}",
|
||||
"lat": _LIVE_LAT,
|
||||
"lon": _LIVE_LON,
|
||||
"age": age,
|
||||
},
|
||||
)
|
||||
|
||||
result = coverage_probe(
|
||||
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
|
||||
)
|
||||
assert result.n_listings == 5
|
||||
assert result.n_with_age == 5
|
||||
assert result.median_listing_age_days == 8
|
||||
|
||||
outlier_url = "https://test.invalid/coverage-major2-outlier"
|
||||
db.execute(
|
||||
sa_text(
|
||||
"""
|
||||
INSERT INTO listings
|
||||
(source, source_url, source_id, dedup_hash, address, lat, lon,
|
||||
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
|
||||
VALUES
|
||||
('test', :url, :url, :url, 'test addr coverage-major2-outlier', :lat, :lon,
|
||||
2, 50.0, 5000000, true, NOW(), 4000)
|
||||
"""
|
||||
),
|
||||
{"url": outlier_url, "lat": _LIVE_LAT, "lon": _LIVE_LON},
|
||||
)
|
||||
|
||||
result_with_outlier = coverage_probe(
|
||||
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
|
||||
)
|
||||
assert result_with_outlier.n_listings == 6 # выброс всё же попадает в n_listings
|
||||
assert result_with_outlier.n_with_age == 5, (
|
||||
f"MAJOR-2 regression: outlier (days_on_market=4000 > MAX=365) leaked into "
|
||||
f"n_with_age={result_with_outlier.n_with_age} — count(*) FILTER пропал/сломан"
|
||||
)
|
||||
assert result_with_outlier.median_listing_age_days == 8, (
|
||||
f"MAJOR-2 regression: median_listing_age_days="
|
||||
f"{result_with_outlier.median_listing_age_days} shifted by outlier — "
|
||||
f"percentile_cont(...) FILTER пропал (мутация «убрать FILTER у "
|
||||
f"percentile_cont, оставив у count»)"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -126,13 +126,21 @@ def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[st
|
|||
|
||||
|
||||
def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ни одно из 127 доказанно ложных снятий не должно повториться.
|
||||
"""Ни одно из 127 доказанно ложных снятий (cian/yandex) не должно повториться.
|
||||
|
||||
Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть
|
||||
строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон
|
||||
снимет ту же строку снова.
|
||||
строго выше этого возраста на cian/yandex-срезах — иначе следующий прогон
|
||||
снимет ту же строку снова. avito из этого цикла исключён намеренно: 127
|
||||
доказанных ложных снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex,
|
||||
у avito другой сценарий и своя проверка ниже
|
||||
(test_avito_prod_floor_is_capped_by_calibrated_cap_mult) -- калибровка cap_mult=6
|
||||
для avito (миграция 264_deactivate_stale_avito_cap_mult.sql) пиннится ТАМ, а не
|
||||
здесь, чтобы не смешивать два разных замера под одним порогом
|
||||
_FALSE_KILL_AGE_MAX, который к avito не относится.
|
||||
"""
|
||||
for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items():
|
||||
if source == "avito":
|
||||
continue
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
|
|
@ -154,6 +162,108 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey
|
|||
), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
|
||||
|
||||
|
||||
def _read_cap_mult_from_migration(filename: str, *, source: str) -> int:
|
||||
"""Читает cap_mult из UPDATE default_params миграции -- НЕ хардкодит дубль в тесте.
|
||||
|
||||
Найдено ревью круга 3 2026-08-15: раньше тест ниже принимал cap_mult=6 как
|
||||
аргумент напрямую, захардкоженный прямо в теле теста. Мутация значения в
|
||||
264_deactivate_stale_avito_cap_mult.sql (6 -> 2) НЕ трогала вход теста вовсе --
|
||||
набор оставался зелёным при любом реальном значении в миграции, то есть
|
||||
калибровка нигде не была пином, только упоминанием в комментарии. Здесь
|
||||
значение читается ИЗ ФАЙЛА миграции regex'ом, а ожидаемый результат
|
||||
(ttl_days_effective, ttl_floor_capped) остаётся зафиксированным числом в самом
|
||||
тесте -- так дрейф калибровки в миграции ломает тест, как и задумано.
|
||||
"""
|
||||
migration = Path(__file__).resolve().parents[1] / "data" / "sql" / filename
|
||||
src = migration.read_text("utf-8")
|
||||
# Порядок в файле -- jsonb_build_object('cap_mult', N) в SET, ЗАТЕМ WHERE source
|
||||
# = '<source>' ниже (см. 264/265_*.sql). DOTALL матчит перевод строки между ними;
|
||||
# source в regex -- страховка от чтения не того UPDATE, если файл когда-нибудь
|
||||
# станет мульти-source (сейчас в каждом файле ровно один UPDATE).
|
||||
match = re.search(
|
||||
r"jsonb_build_object\('cap_mult',\s*(\d+)\).*?WHERE\s+source\s*=\s*'"
|
||||
+ re.escape(source)
|
||||
+ r"'",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match is not None, (
|
||||
f"{filename} сменил формат UPDATE default_params для source={source!r} -- "
|
||||
"обнови regex в _read_cap_mult_from_migration"
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пиннит калибровку cap_mult=6 для avito (миграция
|
||||
264_deactivate_stale_avito_cap_mult.sql) на измеренном прод-поле _PROD_FLOORS
|
||||
("avito/все сегменты" = 69.7, замер 2026-08-09).
|
||||
|
||||
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции (regex), не хардкодится
|
||||
здесь: дрейф калибровки в 264_*.sql (например 6 -> 2) меняет вход, но НЕ
|
||||
ожидаемый результат ниже (60/70) -- эти числа пинят калибровку саму по себе,
|
||||
поэтому дрейф ломает тест, как и задумано (см. _read_cap_mult_from_migration).
|
||||
|
||||
С дефолтным cap_mult=2 потолок avito (20 сут) РЕЖЕТ ниже собственного хвоста
|
||||
переобхода p99=42.1 (_REVISIT_TAIL) -- ровно тот false-kill, ради которого пол
|
||||
заведён. С калиброванным cap_mult=6 потолок 60 сут -- выше и p99=42.1, и живого
|
||||
прод-пика 52 (замер 08-10..08-12), и этого гипотетического замера 69.7 (капается
|
||||
ровно на 60, не пропускается как есть).
|
||||
"""
|
||||
calibrated_cap_mult = _read_cap_mult_from_migration(
|
||||
"264_deactivate_stale_avito_cap_mult.sql", source="deactivate_stale_avito"
|
||||
)
|
||||
source, segments, ttl_days, floor = _PROD_FLOORS["avito/все сегменты"]
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source=source,
|
||||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
cap_mult=calibrated_cap_mult,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 60, "cap_mult из миграции 264 обязан дать потолок 60"
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу"
|
||||
|
||||
|
||||
def test_yandex_prod_floor_is_not_capped_by_calibrated_cap_mult(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Пиннит калибровку cap_mult=3 для yandex (миграция
|
||||
265_deactivate_stale_yandex_cap_mult.sql, найдено ревью круга 3 2026-08-15) на
|
||||
измеренном прод-поле _PROD_FLOORS ("yandex/vtorichka" = 74.3).
|
||||
|
||||
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции 265 (тот же приём, что и у
|
||||
avito выше): дрейф калибровки в 265_*.sql ломает тест.
|
||||
|
||||
С дефолтным cap_mult=2 потолок yandex (60 сут) РЕЖЕТ живой пол (75-79 сут,
|
||||
scrape_runs.counters 08-10..08-15 и live-замер 08-15) -- та же дыра, что у
|
||||
avito, найдена позже (первая версия 264 ошибочно считала yandex безопасным по
|
||||
устаревшему статическому p99=43.0). С калиброванным cap_mult=3 потолок 90 сут
|
||||
выше живого пика 79.2 -- пол 74.3 из этого теста НЕ капается, эффективный TTL
|
||||
равен сырому полу (75, ceil(74.3)).
|
||||
"""
|
||||
calibrated_cap_mult = _read_cap_mult_from_migration(
|
||||
"265_deactivate_stale_yandex_cap_mult.sql", source="deactivate_stale_yandex"
|
||||
)
|
||||
source, segments, ttl_days, floor = _PROD_FLOORS["yandex/vtorichka"]
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source=source,
|
||||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
cap_mult=calibrated_cap_mult,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 75, "ceil(74.3) == 75, потолок 90 не должен резать"
|
||||
assert "ttl_floor_capped" not in out, "потолок 90 выше живого пола 74.3 -- капать нечего"
|
||||
|
||||
|
||||
def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше."""
|
||||
assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue