merge(tradein/payments): влить main в feat/tradein-payments-perimeter-hardening
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m56s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m56s
Слияние main принесло собственные Sentry-скрубберы (redact_telegram_bot_token + stabilize_retry_error_fingerprint) в app/main.py и app/scheduler_main.py — конфликт разрешён композицией, а не выбором стороны: обработчик перед отправкой в GlitchTip теперь прогоняет событие через всю цепочку в указанном порядке: scrub_payment_request_body → scrub_pii_event → redact_telegram_bot_token → stabilize_retry_error_fingerprint (main.py), и без redact_telegram_bot_token в scheduler_main.py (тот процесс не держит TelegramClient) — оба канала, before_send и before_send_transaction, используют один и тот же обработчик. tests/test_sentry_scrub.py: тесты обеих сторон объединены без потерь — PR-D2 платёжный composed-тест (body-wipe + PII-scrub + token-redaction) и весь блок RetryError fingerprint-стабилизации из main сосуществуют в одном файле.
This commit is contained in:
commit
a8fa7364ae
241 changed files with 28308 additions and 1819 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 → НЕ триггерит деплой
|
||||
|
|
|
|||
|
|
@ -116,7 +116,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
|
||||
|
|
@ -221,7 +221,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 — тоже, а каталог
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ jobs:
|
|||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||
postgres:16
|
||||
|
|
@ -233,11 +233,18 @@ jobs:
|
|||
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
|
||||
# term-missing → видно непокрытые строки прямо в job-логе.
|
||||
run: |
|
||||
# #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит
|
||||
# в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как
|
||||
# «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется:
|
||||
# ниже `exit $rc`.
|
||||
rc=0
|
||||
uv run pytest -q -rs --ignore=tests/smoke \
|
||||
--cov=app \
|
||||
--cov-report=term-missing:skip-covered \
|
||||
--cov-report=xml:coverage.xml \
|
||||
--cov-fail-under=65
|
||||
--cov-fail-under=65 || rc=$?
|
||||
echo "### pytest вернул код $rc"
|
||||
exit $rc
|
||||
|
||||
- name: Coverage summary → job output
|
||||
# Дешёвый human-readable итог. Бежит даже если gate упал (if: always) —
|
||||
|
|
@ -246,20 +253,34 @@ jobs:
|
|||
# если переменная пустая/файла нет, печатаем в обычный лог (fallback).
|
||||
if: always()
|
||||
run: |
|
||||
echo "### шаг «Coverage summary» начался"
|
||||
[ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; }
|
||||
report="$(uv run coverage report --skip-covered --sort=cover | tail -40)"
|
||||
# NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с
|
||||
# кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` —
|
||||
# то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал
|
||||
# неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден.
|
||||
# `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после
|
||||
# упавшей команды просто не выполнится, и код возврата снова потеряется.
|
||||
cov_rc=0
|
||||
uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$?
|
||||
echo "### coverage report вернул код $cov_rc"
|
||||
report="$(tail -40 /tmp/cov_report.txt)"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{ echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$report"
|
||||
fi
|
||||
echo "### шаг «Coverage summary» закончился успешно"
|
||||
|
||||
- name: Снести тестовый Postgres
|
||||
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
|
||||
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
|
||||
if: always()
|
||||
working-directory: .
|
||||
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
run: |
|
||||
echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})"
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
echo "### шаг «Снести тестовый Postgres» закончился успешно"
|
||||
|
||||
frontend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -30,11 +30,26 @@ jobs:
|
|||
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
|
||||
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
|
||||
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
|
||||
app_version: ${{ steps.build-meta.outputs.app_version }}
|
||||
build_sha: ${{ steps.build-meta.outputs.build_sha }}
|
||||
build_date: ${{ steps.build-meta.outputs.build_date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
|
||||
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
|
||||
# проброшены как build-args в build-backend/build-frontend ниже (см.
|
||||
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
|
||||
# Считается ОДИН раз здесь, а не в каждой job отдельно.
|
||||
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
|
||||
id: build-meta
|
||||
run: |
|
||||
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
|
||||
# The file is written by the deploy job on every successful deploy.
|
||||
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
|
||||
|
|
@ -107,8 +122,20 @@ jobs:
|
|||
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
|
||||
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
|
||||
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
|
||||
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
|
||||
- 'tradein-mvp/VERSION'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
|
||||
# причина, что у backend выше.
|
||||
- 'tradein-mvp/VERSION'
|
||||
# /versions статически запекает CHANGELOG.md в билд (см.
|
||||
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
|
||||
# frontend/** иначе не долетала бы до образа.
|
||||
- 'tradein-mvp/CHANGELOG.md'
|
||||
browser:
|
||||
- 'tradein-mvp/browser/**'
|
||||
infra:
|
||||
|
|
@ -200,10 +227,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
|
||||
|
|
@ -211,12 +277,65 @@ jobs:
|
|||
context: ./tradein-mvp
|
||||
file: ./tradein-mvp/backend/Dockerfile
|
||||
push: true
|
||||
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
|
||||
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
|
||||
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ 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
|
||||
|
|
@ -233,10 +352,55 @@ 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 за пределы контекста,
|
||||
# поэтому копируем внутрь ДО build. /versions статически запекает его
|
||||
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
|
||||
# комментарий). Не влияет на кэш другого шага — читается только этим.
|
||||
- name: Stage CHANGELOG.md into frontend build context
|
||||
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
|
||||
|
|
@ -246,15 +410,62 @@ jobs:
|
|||
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
||||
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
||||
# уходит в notFound (404), не индексируется и не краулится.
|
||||
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
||||
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
||||
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
||||
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-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ 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
|
||||
|
|
@ -273,10 +484,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
|
||||
|
|
@ -287,6 +535,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]
|
||||
|
|
@ -821,3 +1104,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,11 @@ on:
|
|||
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
|
||||
# до прода до следующего чужого коммита в backend/.
|
||||
- "ops/db-bootstrap/**"
|
||||
# То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM
|
||||
# по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard`
|
||||
# шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами
|
||||
# исполнял бы старую версию — молча и без единого сигнала.
|
||||
- "ops/docker-prune.sh"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -71,10 +76,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 +130,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 +193,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 +245,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 +300,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 +354,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 +712,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 "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
25
Caddyfile
25
Caddyfile
|
|
@ -251,6 +251,31 @@ meraocenka.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
|
||||
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
|
||||
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
|
||||
# обязаны резолвиться сами по себе, а не только длинным
|
||||
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
|
||||
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
|
||||
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
|
||||
# короткого /oferta нет.
|
||||
#
|
||||
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
|
||||
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
|
||||
# который в ней указан. Каноничность для поисковиков задана отдельно, через
|
||||
# `alternates.canonical` на каждой из трёх страниц.
|
||||
#
|
||||
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
|
||||
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
|
||||
# путь проксируется» ради трёх страниц нельзя.
|
||||
@meraLegalDocs path /oferta /refund /privacy
|
||||
handle @meraLegalDocs {
|
||||
rewrite * /trade-in/mera-public{path}
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up -X-Authenticated-User
|
||||
}
|
||||
}
|
||||
|
||||
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
|
||||
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
|
||||
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
|
||||
|
|
|
|||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
.coverage
|
||||
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
|
||||
coverage.xml
|
||||
|
|
|
|||
|
|
@ -2189,12 +2189,21 @@ def analyze_parcel(
|
|||
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
|
||||
_weights_source = "inline"
|
||||
else:
|
||||
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||
_weights_source = (
|
||||
"profile"
|
||||
if profile_id is not None
|
||||
else ("user_default" if profile_user_id is not None else "system")
|
||||
)
|
||||
# Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811):
|
||||
# profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и
|
||||
# тогда веса системные или дефолтные, а не профильные.
|
||||
_resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||
_effective_weights = _resolved.weights
|
||||
_weights_source = _resolved.source
|
||||
|
||||
# «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит,
|
||||
# был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" —
|
||||
# иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли».
|
||||
# None когда profile_id не передавали; False когда передали, но применилось
|
||||
# другое (не найден / чужой / перебит inline-весами).
|
||||
_requested_profile_applied: bool | None = (
|
||||
None if profile_id is None else _weights_source == "profile"
|
||||
)
|
||||
|
||||
# 4) Scoring: weighted sum с distance decay
|
||||
score = 0.0
|
||||
|
|
@ -2310,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
|
||||
|
|
@ -4085,9 +4113,12 @@ def analyze_parcel(
|
|||
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
|
||||
"program_alternatives": program_alternatives,
|
||||
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||
# source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли
|
||||
# удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали.
|
||||
"weights_profile": {
|
||||
"source": _weights_source,
|
||||
"profile_id": profile_id,
|
||||
"requested_profile_applied": _requested_profile_applied,
|
||||
"user_id": profile_user_id,
|
||||
"weights_applied": _effective_weights,
|
||||
"inline_weights": _inline_weights,
|
||||
|
|
@ -4203,6 +4234,7 @@ def analyze_parcel(
|
|||
"profile_user_id": profile_user_id,
|
||||
"inline_weights": _inline_weights,
|
||||
"weights_source": _weights_source,
|
||||
"requested_profile_applied": _requested_profile_applied,
|
||||
"x_session_id": _session_id,
|
||||
},
|
||||
district=_district_name,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ API surface:
|
|||
- create_profile(db, payload) → WeightProfile
|
||||
- update_profile(db, user_id, profile_id, payload) → WeightProfile | None
|
||||
- delete_profile(db, user_id, profile_id) → bool
|
||||
- resolve_weights(db, user_id, profile_id) → dict[str, float]
|
||||
- resolve_weights(db, user_id, profile_id) → ResolvedWeights(weights, source)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,7 +19,7 @@ import json
|
|||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
|
|
@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
|
||||
"""Вернуть эффективные веса для analyze_parcel.
|
||||
class ResolvedWeights(NamedTuple):
|
||||
"""Веса + КАКОЙ источник фактически применился (#2811).
|
||||
|
||||
Лестница приоритетов ниже по построению стирает разницу между «взял, что
|
||||
просили» и «не нашёл, взял что было» — а метка в ответе /analyze строится
|
||||
именно на этой разнице. Поэтому источник возвращается вместе с весами, а не
|
||||
выводится вызывающим из своих же входных параметров. NamedTuple, а не голый
|
||||
dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко,
|
||||
молча «весами» этот объект не притворится.
|
||||
"""
|
||||
|
||||
weights: dict[str, float]
|
||||
source: str # "profile" | "user_default" | "system"
|
||||
|
||||
|
||||
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights:
|
||||
"""Вернуть эффективные веса для analyze_parcel + фактический их источник.
|
||||
|
||||
Порядок приоритетов:
|
||||
1. profile_id задан → загрузить именно этот профиль
|
||||
2. user_id задан → загрузить default-профиль пользователя
|
||||
3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS
|
||||
1. profile_id задан → загрузить именно этот профиль → source="profile"
|
||||
2. user_id задан → загрузить default-профиль пользователя → source="user_default"
|
||||
3. Иначе → системные значения _SYSTEM_POI_WEIGHTS → source="system"
|
||||
|
||||
Запрошенный, но НЕ применённый profile_id — не тишина: warning с
|
||||
идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для
|
||||
/analyze — необязательный модификатор, а не адресуемый ресурс; 404 превратил
|
||||
бы гонку «профиль удалили между списком и анализом» в отказ вместо честно
|
||||
помеченного ответа. Клиенту хватает source + requested_profile_not_found.
|
||||
"""
|
||||
if profile_id is not None and user_id is not None:
|
||||
profile = get_profile(db, user_id, profile_id)
|
||||
|
|
@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic
|
|||
logger.debug(
|
||||
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
|
||||
)
|
||||
return dict(profile.weights)
|
||||
return ResolvedWeights(dict(profile.weights), "profile")
|
||||
|
||||
resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system")
|
||||
if user_id is not None:
|
||||
profile = get_default_profile(db, user_id)
|
||||
if profile is not None and profile.weights:
|
||||
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
|
||||
return dict(profile.weights)
|
||||
resolved = ResolvedWeights(dict(profile.weights), "user_default")
|
||||
|
||||
logger.debug("resolve_weights: returning system defaults")
|
||||
return dict(_SYSTEM_POI_WEIGHTS)
|
||||
if profile_id is not None:
|
||||
# Сюда попадаем, если запрошенный профиль не применился: owner не передан
|
||||
# (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights
|
||||
# пустые. Раньше это был logger.debug, которого на проде нет, — и оценка
|
||||
# молча считалась не по тем весам (#2811, ранее #2788).
|
||||
logger.warning(
|
||||
"resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — "
|
||||
"фактический источник весов %r",
|
||||
profile_id,
|
||||
user_id,
|
||||
resolved.source,
|
||||
)
|
||||
else:
|
||||
logger.debug("resolve_weights: источник весов %s", resolved.source)
|
||||
return resolved
|
||||
|
|
|
|||
|
|
@ -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), НЕ сырой.
|
||||
|
||||
|
|
|
|||
|
|
@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None:
|
|||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_analyze_missing_profile_is_not_labelled_profile() -> None:
|
||||
"""#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'.
|
||||
|
||||
Три способа промахнуться мимо профиля (все три воспроизведены живым запросом
|
||||
на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id.
|
||||
В mock-БД профилей нет — значит применились системные веса, и ответ обязан
|
||||
это признать, а не утверждать, что считал по профилю.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS
|
||||
|
||||
for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"):
|
||||
db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
wp = resp.json()["weights_profile"]
|
||||
# sanity: веса и правда системные, промах реальный
|
||||
assert wp["weights_applied"]["tram_stop"] == pytest.approx(
|
||||
_SYSTEM_POI_WEIGHTS["tram_stop"]
|
||||
)
|
||||
assert wp["source"] != "profile", (
|
||||
f"?{qs}: применились системные веса, а метка source='profile' — "
|
||||
"ответ утверждает то, чего не было (#2811)"
|
||||
)
|
||||
assert wp["source"] == "system"
|
||||
# «что просили» не теряется: запрошенный id + явный признак промаха
|
||||
assert wp["profile_id"] == 999999
|
||||
assert wp["requested_profile_applied"] is False
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_analyze_found_profile_keeps_label_and_flag() -> None:
|
||||
"""Обратная сторона: профиль найден → source='profile', флаг промаха False."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import app.services.site_finder.weight_profiles as wp_module
|
||||
from app.core.db import get_db
|
||||
from app.services.site_finder.weight_profiles import WeightProfile
|
||||
|
||||
profile = WeightProfile(
|
||||
id=7,
|
||||
user_id="user-1",
|
||||
profile_name="test",
|
||||
weights={"tram_stop": -0.4},
|
||||
is_default=False,
|
||||
description=None,
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
db = _make_db_for_analyze()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
original = wp_module.get_profile
|
||||
wp_module.get_profile = lambda _db, uid, pid: profile
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
wp = resp.json()["weights_profile"]
|
||||
assert wp["source"] == "profile"
|
||||
assert wp["requested_profile_applied"] is True
|
||||
assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4)
|
||||
finally:
|
||||
wp_module.get_profile = original
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет:
|
|||
- resolve_weights: нет user_id и profile_id → системные дефолты
|
||||
- resolve_weights: user_id задан, default-профиль есть → его веса
|
||||
- resolve_weights: profile_id задан → его веса
|
||||
- resolve_weights: профиль не найден → системные дефолты (fallback)
|
||||
- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None:
|
|||
"""Оба аргумента None → возвращаются системные веса."""
|
||||
db = MagicMock()
|
||||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert result.source == "system"
|
||||
# db не должен вызываться вообще
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
|
@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None:
|
|||
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
|
||||
db = MagicMock()
|
||||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||
result["school"] = 999.0
|
||||
result.weights["school"] = 999.0
|
||||
# Оригинал не изменён
|
||||
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
|
||||
|
||||
|
|
@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None:
|
|||
finally:
|
||||
wp_module.get_default_profile = original
|
||||
|
||||
assert result == custom_weights
|
||||
assert result.weights == custom_weights
|
||||
assert result.source == "user_default"
|
||||
|
||||
|
||||
def test_resolve_weights_uses_specific_profile() -> None:
|
||||
|
|
@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None:
|
|||
finally:
|
||||
wp_module.get_profile = original
|
||||
|
||||
assert result == custom_weights
|
||||
assert result.weights == custom_weights
|
||||
assert result.source == "profile"
|
||||
|
||||
|
||||
def test_resolve_weights_profile_not_found_fallback() -> None:
|
||||
|
|
@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None:
|
|||
wp_module.get_profile = original_get
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
# #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
|
||||
assert result.source == "system"
|
||||
|
||||
|
||||
def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
||||
|
|
@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
|||
finally:
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert result.source == "system"
|
||||
|
||||
|
||||
def test_resolve_weights_profile_id_without_owner_is_not_profile(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
|
||||
|
||||
Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
|
||||
source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
|
||||
-0.4). Метка обязана быть 'system', а промах — попасть в warning.
|
||||
"""
|
||||
db = MagicMock()
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||||
result = resolve_weights(db, user_id=None, profile_id=1)
|
||||
|
||||
assert result.source == "system"
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert "profile_id=1" in caplog.text
|
||||
db.execute.assert_not_called() # профиль даже не искали
|
||||
|
||||
|
||||
def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
|
||||
|
||||
Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
|
||||
видна. Метка должна сказать 'user_default', а не 'profile'.
|
||||
"""
|
||||
import app.services.site_finder.weight_profiles as wp_module
|
||||
|
||||
default_profile = _make_profile_mock({"school": 2.0})
|
||||
db = MagicMock()
|
||||
original_get = wp_module.get_profile
|
||||
original_default = wp_module.get_default_profile
|
||||
wp_module.get_profile = lambda _db, uid, pid: None
|
||||
wp_module.get_default_profile = lambda _db, uid: default_profile
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||||
result = resolve_weights(db, user_id="user-1", profile_id=999)
|
||||
finally:
|
||||
wp_module.get_profile = original_get
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result.source == "user_default"
|
||||
assert result.weights == {"school": 2.0}
|
||||
assert "profile_id=999" in caplog.text
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At
|
|||
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
|
||||
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
||||
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
|
||||
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||
import {
|
||||
AnalyzeWeightsContext,
|
||||
adaptEgrn,
|
||||
useParcelAnalyzeQuery,
|
||||
} from "@/lib/site-finder-api";
|
||||
import type { PoiCategoryKey } from "@/lib/api/weightProfiles";
|
||||
import type {
|
||||
ParcelAnalysis,
|
||||
PendingConceptProgram,
|
||||
|
|
@ -29,7 +34,40 @@ interface Props {
|
|||
|
||||
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
|
||||
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
|
||||
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
|
||||
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
|
||||
* анализа — свой у шапки, свой у секций.
|
||||
*
|
||||
* null = веса не применяли → запрос как раньше, без тела.
|
||||
*/
|
||||
export function AnalysisPageContent({ cad }: Props) {
|
||||
const [appliedWeights, setAppliedWeights] = useState<Record<
|
||||
PoiCategoryKey,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
return (
|
||||
<AnalyzeWeightsContext.Provider value={appliedWeights}>
|
||||
<AnalysisPageBody
|
||||
cad={cad}
|
||||
appliedWeights={appliedWeights}
|
||||
onWeightsApply={setAppliedWeights}
|
||||
/>
|
||||
</AnalyzeWeightsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisPageBody({
|
||||
cad,
|
||||
appliedWeights,
|
||||
onWeightsApply,
|
||||
}: Props & {
|
||||
appliedWeights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [horizon, setHorizon] = useState<number>(12);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
||||
<GroupDivider label="Стройка и рынок" />
|
||||
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
|
||||
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1
|
||||
поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор
|
||||
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
|
||||
<Section3SettingsAndCompetitors
|
||||
cad={cad}
|
||||
data={analysis}
|
||||
weights={appliedWeights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
||||
<Section5Atmosphere cad={cad} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло.
|
||||
*
|
||||
* Состояние весов жило в `Section31Settings` и читалось только обратно в ту же
|
||||
* панель: до `/analyze` оно не доезжало никогда (слова `weights` в
|
||||
* AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал
|
||||
* «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам.
|
||||
*
|
||||
* Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и
|
||||
* настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты,
|
||||
* прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй
|
||||
* POST /analyze не случается вообще → красный.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisPageContent } from "../AnalysisPageContent";
|
||||
|
||||
// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут
|
||||
// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) —
|
||||
// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста.
|
||||
vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null }));
|
||||
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
|
||||
GateVerdictBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/HorizonSelector", () => ({
|
||||
HorizonSelector: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
|
||||
Section1ParcelInfo: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
|
||||
Section2NetworksUtilities: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
|
||||
Section4Estimate: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
|
||||
Section5Atmosphere: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
|
||||
Section6Forecast: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({
|
||||
Section7Concept: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
|
||||
SectionAlternatives: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({
|
||||
BestLayoutsBlock: () => null,
|
||||
}));
|
||||
|
||||
const CAD = "66:41:0702017:131";
|
||||
|
||||
const ANALYSIS = {
|
||||
cad_num: CAD,
|
||||
score: 18.91,
|
||||
district: { district_name: "Чкаловский" },
|
||||
egrn: null,
|
||||
competitors: [],
|
||||
};
|
||||
|
||||
/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */
|
||||
const analyzeBodies: Array<Record<string, unknown> | undefined> = [];
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
analyzeBodies.length = 0;
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
if (url.includes("/analyze")) {
|
||||
const raw = init?.body;
|
||||
analyzeBodies.push(
|
||||
typeof raw === "string"
|
||||
? (JSON.parse(raw) as Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
return jsonResponse(ANALYSIS);
|
||||
}
|
||||
if (url.includes("/api/v1/me")) {
|
||||
return jsonResponse({
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
allowed_paths: ["/**"],
|
||||
deny_paths: [],
|
||||
});
|
||||
}
|
||||
if (url.includes("/weight-profiles")) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent cad={CAD} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Ползунок конкретной категории по подписи строки в панели весов. */
|
||||
function sliderFor(label: string): HTMLInputElement {
|
||||
const row = screen.getByText(label).closest("div");
|
||||
if (!row) throw new Error(`не нашёл строку ползунка «${label}»`);
|
||||
const input = row.querySelector('input[type="range"]');
|
||||
if (!input) throw new Error(`в строке «${label}» нет ползунка`);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => {
|
||||
it("отправляет ползунки в тело повторного analyze", async () => {
|
||||
renderPage();
|
||||
|
||||
// Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе,
|
||||
// бэкенд считает по системным. Это же и baseline для «стало другим».
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(1));
|
||||
expect(analyzeBodies[0]).toBeUndefined();
|
||||
|
||||
fireEvent.click(await screen.findByText("POI Веса"));
|
||||
fireEvent.change(sliderFor("Парки"), { target: { value: "3" } });
|
||||
fireEvent.change(sliderFor("Трамвайные ост. (−)"), {
|
||||
target: { value: "-2" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
|
||||
// Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что
|
||||
// выставлены ползунками. До фикса второго запроса не было — красный здесь.
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(2));
|
||||
const applied = analyzeBodies[1]?.weights as Record<string, number>;
|
||||
expect(applied.park).toBe(3);
|
||||
expect(applied.tram_stop).toBe(-2);
|
||||
// Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
|
||||
// но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
|
||||
expect(applied.school).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
POI_LABELS,
|
||||
POI_WEIGHT_MAX,
|
||||
POI_WEIGHT_MIN,
|
||||
SYSTEM_PROFILE_USER_ID,
|
||||
useCreateProfile,
|
||||
useWeightProfiles,
|
||||
type PoiCategoryKey,
|
||||
|
|
@ -110,7 +111,18 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
|||
}
|
||||
|
||||
function handleApply() {
|
||||
onWeightsChange({ ...draft }, selectedProfileId);
|
||||
// Системный пресет не адресуем через profile_id: resolve_weights() ищет
|
||||
// профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не
|
||||
// текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и
|
||||
// отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для
|
||||
// пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса,
|
||||
// а они ровно те, что на ползунках.
|
||||
const selected = profiles.find((p) => p.id === selectedProfileId) ?? null;
|
||||
const addressableId =
|
||||
selected && selected.user_id !== SYSTEM_PROFILE_USER_ID
|
||||
? selected.id
|
||||
: null;
|
||||
onWeightsChange({ ...draft }, addressableId);
|
||||
}
|
||||
|
||||
const handleSaveProfile = useCallback(async () => {
|
||||
|
|
@ -258,6 +270,7 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
|||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.profile_name}
|
||||
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
|
||||
{p.is_default ? " ★" : ""}
|
||||
</option>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ interface Props {
|
|||
cad: string;
|
||||
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
||||
data: ParcelAnalysis;
|
||||
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
|
|
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
|
|||
function Section31Settings({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: {
|
||||
filters: FilterState;
|
||||
onFiltersChange: (f: FilterState) => void;
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
|
||||
() => ({ ...POI_DEFAULT_WEIGHTS }),
|
||||
);
|
||||
|
||||
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
|
||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||
}
|
||||
|
||||
function handleWeightsChange(
|
||||
newWeights: Record<PoiCategoryKey, number>,
|
||||
_profileId: number | null,
|
||||
) {
|
||||
setWeights(newWeights);
|
||||
}
|
||||
|
||||
const chips: Array<{
|
||||
key: keyof Omit<FilterState, "radiusKm">;
|
||||
label: string;
|
||||
|
|
@ -136,8 +133,8 @@ function Section31Settings({
|
|||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
Фильтры применяются к конкурентам локально — без повторного запроса к
|
||||
бэкенду
|
||||
Радиус и фильтры применяются к конкурентам локально. Веса POI —
|
||||
пересчёт анализа на бэкенде по кнопке «Применить»
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -259,8 +256,8 @@ function Section31Settings({
|
|||
Профиль весов POI
|
||||
</div>
|
||||
<WeightProfilePanel
|
||||
currentWeights={weights}
|
||||
onWeightsChange={handleWeightsChange}
|
||||
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
|
||||
onWeightsChange={onWeightsApply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -769,7 +766,12 @@ function applyFilters(
|
|||
|
||||
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
||||
|
||||
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||
export function Section3SettingsAndCompetitors({
|
||||
cad,
|
||||
data,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: Props) {
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
radiusKm: 2,
|
||||
onlyUnderConstruction: false,
|
||||
|
|
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
|||
<StageDetails>
|
||||
{/* Sub-sections */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<Section31Settings filters={filters} onFiltersChange={setFilters} />
|
||||
<Section31Settings
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
weights={weights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* Competitor table — moved before 3.2/3.3 for context */}
|
||||
{filteredCompetitors.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
|
||||
* timers, and assert on abort behaviour + the happy path.
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Capture the options passed to useQuery ───────────────────────────────────
|
||||
|
|
@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42";
|
|||
* polling queryFn. Reads `captured.options` via a fresh binding so TS control-
|
||||
* flow doesn't pin it (the hook mutates it opaquely through the mock).
|
||||
*
|
||||
* `useQuery` is fully mocked (it just records its options, no React state), so
|
||||
* the rules-of-hooks invariant does not apply to this call — disable locally.
|
||||
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
|
||||
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
|
||||
* dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему
|
||||
* замокан и просто записывает options; провайдера над хуком нет, значит
|
||||
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
|
||||
* тест и гоняет.
|
||||
*/
|
||||
function getQueryFn(): CapturedQueryOptions["queryFn"] {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useParcelAnalyzeQuery(CAD, 12);
|
||||
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
|
||||
const options = captured.options;
|
||||
if (options === null) throw new Error("useQuery options not captured");
|
||||
return options.queryFn;
|
||||
|
|
|
|||
|
|
@ -27,14 +27,18 @@ export interface WeightProfileCreate {
|
|||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface WeightProfileUpdate {
|
||||
profile_name?: string;
|
||||
weights?: Record<string, number>;
|
||||
is_default?: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors
|
||||
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
|
||||
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
|
||||
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
|
||||
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
|
||||
* (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel.
|
||||
*/
|
||||
export const SYSTEM_PROFILE_USER_ID = "__system__";
|
||||
|
||||
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
|
||||
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
|
||||
|
||||
|
|
@ -103,13 +107,20 @@ const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
|||
|
||||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List all weight profiles for a given user_id. */
|
||||
/**
|
||||
* Профили пользователя + системные пресеты (#2790).
|
||||
*
|
||||
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
|
||||
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
|
||||
* Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в
|
||||
* проде с 16.05.2026 и не были видны никому.
|
||||
*/
|
||||
export function useWeightProfiles(userId: string) {
|
||||
return useQuery<WeightProfile[]>({
|
||||
queryKey: ["weight-profiles", userId],
|
||||
queryFn: () =>
|
||||
apiFetch<WeightProfile[]>(
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
|
||||
),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
|
@ -132,35 +143,9 @@ export function useCreateProfile() {
|
|||
});
|
||||
}
|
||||
|
||||
/** Update an existing weight profile by id. */
|
||||
export function useUpdateProfile(userId: string, profileId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
||||
mutationFn: (payload) =>
|
||||
apiFetch<WeightProfile>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
||||
export function useDeleteProfile(userId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, number>({
|
||||
mutationFn: (profileId) =>
|
||||
apiFetch<void>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
|
||||
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
|
||||
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
|
||||
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
|
||||
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
|
||||
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext } from "react";
|
||||
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||
import { abortableSleep } from "@/lib/abortableSleep";
|
||||
import type {
|
||||
|
|
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
|
|||
const ANALYZE_POLL_INTERVAL_MS = 2000;
|
||||
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
|
||||
|
||||
/**
|
||||
* Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос
|
||||
* уходит без тела, как и раньше (бэкенд считает по системным весам).
|
||||
*
|
||||
* Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)`
|
||||
* зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один
|
||||
* ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос.
|
||||
* Если веса доедут только до части из них, ключи разойдутся: половина страницы
|
||||
* покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды.
|
||||
* Контекст держит всех потребителей ключа на одном значении по построению —
|
||||
* забыть прокинуть проп в новую секцию нельзя.
|
||||
*/
|
||||
export const AnalyzeWeightsContext = createContext<Record<
|
||||
string,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||
const weights = useContext(AnalyzeWeightsContext);
|
||||
// Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем.
|
||||
// null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до
|
||||
// #2790, кэш не сбрасывается на ровном месте.
|
||||
const weightsKey = weights
|
||||
? JSON.stringify(Object.entries(weights).sort())
|
||||
: null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["parcel-analyze", cad, horizon],
|
||||
// Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI
|
||||
// мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт.
|
||||
queryKey: ["parcel-analyze", cad, horizon, weightsKey],
|
||||
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
|
||||
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
|
||||
// it through the POST/GET fetches and check it before each poll iteration so
|
||||
|
|
@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
cad,
|
||||
)}/analyze?horizon=${horizon}`;
|
||||
|
||||
// Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id:
|
||||
// тело запроса == ползункам панели, и ответ рапортует source="inline" —
|
||||
// расхождению между показанными весами и посчитанным скором взяться
|
||||
// неоткуда (в отличие от profile_id, см. #2782).
|
||||
const analyzeInit: RequestInit = weights
|
||||
? { method: "POST", signal, body: JSON.stringify({ weights }) }
|
||||
: { method: "POST", signal };
|
||||
|
||||
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
|
||||
// Accepted code instead of treating it as a successful payload.
|
||||
const first = await apiFetchWithStatus<
|
||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl, { method: "POST", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
|
||||
// 200 → geometry was cached, full analysis is ready.
|
||||
if (first.status === 200) {
|
||||
|
|
@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
// rather than returning the stub (symmetry with the first request).
|
||||
const second = await apiFetchWithStatus<
|
||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl, { method: "POST", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
if (second.status === 200) {
|
||||
return second.body as ParcelAnalyzeResponse;
|
||||
}
|
||||
|
|
|
|||
102
ops/docker-prune.sh
Executable file
102
ops/docker-prune.sh
Executable file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env bash
|
||||
# Периодическая уборка docker-мусора на прод-VM.
|
||||
#
|
||||
# ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201
|
||||
# том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых
|
||||
# прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не
|
||||
# было ни одного.
|
||||
#
|
||||
# Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml
|
||||
# снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил,
|
||||
# а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он
|
||||
# подбирает то, что runner не убрал за собой, и то, что накопилось раньше.
|
||||
#
|
||||
# ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно):
|
||||
# - остановленные контейнеры старше 24ч;
|
||||
# - висячие (dangling) образы старше 7 суток;
|
||||
# - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и
|
||||
# FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data,
|
||||
# tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются
|
||||
# НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune`
|
||||
# такой разницы не делает, поэтому здесь он намеренно не используется.
|
||||
#
|
||||
# Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает).
|
||||
# Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы):
|
||||
# 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1
|
||||
#
|
||||
# Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup
|
||||
# tradein, 05:00+ backfill'ы.
|
||||
#
|
||||
# Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI)
|
||||
# копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
STOPPED_AGE="${STOPPED_AGE:-24h}"
|
||||
IMAGE_AGE="${IMAGE_AGE:-168h}"
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
|
||||
|
||||
disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; }
|
||||
|
||||
before_pct="$(disk_used_pct)"
|
||||
log "старт: диск занят ${before_pct}%"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "DRY_RUN=1 — только показываю"
|
||||
fi
|
||||
|
||||
# ── 1. остановленные контейнеры ───────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
# `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»),
|
||||
# поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка.
|
||||
log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \
|
||||
$(docker container ls -aq --filter "status=exited" | wc -l)"
|
||||
else
|
||||
log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \
|
||||
2>&1 | tail -1)"
|
||||
fi
|
||||
|
||||
# ── 2. висячие образы ─────────────────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "висячих образов: $(docker image ls -qf dangling=true | wc -l)"
|
||||
else
|
||||
log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)"
|
||||
fi
|
||||
|
||||
# ── 3. тома-сироты известных форм ─────────────────────────────────────────────
|
||||
# Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой
|
||||
# отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся.
|
||||
mapfile -t candidates < <(
|
||||
docker volume ls -qf dangling=true \
|
||||
| grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true
|
||||
)
|
||||
|
||||
skipped="$(docker volume ls -qf dangling=true \
|
||||
| grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)"
|
||||
if [[ -n "$skipped" ]]; then
|
||||
log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
if [[ "${#candidates[@]}" -eq 0 ]]; then
|
||||
log "томов-сирот известных форм нет"
|
||||
elif [[ "$DRY_RUN" == "1" ]]; then
|
||||
log "томов к удалению: ${#candidates[@]}"
|
||||
else
|
||||
removed=0
|
||||
for v in "${candidates[@]}"; do
|
||||
if docker volume rm "$v" >/dev/null 2>&1; then
|
||||
removed=$((removed + 1))
|
||||
fi
|
||||
done
|
||||
log "томов удалено: ${removed} из ${#candidates[@]}"
|
||||
fi
|
||||
|
||||
after_pct="$(disk_used_pct)"
|
||||
log "готово: диск занят ${after_pct}% (было ${before_pct}%)"
|
||||
|
||||
# Сигнал в лог, если места всё равно мало — повод посмотреть глазами.
|
||||
if [[ "$after_pct" -ge 85 ]]; then
|
||||
log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно"
|
||||
fi
|
||||
|
|
@ -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"})
|
||||
0
ops/restore.sh
Normal file → Executable file
0
ops/restore.sh
Normal file → Executable file
|
|
@ -5,6 +5,8 @@
|
|||
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
|
||||
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
|
||||
# политика ПДн, на которую ссылается футер.
|
||||
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
|
||||
# напечатаны внутри самих юридических документов и уходят эквайеру.
|
||||
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
|
||||
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
|
||||
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
|
||||
|
|
@ -52,6 +54,15 @@ check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
|
|||
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
|
||||
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
|
||||
|
||||
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
|
||||
# эти три URL напечатаны внутри самих документов и уходят в заявку
|
||||
# эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на
|
||||
# 404, и заявку завернут. Проверяем все три поимённо, потому что и в
|
||||
# Caddyfile они перечислены поимённо (allowlist, не шаблон).
|
||||
check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200
|
||||
check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200
|
||||
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
|
||||
|
||||
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
||||
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
||||
|
||||
|
|
|
|||
46
tradein-mvp/CHANGELOG.md
Normal file
46
tradein-mvp/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# История версий «МЕРА»
|
||||
|
||||
Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
|
||||
[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
|
||||
`## <semver> — <YYYY-MM-DD>` (машинно читается страницей истории версий).
|
||||
|
||||
## 2.1.0 — 2026-08-10
|
||||
|
||||
Первая версия с явным версионированием. Номер продолжает ряд, который до этого
|
||||
показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
|
||||
отчёты.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
|
||||
реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
|
||||
уверенности в оценке.
|
||||
- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
|
||||
аналогов и сделок, формирование выкупной стоимости.
|
||||
- Аналитика по дому — история размещений объявлений и продаж в доме.
|
||||
- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
|
||||
- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
|
||||
- Чат поддержки на сайте, в том числе без входа в личный кабинет.
|
||||
- Публичный лендинг «МЕРА».
|
||||
- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
|
||||
страница истории версий.
|
||||
|
||||
### Изменено
|
||||
|
||||
- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
|
||||
раннего технического макета.
|
||||
|
||||
### Исправлено
|
||||
|
||||
- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
|
||||
комнатности не было варианта «Студия», из-за чего для студии подбирались
|
||||
однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
|
||||
- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
|
||||
автоматически расширяется (студии, срок объявлений, новостройки, радиус),
|
||||
а над результатом показывается предупреждение о сниженной точности и о том,
|
||||
какие параметры пришлось расширить.
|
||||
- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
|
||||
адресов улица не распознавалась, и разделы оставались пустыми.
|
||||
- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
|
||||
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
|
||||
на обложке, некорректные допущения о сроке экспозиции.
|
||||
1
tradein-mvp/VERSION
Normal file
1
tradein-mvp/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
2.1.0
|
||||
|
|
@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages
|
|||
COPY --from=builder --chown=app:app /app/backend/app /app/app
|
||||
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
|
||||
|
||||
# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего
|
||||
# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION).
|
||||
# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста.
|
||||
COPY --chown=app:app VERSION VERSION
|
||||
|
||||
# Версия продукта + короткий git SHA + дата сборки — запечены как build-args
|
||||
# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend).
|
||||
# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам
|
||||
# (VERSION-файл выше / "dev" / момент импорта модуля).
|
||||
#
|
||||
# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых
|
||||
# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на
|
||||
# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои
|
||||
# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале
|
||||
# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get +
|
||||
# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот
|
||||
# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже).
|
||||
ARG APP_VERSION=""
|
||||
ARG BUILD_SHA=""
|
||||
ARG BUILD_DATE=""
|
||||
ENV APP_VERSION=$APP_VERSION \
|
||||
BUILD_SHA=$BUILD_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
|
||||
USER app
|
||||
|
||||
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ from app.services import proxy_rotation as proxy_rotation_svc
|
|||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
from app.services.geocoder import geocode, known_city_hint
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
from app.services.proxy_pool import clear_source_bans
|
||||
from app.services.scheduler import has_running_run
|
||||
from app.services.scraper_adapters import (
|
||||
|
|
@ -373,6 +374,56 @@ async def geocode_missing(
|
|||
}
|
||||
|
||||
|
||||
def _cian_verify_state_error(state: dict[str, Any] | None) -> HTTPException | None:
|
||||
"""Маппинг исхода cian_session_svc.verify_session() на HTTP-ответ админки.
|
||||
|
||||
verify_session() возвращает 4 разных исхода (см. докстринг сервиса) плюс успех —
|
||||
их нельзя схлопывать в один "cookies invalid", иначе бан по IP выглядит так же,
|
||||
как протухшие куки, и человек в момент инцидента перезаливает заведомо валидные
|
||||
куки вместо починки egress/прокси (инцидент 2026-08-10).
|
||||
|
||||
Sentinel'ы сравниваются через `is`, НЕ `==` — так требует докстринг verify_session.
|
||||
|
||||
Возвращает None, если state — это успешно распаршенный state dict (в т.ч. случай
|
||||
"успех, но userId не найден" — этот случай caller должен обработать отдельно).
|
||||
"""
|
||||
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Cian заблокировал наш IP (HTTP 403, TLS/bot-fingerprint ban). "
|
||||
"Куки, скорее всего, валидны — блокировка не про них. "
|
||||
"Нужно чинить egress: проверить SCRAPER_PROXY_URL и баны в "
|
||||
"scrape_proxy_source_bans. Перезаливать куки бесполезно. "
|
||||
"(CIAN_PROXY_URL — мёртвая переменная, снята в #2616.)"
|
||||
),
|
||||
)
|
||||
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Cian временно недоступен (5xx или сетевой сбой при проверке кук). "
|
||||
"Повторите проверку позже. Куки не трогать — источник просто не ответил."
|
||||
),
|
||||
)
|
||||
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Cian изменил вёрстку/схему страницы — auth-state не найден/не "
|
||||
"распарсился (scraper_kit.cian_state_parser.extract_state, MFE "
|
||||
"header-frontend). Нужен инженерный фикс парсера, перезалив кук "
|
||||
"проблему НЕ решит."
|
||||
),
|
||||
)
|
||||
if state is None:
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail="Куки протухли или сессия разлогинена на cian.ru — перезалейте куки.",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/scrape/cian/upload-cookies", status_code=200)
|
||||
async def upload_cian_cookies(
|
||||
cookies: dict[str, str],
|
||||
|
|
@ -405,15 +456,21 @@ async def upload_cian_cookies(
|
|||
)
|
||||
|
||||
state = await cian_session_svc.verify_session(cleaned)
|
||||
if state is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Cookies invalid or session not authenticated on cian.ru",
|
||||
)
|
||||
verify_error = _cian_verify_state_error(state)
|
||||
if verify_error is not None:
|
||||
raise verify_error
|
||||
assert state is not None # narrowed by _cian_verify_state_error above
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Cian подтвердил аутентификацию (state распарсился), но userId в "
|
||||
"ответе не найден — структура state неожиданная, куки тут ни при "
|
||||
"чём, смотрите server logs."
|
||||
),
|
||||
)
|
||||
|
||||
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
|
||||
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
|
||||
|
|
@ -480,15 +537,21 @@ async def cian_auto_login(
|
|||
)
|
||||
|
||||
state = await cian_session_svc.verify_session(cleaned)
|
||||
if state is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Logged in but session not authenticated (cookies rejected by cian.ru)",
|
||||
)
|
||||
verify_error = _cian_verify_state_error(state)
|
||||
if verify_error is not None:
|
||||
raise verify_error
|
||||
assert state is not None # narrowed by _cian_verify_state_error above
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Cian подтвердил аутентификацию (state распарсился), но userId в "
|
||||
"ответе не найден — структура state неожиданная, куки тут ни при "
|
||||
"чём, смотрите server logs."
|
||||
),
|
||||
)
|
||||
|
||||
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
|
||||
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
|
||||
|
|
@ -500,6 +563,12 @@ async def test_cian_auth(
|
|||
) -> dict:
|
||||
"""Проверить что текущие сохранённые Cian cookies ещё валидны.
|
||||
|
||||
reason различает 5 исходов (см. cian_session_svc.verify_session докстринг):
|
||||
"banned_403" — куки, вероятно, ОК, блокирован IP; "source_unavailable" —
|
||||
Cian недоступен, куки ни при чём; "markup_changed" — вёрстка Cian сменилась,
|
||||
нужен фикс парсера; "session_expired_or_invalid" — куки реально протухли;
|
||||
"no_session_in_db" / "encryption_key_not_configured" — конфигурация/данных нет.
|
||||
|
||||
Returns: {"authenticated": bool, "userId": <int|null>, "reason": <str|null>}
|
||||
"""
|
||||
if not settings.cookie_encryption_key:
|
||||
|
|
@ -510,10 +579,14 @@ async def test_cian_auth(
|
|||
return {"authenticated": False, "userId": None, "reason": "no_session_in_db"}
|
||||
|
||||
state = await cian_session_svc.verify_session(cookies)
|
||||
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "banned_403"}
|
||||
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "source_unavailable"}
|
||||
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "markup_changed"}
|
||||
if state is None:
|
||||
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
|
||||
if state.get("_ban"):
|
||||
return {"authenticated": False, "userId": None, "reason": "banned_403"}
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
return {"authenticated": True, "userId": user_id, "reason": None}
|
||||
|
|
@ -1904,9 +1977,16 @@ async def scrape_cian_detail(
|
|||
Without it → debug-only (no DB write).
|
||||
"""
|
||||
_assert_allowed_url(offer_url)
|
||||
from scraper_kit.cian_exceptions import CianBlockedError
|
||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||
|
||||
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
|
||||
try:
|
||||
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
|
||||
except CianBlockedError as exc:
|
||||
# #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке
|
||||
# нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с
|
||||
# этого узла» — разные новости для того, кто дёргает ручку руками.
|
||||
raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc
|
||||
if enrichment is None:
|
||||
raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")
|
||||
|
||||
|
|
@ -1953,14 +2033,17 @@ async def scrape_cian_newbuilding(
|
|||
save_newbuilding_enrichment,
|
||||
)
|
||||
|
||||
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
|
||||
enrichment = await fetch_newbuilding(
|
||||
zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
|
||||
)
|
||||
if enrichment is None:
|
||||
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
|
||||
|
||||
saved = False
|
||||
if house_id is not None:
|
||||
# save_newbuilding_enrichment — sync (def, returns None); await на sync-функции
|
||||
# раньше поднимал TypeError на любом вызове с house_id.
|
||||
# save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции
|
||||
# раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт
|
||||
# записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения.
|
||||
save_newbuilding_enrichment(db, house_id, enrichment)
|
||||
saved = True
|
||||
|
||||
|
|
@ -2147,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,
|
||||
|
|
@ -2184,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-сессию.
|
||||
|
|
@ -2288,18 +2379,33 @@ class ScraperHealthResponse(BaseModel):
|
|||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||
|
||||
|
||||
def _provider_proxy_url(source: str) -> str | None:
|
||||
"""Effective proxy URL для source (учитывает property-fallback в settings).
|
||||
def _provider_proxy_url(db: Session, source: str) -> str | None:
|
||||
"""Узел, который РЕАЛЬНО получит трафик этого источника (#2830).
|
||||
|
||||
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
|
||||
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая
|
||||
mobileproxy-подписка, #2613).
|
||||
Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная
|
||||
переменная для всех трёх источников. После #2825/#2831 egress выбирается из
|
||||
`scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть
|
||||
страница показывала один узел, а трафик шёл через другой — слепое пятно ровно
|
||||
того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел,
|
||||
забаненный и Avito, и Cian), только теперь на диагностической странице.
|
||||
|
||||
Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`,
|
||||
`*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт
|
||||
расходиться с трафиком.
|
||||
|
||||
Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only,
|
||||
lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не
|
||||
его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит
|
||||
выдавать ему чужой бан.
|
||||
"""
|
||||
return {
|
||||
"avito": settings.scraper_proxy_url,
|
||||
"cian": settings.cian_proxy_url,
|
||||
"yandex": settings.yandex_proxy_url,
|
||||
}.get(source)
|
||||
try:
|
||||
return resolve_proxy_url(db, source)
|
||||
except ProxyPoolExhaustedError:
|
||||
# Пул не пуст, но для source не осталось ни одного здорового небаненного узла.
|
||||
# resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть
|
||||
# страница покажет «—», а не статичный env-узел (зелёная строка на месте
|
||||
# отказа хуже пустой).
|
||||
return None
|
||||
|
||||
|
||||
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
||||
|
|
@ -2415,19 +2521,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None:
|
|||
|
||||
|
||||
@router.get("/scraper/health", response_model=ScraperHealthResponse)
|
||||
async def scraper_health() -> ScraperHealthResponse:
|
||||
async def scraper_health(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> ScraperHealthResponse:
|
||||
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
|
||||
|
||||
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
||||
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported
|
||||
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
|
||||
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
|
||||
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
|
||||
- providers: для avito/cian/yandex — узел, который пул отдаст ЭТОМУ источнику
|
||||
сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного
|
||||
здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip
|
||||
mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация —
|
||||
POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort
|
||||
current_ip (пробинг через этот же узел на ipify).
|
||||
|
||||
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
||||
"""
|
||||
proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES}
|
||||
proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES}
|
||||
|
||||
browser, *ips = await asyncio.gather(
|
||||
_probe_browser_health(),
|
||||
|
|
|
|||
|
|
@ -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-ФЗ"
|
||||
"Политикой обработки персональных данных"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
|
@ -23,6 +26,8 @@ from app.schemas.trade_in import (
|
|||
AnalogLot,
|
||||
AvitoImvSummary,
|
||||
CianPriceChangeStats,
|
||||
CoverageProbeInput,
|
||||
CoverageProbeResponse,
|
||||
DkpCorridor,
|
||||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
|
|
@ -52,6 +57,27 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404,
|
||||
# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись
|
||||
# по коду ответа; третий потребитель (`/r/<token>`, PR-9) разошёлся бы
|
||||
# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false
|
||||
# в SQL — для всех существующих строк (retain_until IS NULL) поведение не
|
||||
# меняется вообще. Не копировать это выражение по месту — только через
|
||||
# константу/хелпер ниже. Payments retention, PR #2754.
|
||||
ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())"
|
||||
|
||||
|
||||
def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool:
|
||||
"""Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД.
|
||||
|
||||
tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) —
|
||||
`.replace(tzinfo=UTC)`, не переизобретается.
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
if expires_at.replace(tzinfo=UTC) > now:
|
||||
return True
|
||||
return retain_until is not None and retain_until.replace(tzinfo=UTC) > now
|
||||
|
||||
|
||||
def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None:
|
||||
"""IDOR guard (#690): только владелец оценки или admin могут её читать.
|
||||
|
|
@ -146,6 +172,239 @@ def _resolve_target_house_id(
|
|||
return None
|
||||
|
||||
|
||||
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────────
|
||||
# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»:
|
||||
# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и
|
||||
# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры
|
||||
# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую
|
||||
# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST
|
||||
# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую
|
||||
# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту
|
||||
# цена неприкосновенна.
|
||||
def _precision_to_qc_geo(precision: str | None) -> int | None:
|
||||
"""Best-effort обратное отображение к estimator._qc_geo_to_precision.
|
||||
|
||||
AggregatedEstimate наружу отдаёт только бакетированный address_precision
|
||||
(house/street/approximate), не сырой dadata.qc_geo (0..5) — тот остаётся
|
||||
приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в
|
||||
колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же
|
||||
теперь-живой строки не откатили address_precision в None. Бакеты 2..5
|
||||
(settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision —
|
||||
2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно
|
||||
в тот же "approximate", наблюдаемое поведение не меняется.
|
||||
"""
|
||||
if precision == "house":
|
||||
return 0
|
||||
if precision == "street":
|
||||
return 1
|
||||
if precision == "approximate":
|
||||
return 2
|
||||
return None
|
||||
|
||||
|
||||
def _payload_from_dead_row(row: Any) -> TradeInEstimateInput:
|
||||
"""Восстанавливает вход оценки из мёртвой сохранённой строки для revival.
|
||||
|
||||
Только поля, реально персистящиеся в trade_in_estimates при создании
|
||||
(address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/
|
||||
repair_state/has_balcony) — CRM-only поля (ownership_type/has_mortgage)
|
||||
на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится
|
||||
(payload.radius_m живёт только в рамках одного POST-запроса, ни главный
|
||||
INSERT, ни _empty_estimate его не пишут) — None здесь даёт тот же
|
||||
default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего
|
||||
большинства сохранённых строк (явный радиус выбирает меньшинство).
|
||||
consent=None + require_consent=False у вызывающего — revival не новое
|
||||
согласие физлица, а служебный recompute уже существующей записи.
|
||||
"""
|
||||
return TradeInEstimateInput(
|
||||
address=row.address,
|
||||
area_m2=float(row.area_m2),
|
||||
rooms=row.rooms,
|
||||
floor=row.floor,
|
||||
total_floors=row.total_floors,
|
||||
year_built=row.year_built,
|
||||
house_type=row.house_type,
|
||||
repair_state=row.repair_state,
|
||||
has_balcony=row.has_balcony,
|
||||
lat=row.lat,
|
||||
lon=row.lon,
|
||||
radius_m=None,
|
||||
consent=None,
|
||||
)
|
||||
|
||||
|
||||
async def _try_revive_dead_estimate(
|
||||
db: Session, estimate_id: UUID, row: Any
|
||||
) -> AggregatedEstimate | None:
|
||||
"""Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте.
|
||||
|
||||
Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный —
|
||||
id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк /
|
||||
заявку уже забрал параллельный запрос — anti-storm через атомарный
|
||||
conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что
|
||||
account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка
|
||||
сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по
|
||||
значению, прочитанному раньше в Python — TOCTOU-гонка между двумя
|
||||
параллельными GET невозможна, проигравший просто не дублирует работу;
|
||||
(б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт
|
||||
упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller
|
||||
обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше — НЕ 500.
|
||||
"""
|
||||
claim = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE trade_in_estimates
|
||||
SET revival_attempted_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND (median_price <= 0 OR median_price IS NULL)
|
||||
AND (
|
||||
revival_attempted_at IS NULL
|
||||
OR revival_attempted_at
|
||||
< NOW() - make_interval(mins => CAST(:throttle AS integer))
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
if claim is None:
|
||||
logger.info("estimate revival throttled/lost race: id=%s", estimate_id)
|
||||
return None
|
||||
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
try:
|
||||
payload = _payload_from_dead_row(row)
|
||||
result = await estimate_quality(
|
||||
payload,
|
||||
db,
|
||||
created_by=row.created_by,
|
||||
client_ip=None,
|
||||
require_consent=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address)
|
||||
return None
|
||||
|
||||
temp_id = result.estimate_id
|
||||
if result.median_price_rub <= 0:
|
||||
logger.info("estimate revival still insufficient data: id=%s", estimate_id)
|
||||
db.execute(
|
||||
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(temp_id)},
|
||||
)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
# estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no
|
||||
# notion of "recompute this existing row". Copy the computed OUTPUT fields
|
||||
# into the ORIGINAL row (id/link contract), then drop the throwaway one.
|
||||
# INPUT snapshot (address/area/rooms/...) is untouched — it did not change,
|
||||
# only the outputs were recomputed.
|
||||
# #incident-2026-08-11: created_at is DELIBERATELY excluded from this SET —
|
||||
# it is the client's original request date (printed in /history and in
|
||||
# AggregatedEstimate.created_at, see app/schemas/trade_in.py:317-318), NOT
|
||||
# a recompute output. It previously got clobbered with the throwaway temp
|
||||
# row's created_at (=NOW() at recompute time), which also silently
|
||||
# re-sorted the row to the top of `GET /history ORDER BY created_at DESC`.
|
||||
# revival_completed_at (migration 256) is the audit trail for "when did a
|
||||
# revival LAST successfully rewrite this row" — distinct from
|
||||
# revival_attempted_at (255), which is stamped on every claim regardless
|
||||
# of outcome (throttle loss / recompute failure included).
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE avito_imv_evaluations
|
||||
SET estimate_id = CAST(:orig AS uuid)
|
||||
WHERE estimate_id = CAST(:temp AS uuid)
|
||||
"""
|
||||
),
|
||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE trade_in_estimates SET
|
||||
median_price = :median_price,
|
||||
range_low = :range_low,
|
||||
range_high = :range_high,
|
||||
median_price_per_m2 = :median_ppm2,
|
||||
confidence = :confidence,
|
||||
confidence_explanation = :explanation,
|
||||
n_analogs = :n_analogs,
|
||||
analogs = CAST(:analogs_json AS jsonb),
|
||||
actual_deals = CAST(:deals_json AS jsonb),
|
||||
sources_used = CAST(:sources_json AS jsonb),
|
||||
data_freshness_minutes = :freshness,
|
||||
canonical_address = :canonical_address,
|
||||
house_cadnum = :house_cadnum,
|
||||
house_fias_id = :house_fias_id,
|
||||
dadata_qc_geo = :dadata_qc_geo,
|
||||
dadata_metro = CAST(:dadata_metro_json AS jsonb),
|
||||
expected_sold_price = :expected_sold_price,
|
||||
expected_sold_range_low = :expected_sold_range_low,
|
||||
expected_sold_range_high = :expected_sold_range_high,
|
||||
expected_sold_per_m2 = :expected_sold_per_m2,
|
||||
asking_to_sold_ratio = :asking_to_sold_ratio,
|
||||
ratio_basis = :ratio_basis,
|
||||
relaxations = CAST(:relaxations_json AS jsonb),
|
||||
reliability = :reliability,
|
||||
revival_completed_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(estimate_id),
|
||||
"median_price": result.median_price_rub,
|
||||
"range_low": result.range_low_rub,
|
||||
"range_high": result.range_high_rub,
|
||||
"median_ppm2": result.median_price_per_m2,
|
||||
"confidence": result.confidence,
|
||||
"explanation": result.confidence_explanation,
|
||||
"n_analogs": result.n_analogs,
|
||||
"analogs_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False
|
||||
),
|
||||
"deals_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False
|
||||
),
|
||||
"sources_json": json.dumps(result.sources_used, ensure_ascii=False),
|
||||
"freshness": result.data_freshness_minutes,
|
||||
"canonical_address": result.canonical_address,
|
||||
"house_cadnum": result.house_cadnum,
|
||||
"house_fias_id": result.house_fias_id,
|
||||
"dadata_qc_geo": _precision_to_qc_geo(result.address_precision),
|
||||
"dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False),
|
||||
"expected_sold_price": result.expected_sold_price_rub,
|
||||
"expected_sold_range_low": result.expected_sold_range_low_rub,
|
||||
"expected_sold_range_high": result.expected_sold_range_high_rub,
|
||||
"expected_sold_per_m2": result.expected_sold_per_m2,
|
||||
"asking_to_sold_ratio": result.asking_to_sold_ratio,
|
||||
"ratio_basis": result.ratio_basis,
|
||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||
"reliability": result.reliability,
|
||||
},
|
||||
)
|
||||
db.execute(
|
||||
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(temp_id)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s",
|
||||
estimate_id,
|
||||
result.median_price_rub,
|
||||
result.n_analogs,
|
||||
result.confidence,
|
||||
result.reliability,
|
||||
)
|
||||
# created_at on the returned object must mirror the DB row (untouched
|
||||
# original request date, NOT the temp row's NOW()) — see UPDATE above.
|
||||
return result.model_copy(update={"estimate_id": estimate_id, "created_at": row.created_at})
|
||||
|
||||
|
||||
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||
async def estimate(
|
||||
payload: TradeInEstimateInput,
|
||||
|
|
@ -249,21 +508,22 @@ def get_estimate(
|
|||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||
expires_at, address, lat, lon,
|
||||
expires_at, retain_until, address, lat, lon,
|
||||
area_m2, rooms, floor, total_floors,
|
||||
year_built, house_type, repair_state, has_balcony,
|
||||
canonical_address, house_cadnum, house_fias_id,
|
||||
dadata_qc_geo, dadata_metro,
|
||||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||
relaxations, reliability
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND expires_at > NOW()
|
||||
AND {ESTIMATE_READABLE_SQL}
|
||||
"""
|
||||
),
|
||||
{"id": str(estimate_id)},
|
||||
|
|
@ -274,6 +534,22 @@ def get_estimate(
|
|||
|
||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||
|
||||
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
|
||||
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем
|
||||
# пересчитать её на месте (throttled, race-safe — см. докстринг
|
||||
# _try_revive_dead_estimate) через тот же путь, что и POST /estimate.
|
||||
# Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔
|
||||
# async мост (тот же паттерн, что app/scheduler_main.py): get_estimate
|
||||
# остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому
|
||||
# ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event
|
||||
# loop — только сама попытка revival временно занимает свой поток на время
|
||||
# await estimate_quality(). Любая ошибка расчёта — не 500: revived is None,
|
||||
# и функция просто продолжает как раньше, отдавая сохранённую строку.
|
||||
if row.median_price is None or row.median_price <= 0:
|
||||
revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row))
|
||||
if revived is not None:
|
||||
return revived
|
||||
|
||||
from app.services.estimator import (
|
||||
_canonical_sources,
|
||||
_cv_from_ppm2,
|
||||
|
|
@ -283,11 +559,22 @@ def get_estimate(
|
|||
_qc_geo_to_precision,
|
||||
_resolve_target_city,
|
||||
_source_counts,
|
||||
rehydrate_search_radius_m,
|
||||
)
|
||||
|
||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
|
||||
|
||||
# #2632: search_radius_m колонкой не персистится — восстанавливаем его из
|
||||
# того, что персистится (подпись каскада «радиус расширен до N м», иначе
|
||||
# размах сохранённых аналогов). Без этого GET отдавал null, фронт падал на
|
||||
# превью-радиус 1 км и рисовал круг, за которым лежат его же пины (прод
|
||||
# 2026-08-11: 10 из 10 аналогов вне круга, самый дальний — 4381 м).
|
||||
persisted_relaxations = list(getattr(row, "relaxations", None) or [])
|
||||
search_radius_m = rehydrate_search_radius_m(
|
||||
persisted_relaxations, [a.distance_m for a in analogs]
|
||||
)
|
||||
|
||||
# #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
|
||||
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
|
||||
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
|
||||
|
|
@ -372,6 +659,7 @@ def get_estimate(
|
|||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
expires_at=row.expires_at,
|
||||
retain_until=row.retain_until,
|
||||
target_address=row.address,
|
||||
target_lat=row.lat,
|
||||
target_lon=row.lon,
|
||||
|
|
@ -409,6 +697,18 @@ def get_estimate(
|
|||
cv=cv,
|
||||
source_counts=source_counts,
|
||||
created_at=row.created_at,
|
||||
# PR #2823 open follow-up (fixed incident 2026-08-10, migration 255):
|
||||
# relaxations/reliability теперь персистятся — GET-rehydrate больше не
|
||||
# теряет красный баннер «точность снижена» при открытии по ссылке.
|
||||
# getattr defensive: старые in-memory test doubles / любая строка без
|
||||
# этих колонок (не должно случаться после миграции) деградируют в
|
||||
# дефолт схемы (ok / []), а не падают AttributeError.
|
||||
relaxations=persisted_relaxations,
|
||||
reliability=getattr(row, "reliability", None) or "ok",
|
||||
# #2632: фактический радиус подбора (реконструкция выше). requested_radius_m
|
||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||
search_radius_m=search_radius_m,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -433,14 +733,15 @@ def estimate_pdf(
|
|||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||
expires_at,
|
||||
expires_at, retain_until,
|
||||
address, lat, lon, area_m2, rooms, floor, total_floors,
|
||||
year_built, house_type, repair_state, has_balcony,
|
||||
canonical_address, house_cadnum, house_fias_id,
|
||||
dadata_qc_geo, dadata_metro,
|
||||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by
|
||||
asking_to_sold_ratio, ratio_basis, created_by,
|
||||
relaxations, reliability
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -453,8 +754,12 @@ def estimate_pdf(
|
|||
|
||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||
|
||||
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
|
||||
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||||
# PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше
|
||||
# здесь была независимая Python-проверка expires_at, разошедшаяся с SQL-
|
||||
# фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при
|
||||
# годовом retain_until упоминание 24ч в ответе API стало бы ложью.
|
||||
if not estimate_readable(row.expires_at, row.retain_until):
|
||||
raise HTTPException(status_code=410, detail="estimate expired")
|
||||
|
||||
from app.services.estimator import _qc_geo_to_precision
|
||||
|
||||
|
|
@ -477,6 +782,7 @@ def estimate_pdf(
|
|||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
expires_at=row.expires_at,
|
||||
retain_until=row.retain_until,
|
||||
target_address=row.address,
|
||||
target_lat=row.lat,
|
||||
target_lon=row.lon,
|
||||
|
|
@ -496,6 +802,10 @@ def estimate_pdf(
|
|||
house_fias_id=row.house_fias_id,
|
||||
address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
|
||||
metro_nearest=(row.dadata_metro or []),
|
||||
# migration 255 — та же сноска «точность снижена», что и на JSON GET,
|
||||
# теперь и в PDF-регенерации сохранённой оценки (см. get_estimate).
|
||||
relaxations=list(getattr(row, "relaxations", None) or []),
|
||||
reliability=getattr(row, "reliability", None) or "ok",
|
||||
)
|
||||
input_snapshot = {
|
||||
"address": row.address,
|
||||
|
|
@ -2242,3 +2552,268 @@ def get_sales_vs_listings(
|
|||
data_quality="street_only" if total_deals > 0 else "no_data",
|
||||
pairs=pairs,
|
||||
)
|
||||
|
||||
|
||||
# ── Coverage probe (#2894) — бесплатный шаг лэндинга, ЦЕНЫ НЕТ ─────────────────
|
||||
# До оплаты человек видит, СКОЛЬКО похожих квартир продаётся рядом и КАК БЫСТРО
|
||||
# они уходят — ни одной рублёвой цифры (см. CoverageProbeResponse docstring).
|
||||
# Один SQL, ноль внешних вызовов, ноль записей — ручка дешёвая специально: её
|
||||
# планируется открыть анонимам отдельной задачей (#2895, со своим consent-
|
||||
# гейтом). RBAC здесь НЕ трогаем — путь остаётся закрытым (не в _PUBLIC_PATHS).
|
||||
# строго 1000м по ТЗ #2894 (НЕ DEFAULT_RADIUS_M эстиматора — тот допускает fallback до 2000)
|
||||
COVERAGE_RADIUS_M = 1000
|
||||
COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади
|
||||
COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS)
|
||||
|
||||
# MAJOR-2 (независимый ревью #2894): days_on_market на проде заполнена практически
|
||||
# только у yandex (avito/cian/domklik — 0 заполнено) — возраст известен у меньшинства
|
||||
# когорты, и на тонких когортах "медиана" считалась по 1-2 объявлениям. Ниже порога
|
||||
# n_with_age медиану не отдаём (null) — не продуктовое решение, а честность при
|
||||
# заведомо шумной статистике по единичным точкам.
|
||||
COVERAGE_MIN_AGE_SAMPLES = 5
|
||||
# 15% свежих yandex-строк имеют days_on_market > 365 (максимум 4261) — это почти
|
||||
# наверняка мёртвое/забытое объявление, которое никто не снял с публикации, а не
|
||||
# сигнал о реальном времени экспозиции рынка. Отбрасываем как выброс из медианы.
|
||||
COVERAGE_MAX_AGE_DAYS = 365
|
||||
|
||||
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
|
||||
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
|
||||
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда")
|
||||
COVERAGE_GREEN_MIN_N = 8
|
||||
COVERAGE_YELLOW_MIN_N = 12
|
||||
|
||||
|
||||
def _fold_city(name: str) -> str:
|
||||
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
||||
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
||||
|
||||
|
||||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||
}
|
||||
|
||||
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
|
||||
# `listings.city` найденной когорты — оказалось, что `listings.city` это город
|
||||
# СВИП-контекста скрейпера (миграция 196 — колонка заполняется тем городом,
|
||||
# который скрейпер обходил, не геокодом самого объявления). Замер на проде:
|
||||
# в радиусе 1000 м вокруг Берёзовского 90/90 строк имеют city='Екатеринбург';
|
||||
# вокруг Ревды 74/74 — city='Первоуральск'. Следствие: продавец в Берёзовском
|
||||
# видел на лэндинге «Екатеринбург», а сами COVERAGE_GREEN/YELLOW_CITIES для
|
||||
# городов-спутников были НЕДОСТИЖИМЫ (в БД нет ни одной строки с их city).
|
||||
# Фикс — детерминированный резолв по координатам ЗАПРОСА (никакого участия
|
||||
# клиента, никакой моды когорты): ближайший центроид города из списка ниже,
|
||||
# если он в пределах COVERAGE_CITY_MATCH_RADIUS_KM.
|
||||
#
|
||||
# Координаты — константа РЯДОМ С РУЧКОЙ, не таблица в БД: единственный
|
||||
# существующий кандидат на "готовый реестр городов" — это
|
||||
# frontend/src/lib/city-registry.ts (OBLAST_CITIES) и backend
|
||||
# geocoder.py::SVERDLOVSK_OBLAST_CITIES — оба хранят ТОЛЬКО текстовые лейблы
|
||||
# (city_hint для геокодера), без координат. Заводить миграцию + таблицу ради
|
||||
# статичного справочника из 8 географических центров населённых пунктов —
|
||||
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
|
||||
# рядом с порогами, которые они резолвят.
|
||||
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
|
||||
|
||||
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||
"Екатеринбург": (56.8389, 60.6057),
|
||||
"Верхняя Пышма": (56.9789, 60.5636),
|
||||
"Берёзовский": (56.9096, 60.8034),
|
||||
"Среднеуральск": (56.9848, 60.4759),
|
||||
"Нижний Тагил": (57.9099, 59.9819),
|
||||
"Каменск-Уральский": (56.4110, 61.9243),
|
||||
"Первоуральск": (56.9083, 59.9483),
|
||||
"Ревда": (56.7986, 59.9298),
|
||||
}
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
|
||||
r_earth_km = 6371.0
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * r_earth_km * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
|
||||
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
|
||||
|
||||
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
|
||||
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
|
||||
и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то,
|
||||
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
|
||||
больше НЕ допускаются в качестве источника истины.
|
||||
"""
|
||||
nearest_city: str | None = None
|
||||
nearest_km = math.inf
|
||||
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
|
||||
distance_km = _haversine_km(lat, lon, clat, clon)
|
||||
if distance_km < nearest_km:
|
||||
nearest_km = distance_km
|
||||
nearest_city = city
|
||||
|
||||
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
|
||||
return "", 0, False
|
||||
|
||||
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
|
||||
return display, threshold, True
|
||||
|
||||
|
||||
@router.post("/coverage", response_model=CoverageProbeResponse)
|
||||
def coverage_probe(
|
||||
payload: CoverageProbeInput,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> CoverageProbeResponse:
|
||||
"""Бесплатная проба покрытия (issue #2894) — сколько похожих квартир рядом.
|
||||
|
||||
Когорта — тот же дедуп/cap-канон, что radius-тиры в estimator._fetch_analogs
|
||||
(rn_dup по (source, source_id), rn_addr cap по адресу, реюз тех же
|
||||
приватных helper'ов эстиматора — импорт локальный, как и в остальных
|
||||
ручках этого файла, чтобы не тащить тяжёлый app.services.estimator
|
||||
в module-level import graph): ST_DWithin 1000м, rooms точное совпадение,
|
||||
area ±15%, scraped_at не старше 14 дней, is_active.
|
||||
|
||||
MAJOR-1 fix (независимый ревью #2894): когорта пробы обязана быть
|
||||
ПОДМНОЖЕСТВОМ когорты платного эстиматора, не шире её — иначе проба честно
|
||||
отвечает "ok" там, где платный расчёт увидит 0. Три предиката ниже — тот же
|
||||
канон, что estimator._COMMON_WHERE (app/services/estimator.py:5441/5460) и
|
||||
inline-копия Tier W (estimator.py:5910/5916/5932, radius-тир, откуда реально
|
||||
берутся аналоги на 1000 м): guard новостроек, geo_precision != 'city'
|
||||
(#769 Part E — city-centroid листинги без реального адреса), price_rub > 0.
|
||||
|
||||
В ответе НЕТ ни одной цены — см. CoverageProbeResponse docstring.
|
||||
|
||||
MAJOR-2 (независимый ревью #2894): days_on_market на проде фактически
|
||||
заполнена только у ОДНОГО источника (yandex) — это ограничение данных, а
|
||||
не продуктовое решение. n_with_age в ответе честно считает, по скольким
|
||||
объявлениям взята медиана; ниже COVERAGE_MIN_AGE_SAMPLES — null (см. поле
|
||||
в ответе). Значения > COVERAGE_MAX_AGE_DAYS (почти наверняка мёртвое
|
||||
объявление) в расчёт медианы не берутся.
|
||||
|
||||
#oblast (2026-08): house_placement_history.exposure_days — реальная (не
|
||||
цензурированная) экспозиция history-строк — НЕ используется здесь: это
|
||||
house-level архив (join по house_id, не привязан к текущей radius/rooms/
|
||||
area когорте один-в-один), а не активные листинги в подобранном радиусе;
|
||||
сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша
|
||||
в честности (у нас и так честное имя поля — age активного объявления, не
|
||||
срок продажи). См. openQuestions PR #2894 при ревью.
|
||||
|
||||
Повторная проверка ручки (2026-08): город больше НЕ берётся из моды
|
||||
`listings.city` найденной когорты и НЕ зависит от `payload.city_hint` —
|
||||
оба источника ненадёжны (см. комментарий над `_CITY_CENTROIDS_DEG`).
|
||||
Город резолвится детерминированно по `payload.lat/lon` через
|
||||
`_resolve_coverage_city` — `city_hint` в payload остаётся только
|
||||
информационным полем (см. `CoverageProbeInput.city_hint`), на результат
|
||||
не влияет.
|
||||
"""
|
||||
from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS
|
||||
|
||||
area_min = payload.area_m2 * (1 - COVERAGE_AREA_TOLERANCE)
|
||||
area_max = payload.area_m2 * (1 + COVERAGE_AREA_TOLERANCE)
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH base AS (
|
||||
SELECT
|
||||
days_on_market,
|
||||
row_number() OVER (
|
||||
PARTITION BY address ORDER BY scraped_at DESC
|
||||
) AS rn_addr,
|
||||
{_RN_DUP_WINDOW}
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
AND rooms = :rooms
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND ST_DWithin(
|
||||
geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
|
||||
)
|
||||
-- MAJOR-1: sync с estimator._COMMON_WHERE (5441) / Tier W (5916) —
|
||||
AND price_rub > 0
|
||||
-- MAJOR-1: sync с estimator._COMMON_WHERE (5460) / Tier W (5932) —
|
||||
-- guard новостроек, NULL = legacy вторичка до м.011
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- MAJOR-1: sync с estimator Tier W (5910/5945-5948, #769 Part E) —
|
||||
-- исключает city-centroid листинги без реального адреса;
|
||||
-- IS DISTINCT FROM пропускает NULL (неизвестная точность)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
)
|
||||
SELECT
|
||||
count(*) AS n_listings,
|
||||
count(*) FILTER (
|
||||
WHERE days_on_market IS NOT NULL
|
||||
AND days_on_market <= :max_age_days
|
||||
) AS n_with_age,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market)
|
||||
FILTER (
|
||||
WHERE days_on_market IS NOT NULL
|
||||
AND days_on_market <= :max_age_days
|
||||
) AS median_age_days
|
||||
FROM base
|
||||
WHERE rn_addr <= :max_per_addr
|
||||
AND rn_dup = 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"rooms": payload.rooms,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"fresh_days": COVERAGE_FRESH_DAYS,
|
||||
"lat": payload.lat,
|
||||
"lon": payload.lon,
|
||||
"radius": COVERAGE_RADIUS_M,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
"max_age_days": COVERAGE_MAX_AGE_DAYS,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
n_listings = int(row["n_listings"]) if row else 0
|
||||
n_with_age = int(row["n_with_age"]) if row and row["n_with_age"] is not None else 0
|
||||
median_age = (
|
||||
round(row["median_age_days"])
|
||||
if row is not None
|
||||
and row["median_age_days"] is not None
|
||||
and n_with_age >= COVERAGE_MIN_AGE_SAMPLES
|
||||
else None
|
||||
)
|
||||
|
||||
city, threshold, supported = _resolve_coverage_city(payload.lat, payload.lon)
|
||||
|
||||
if not supported or n_listings == 0:
|
||||
status: Literal["ok", "thin", "not_covered"] = "not_covered"
|
||||
# Nit-fix (повторная проверка #2894): threshold неприменим при
|
||||
# not_covered — см. CoverageProbeResponse.threshold docstring. Раньше
|
||||
# поддерживаемый (по координатам) город с пустой когортой отдавал
|
||||
# реальный порог (8/12) вместе с not_covered — противоречило докстрингу.
|
||||
threshold = 0
|
||||
elif n_listings >= threshold:
|
||||
status = "ok"
|
||||
else:
|
||||
status = "thin"
|
||||
|
||||
logger.info(
|
||||
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d n_with_age=%d",
|
||||
payload.rooms,
|
||||
payload.area_m2,
|
||||
city,
|
||||
status,
|
||||
n_listings,
|
||||
n_with_age,
|
||||
)
|
||||
|
||||
return CoverageProbeResponse(
|
||||
status=status,
|
||||
n_listings=n_listings,
|
||||
median_listing_age_days=median_age,
|
||||
n_with_age=n_with_age,
|
||||
radius_m=COVERAGE_RADIUS_M,
|
||||
city=city,
|
||||
threshold=threshold,
|
||||
)
|
||||
|
|
|
|||
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""GET /api/v1/trade-in/version — build metadata (product version + short SHA +
|
||||
build date), source `app/core/version.py`.
|
||||
|
||||
Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не
|
||||
секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только
|
||||
process env / уже загруженные при импорте константы (без похода в БД)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
def get_version() -> dict[str, str]:
|
||||
"""{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}."""
|
||||
return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE}
|
||||
|
|
@ -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,
|
||||
|
|
@ -836,6 +857,35 @@ class Settings(BaseSettings):
|
|||
# срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS.
|
||||
trade_in_lead_retention_days: int = 180
|
||||
|
||||
# ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ─────
|
||||
# trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/
|
||||
# СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность
|
||||
# расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см.
|
||||
# migration 240 докстринг. Отдельная колонка, а не подъём expires_at:
|
||||
# expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков
|
||||
# для всех строк, поднять его до года = соврать в документе клиента про
|
||||
# свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов.
|
||||
# Единственный источник числа «12 месяцев» на фронте —
|
||||
# `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты,
|
||||
# экран после оплаты и SQL продления retain_until при оплате (платёжный
|
||||
# код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе
|
||||
# классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране
|
||||
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
|
||||
trade_in_paid_retention_days: int = 365
|
||||
|
||||
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────
|
||||
# Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL)
|
||||
# сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F,
|
||||
# PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается
|
||||
# пересчитать такую строку через тот же estimate_quality(), что и POST
|
||||
# (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза
|
||||
# в это число минут на строку — иначе каждый refresh страницы бил бы по
|
||||
# геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс:
|
||||
# достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро,
|
||||
# чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV:
|
||||
# TRADE_IN_REVIVAL_THROTTLE_MINUTES.
|
||||
trade_in_revival_throttle_minutes: int = 10
|
||||
|
||||
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
|
||||
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
|
||||
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset(
|
|||
"/api/v1/trade-in/support/anon/messages",
|
||||
"/api/v1/trade-in/support/anon/unread",
|
||||
"/api/v1/trade-in/support/anon/read",
|
||||
# Версионирование (VERSION-файл + build-args, см. app/core/version.py):
|
||||
# не секрет, читает только process env — быстрая справка для клиента/
|
||||
# поддержки/смоук-теста, не должна требовать сессию.
|
||||
"/api/v1/trade-in/version",
|
||||
}
|
||||
)
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
|
|
|
|||
83
tradein-mvp/backend/app/core/version.py
Normal file
83
tradein-mvp/backend/app/core/version.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`.
|
||||
|
||||
`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env,
|
||||
запечённые в образ через build-args в `backend/Dockerfile`
|
||||
(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же
|
||||
ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и
|
||||
`date -u +%Y-%m-%dT%H:%M:%SZ`.
|
||||
|
||||
Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env —
|
||||
тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов,
|
||||
см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта
|
||||
модуля. Ничего здесь не должно падать при отсутствии env (потребитель —
|
||||
и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`).
|
||||
|
||||
Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда.
|
||||
Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION`
|
||||
в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и
|
||||
`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля /
|
||||
`@/lib/buildInfo` соответственно; не заводи третий.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_VERSION = "0.0.0"
|
||||
# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает
|
||||
# и локальный layout (backend/app/core/version.py → ../../../VERSION ==
|
||||
# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/
|
||||
# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION).
|
||||
_MAX_ANCESTORS = 6
|
||||
|
||||
|
||||
def _find_version_file() -> Path | None:
|
||||
here = Path(__file__).resolve()
|
||||
for ancestor in list(here.parents)[:_MAX_ANCESTORS]:
|
||||
candidate = ancestor / "VERSION"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _read_version_file() -> str:
|
||||
path = _find_version_file()
|
||||
if path is None:
|
||||
return _DEFAULT_VERSION
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return _DEFAULT_VERSION
|
||||
return text or _DEFAULT_VERSION
|
||||
|
||||
|
||||
def _default_build_date() -> str:
|
||||
return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# Читаются один раз при импорте модуля (совпадает с паттерном `settings =
|
||||
# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем,
|
||||
# перечитывать на каждый запрос незачем.
|
||||
APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file()
|
||||
BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev"
|
||||
BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date()
|
||||
|
||||
|
||||
def format_build_date_human(build_date: str = BUILD_DATE) -> str:
|
||||
"""ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF
|
||||
колонтитул). Никогда не бросает исключение — при неразборчивой строке
|
||||
возвращает её как есть (это футер отчёта, не API-контракт)."""
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return build_date
|
||||
return parsed.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def product_version_line(product_name: str = "Мера") -> str:
|
||||
"""`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта
|
||||
2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF
|
||||
колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`."""
|
||||
return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"
|
||||
|
|
@ -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
|
||||
|
|
@ -34,6 +34,7 @@ from app.api.v1 import (
|
|||
support,
|
||||
team,
|
||||
trade_in,
|
||||
version,
|
||||
)
|
||||
from app.core.auth_db import get_auth_engine
|
||||
from app.core.config import settings
|
||||
|
|
@ -65,28 +66,40 @@ 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, scrub_payment_request_body
|
||||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction
|
||||
(#tgsupport-web, PR-D2) — см. app/tgbot_main.py._before_send (идентичная
|
||||
композиция, тот же риск: теперь этот процесс тоже держит TelegramClient в
|
||||
стек-фреймах при ошибке sendMessage, а include_local_variables=False ниже —
|
||||
первый рубеж защиты).
|
||||
"""Композиция платёжный 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-канал остался вообще без обработчика."""
|
||||
только 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
|
||||
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
|
||||
if detokened is None:
|
||||
return None
|
||||
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
|
|
@ -224,6 +237,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"])
|
||||
|
|
@ -231,6 +264,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
|||
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
|
||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||
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(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import re
|
|||
from typing import Any
|
||||
|
||||
from sentry_sdk.types import Event
|
||||
from tenacity import RetryError
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||
|
|
@ -105,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)."""
|
||||
|
|
@ -119,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`, тот же
|
||||
|
|
@ -152,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
|
||||
|
|
@ -170,7 +203,8 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
|||
_scrub(request.get("data"))
|
||||
_scrub(event.get("extra"))
|
||||
_scrub(event.get("contexts"))
|
||||
_redact_url_secrets_inplace(event)
|
||||
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
|
||||
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
|
||||
return event
|
||||
|
||||
|
||||
|
|
@ -236,3 +270,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,19 +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_payment_request_body, scrub_pii_event
|
||||
from app.observability.sentry_scrub import (
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: object, hint: dict[str, object]) -> object:
|
||||
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 (идентичный мотив, не дублировать без причины)."""
|
||||
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
|
||||
return scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -196,6 +209,10 @@ class AggregatedEstimate(BaseModel):
|
|||
analogs: list[AnalogLot]
|
||||
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
|
||||
expires_at: datetime
|
||||
# PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности
|
||||
# расчёта — тот остаётся expires_at (не путать, см. migration 240).
|
||||
# NULL = неоплачено (весь текущий трафик, B2B pilots включительно).
|
||||
retain_until: datetime | None = None
|
||||
# ── Дополнительные метаданные ──
|
||||
target_address: str | None = None # geocoded full address
|
||||
target_lat: float | None = None
|
||||
|
|
@ -206,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 самого свежего парсинга аналогов
|
||||
|
|
@ -271,15 +295,26 @@ class AggregatedEstimate(BaseModel):
|
|||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
|
||||
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
|
||||
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
|
||||
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
|
||||
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
|
||||
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
|
||||
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
|
||||
# fallback'ает на выбор пользователя.
|
||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
|
||||
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
|
||||
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
|
||||
# выбору) — иначе карта врёт о реально использованном радиусе.
|
||||
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
|
||||
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
|
||||
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
|
||||
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
|
||||
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
|
||||
# фронт тогда fallback'ает на выбор пользователя, как раньше.
|
||||
search_radius_m: int | None = None
|
||||
# requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор
|
||||
# пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он
|
||||
# выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины —
|
||||
# что просили и что получилось — и потребителю не приходилось выводить
|
||||
# расхождение из своего локального состояния. None на GET-rehydrate:
|
||||
# radius_m не персистится, а угадывать «просили 1 км» за пользователя —
|
||||
# ровно та подмена входа результатом, которую чинит это поле.
|
||||
requested_radius_m: int | None = None
|
||||
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
||||
|
|
@ -315,6 +350,41 @@ class AggregatedEstimate(BaseModel):
|
|||
cv: float | None = None
|
||||
source_counts: dict[str, int] = Field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
# ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
|
||||
# #oblast-E priority RESTORED same day — see estimator.py module
|
||||
# docstring for the full 3-way headline-source rule) ──────────────────
|
||||
# Product requirement: an estimate is ALWAYS surfaced — a thin base sample
|
||||
# (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
|
||||
# estimator.estimate_quality() progressively relaxes the analog SEARCH
|
||||
# (room-count adjacency → freshness window → novostroyki segment → radius)
|
||||
# trying to grow the sample past the threshold; if it's STILL thin,
|
||||
# _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
|
||||
# thin listings median when one is available (restored #oblast-E
|
||||
# priority — the Серов repro: 3 listings must not outrank 54 deals), and
|
||||
# only falls back to the thin listings median itself when no corridor
|
||||
# exists. Real refusal happens only at genuine zero (no listings AND no
|
||||
# usable anchor/deals).
|
||||
# relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
|
||||
# ослабления, готовые к показу пользователю как честный дисклеймер рядом с
|
||||
# confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
|
||||
# не понадобился (обычный случай). Возможные значения (дословно, фронт
|
||||
# может на них завязываться): "снят фильтр по году постройки",
|
||||
# "учтены студии", "комнатность ±1", "объявления за 60 дней",
|
||||
# "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
|
||||
# "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
|
||||
# deals corridor because the base listings sample was thin — a source
|
||||
# SWITCH, not a search widening, but surfaced the same way).
|
||||
# reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
|
||||
# (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
|
||||
# relaxations непусто (выборка набралась только ценой ослаблений); капается
|
||||
# на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
|
||||
# выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
|
||||
# НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
|
||||
# ограничение, каскад не переигрывается из сохранённых analogs). НЕ
|
||||
# путать с `confidence` (Literal low/medium/high — старая метрика на
|
||||
# основе уникальных адресов/IQR, см. её собственный докстринг выше).
|
||||
relaxations: list[str] = Field(default_factory=list)
|
||||
reliability: Literal["ok", "low", "very_low"] = "ok"
|
||||
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
|
||||
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
|
||||
area_m2: float | None = None
|
||||
|
|
@ -686,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
|
||||
|
|
|
|||
|
|
@ -19,19 +19,40 @@ from dataclasses import dataclass, field
|
|||
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
|
||||
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
|
||||
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
|
||||
# RealScraperConfig — тот же read-only адаптер над settings, что и остальные
|
||||
# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url)
|
||||
# идентичным прежнему прямому импорту settings.
|
||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
||||
from app.services.scraper_adapters import (
|
||||
RealMatcherAdapter,
|
||||
RealProxyProvider,
|
||||
RealScraperConfig,
|
||||
)
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PoolCurlConfig(RealScraperConfig):
|
||||
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
|
||||
|
||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
|
||||
services.scraper.environment), а этот бэкфилл запускается ручкой
|
||||
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
|
||||
→ `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
|
||||
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
|
||||
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
|
||||
|
||||
Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути
|
||||
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
|
||||
"""
|
||||
|
||||
@property
|
||||
def use_proxy_pool_curl(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CianPriceHistoryResult:
|
||||
checked: int = 0
|
||||
|
|
@ -60,6 +81,11 @@ async def backfill_cian_price_history(
|
|||
result = CianPriceHistoryResult()
|
||||
t0 = time.time()
|
||||
delay = get_scraper_delay("cian") # default 5.0s
|
||||
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
|
||||
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
|
||||
# (mark_banned на CianBlockedError / mark_health / release).
|
||||
scraper_config = _PoolCurlConfig()
|
||||
proxy_provider = RealProxyProvider()
|
||||
|
||||
if listing_id is not None:
|
||||
rows = (
|
||||
|
|
@ -107,9 +133,27 @@ async def backfill_cian_price_history(
|
|||
url: str = row["source_url"]
|
||||
|
||||
try:
|
||||
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
|
||||
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
|
||||
enrichment = await fetch_detail(url, config=RealScraperConfig())
|
||||
# config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси
|
||||
# datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула
|
||||
# (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про
|
||||
# `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался.
|
||||
enrichment = await fetch_detail(
|
||||
url, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except NoProxyAvailableError as exc:
|
||||
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
||||
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
||||
# логом, который читается как «Циан нас блокирует».
|
||||
logger.error(
|
||||
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
||||
"на listing_id=%s (обработано %d из %d)",
|
||||
exc,
|
||||
lid,
|
||||
i,
|
||||
len(rows),
|
||||
)
|
||||
result.errors += 1
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -152,8 +153,12 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
try:
|
||||
# proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже
|
||||
# при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403.
|
||||
# Пусто (env не задан) → прямое подключение (dev/no-op).
|
||||
_proxy_url = settings.cian_proxy_url
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если
|
||||
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
|
||||
# нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except
|
||||
# ниже.
|
||||
_proxy_url = resolve_proxy_url_sync("cian")
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
async with AsyncSession(
|
||||
impersonate="chrome120",
|
||||
|
|
@ -190,6 +195,17 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
logger.info("Cian cookies verified — userId=%s", user.get("userId"))
|
||||
|
||||
return result
|
||||
except ProxyPoolExhaustedError as exc:
|
||||
# Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены
|
||||
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
|
||||
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
|
||||
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
|
||||
logger.error(
|
||||
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
|
||||
"cookies НЕ помечены протухшими, retry на следующем такте",
|
||||
exc,
|
||||
)
|
||||
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
except Exception as exc:
|
||||
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
|
||||
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price
|
|||
from matplotlib.patches import Rectangle
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.version import product_version_line
|
||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
|
|||
_BORDER = _LINE
|
||||
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
|
||||
|
||||
# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
|
||||
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
|
||||
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
|
||||
# HUD, а не версия PDF-модуля/API.
|
||||
_REPORT_ENGINE_VERSION = "v2.0.6"
|
||||
|
||||
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
|
||||
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
|
||||
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
|
||||
|
|
@ -243,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) ──────────────
|
||||
|
|
@ -505,16 +505,48 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
|
|||
"ДАТА", report_date.strftime("%d.%m.%Y")
|
||||
)
|
||||
|
||||
# Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение
|
||||
# владельца продукта 2026-08-10, см. app/core/version.py::product_version_line.
|
||||
# Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска
|
||||
# реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера»,
|
||||
# одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под
|
||||
# существующим masthead-рядом — не растёт по высоте ни при каком контенте
|
||||
# (клипается по ширине, не переносится), top-margin (25mm) даёт под неё
|
||||
# запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты.
|
||||
version_html = (
|
||||
f'<div style="text-align:right;font-size:6.5pt;letter-spacing:0.03em;'
|
||||
f"color:{_MUTED_2};font-family:'IBM Plex Mono','DejaVu Sans Mono',monospace;"
|
||||
f'white-space:nowrap;overflow:hidden;margin-bottom:6pt;">'
|
||||
f"{_html.escape(product_version_line())}</div>"
|
||||
)
|
||||
|
||||
return (
|
||||
f"<div>"
|
||||
f'<div style="display:flex;align-items:center;justify-content:space-between;'
|
||||
f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
|
||||
f'padding-bottom:6pt;margin-bottom:9pt;">'
|
||||
f'padding-bottom:6pt;margin-bottom:3pt;">'
|
||||
f"{mark_html}"
|
||||
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
|
||||
f"</div>"
|
||||
f"{version_html}"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
# Блок 4.2 юр-требований: должен печататься в подвале КАЖДОЙ страницы отчёта
|
||||
# (не только cover). Текст утверждён владельцем продукта дословно — не менять
|
||||
# формулировку без явного запроса. Заведён как модульная константа (не inline
|
||||
# в _page_footer), чтобы не расползалась по нескольким билдерам страниц.
|
||||
_PDF_135FZ_FOOTER_NOTICE = (
|
||||
"Документ содержит индикативный (ориентировочный) расчёт стоимости объекта, "
|
||||
"сформированный автоматически сервисом «МЕРА». Не является отчётом об оценке "
|
||||
"по Федеральному закону № 135-ФЗ и не имеет установленной этим законом "
|
||||
"юридической силы. Не предназначен для использования при ипотечном "
|
||||
"кредитовании, в судебных разбирательствах, нотариальных действиях и иных "
|
||||
"случаях, где законом предусмотрено обязательное проведение независимой оценки."
|
||||
)
|
||||
|
||||
|
||||
def _page_footer(
|
||||
brand, # type: ignore[no-untyped-def]
|
||||
report_num: str,
|
||||
|
|
@ -529,12 +561,30 @@ 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), не связанный с реальной
|
||||
версией продукта — расходился с header на каждой странице, снесён.
|
||||
|
||||
page_note — старый текст 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:
|
||||
|
|
@ -566,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;">
|
||||
|
|
@ -587,11 +637,13 @@ def _page_footer(
|
|||
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
|
||||
min-width:0;overflow-wrap:anywhere;">
|
||||
{_html.escape(brand.name).upper()}</span>
|
||||
<span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
|
||||
flex-shrink:0;white-space:nowrap;">
|
||||
{_REPORT_ENGINE_VERSION}</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>
|
||||
"""
|
||||
|
||||
|
|
@ -1050,6 +1102,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
)
|
||||
report_num = _report_number(estimate)
|
||||
|
||||
# PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа
|
||||
# (retain_until), НЕ путать со «Срок действия данных» (expires_at,
|
||||
# актуальность расчёта) над ней — эта строка не трогается. Рендерится
|
||||
# ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий
|
||||
# трафик — не видят этой строки вообще, поведение бит-в-бит текущее).
|
||||
retain_until_row = (
|
||||
f'<tr><td class="dotted-row">Ссылка доступна до</td>'
|
||||
f'<td class="bold dotted-row">'
|
||||
f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}</td></tr>"
|
||||
if estimate.retain_until is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
# Короткий адрес (для cover): берём первую часть до запятой
|
||||
full_address = input_snapshot.get("address", "—")
|
||||
address_short = full_address.split(",")[0:3]
|
||||
|
|
@ -1146,6 +1211,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
<td class="bold dotted-row">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
|
||||
<tr><td class="dotted-row">Срок действия данных</td>
|
||||
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||||
{retain_until_row}
|
||||
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
||||
<tr><td class="dotted-row">Год постройки</td>
|
||||
<td class="bold dotted-row">{year_label}</td></tr>
|
||||
|
|
@ -1229,11 +1295,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
|
|||
return min(prices), max(prices)
|
||||
|
||||
|
||||
def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
|
||||
"""#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
|
||||
footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
|
||||
corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
|
||||
estimate.analogs is non-empty (the thin listings that triggered the cession
|
||||
are still shown below as reference cards — never cleared, see estimator.py
|
||||
#1871 ghost-anchor guard). Same tone/plain-sentence style as the web
|
||||
LowConfidenceBanner for this scenario. Empty string (no-op) otherwise —
|
||||
covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
|
||||
if estimate.n_analogs != 0 or not estimate.analogs:
|
||||
return ""
|
||||
return (
|
||||
f'<p style="margin:6pt 0 0 0;font-size:{_FS_SM};color:{_MUTED};line-height:1.35;">'
|
||||
"Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
|
||||
"объявлений поблизости почти нет. Объявления ниже приведены справочно, "
|
||||
"для наглядности рынка.</p>"
|
||||
)
|
||||
|
||||
|
||||
def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
|
||||
"""#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
|
||||
(estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
|
||||
already shows this (LowConfidenceBanner); the PDF stayed silent, a
|
||||
client-visible discrepancy between the two. Empty string (no-op) when
|
||||
reliability=='ok' and relaxations is empty — the common, unrelaxed case,
|
||||
byte-identical to the report before these fields existed."""
|
||||
if estimate.reliability == "ok" and not estimate.relaxations:
|
||||
return ""
|
||||
if estimate.relaxations:
|
||||
detail = "Подбор аналогов расширен: " + ", ".join(
|
||||
_html.escape(r) for r in estimate.relaxations
|
||||
)
|
||||
else:
|
||||
# relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
|
||||
# которую каскад ослаблений не смог расширить, см. estimator.py
|
||||
# #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
|
||||
# что и в счётчике выше страницы).
|
||||
detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
|
||||
return f"""
|
||||
<div style="margin-top:10pt;padding:9pt 12pt;border-left:3pt solid {_WARN};
|
||||
background:{_ACCENT_2_SOFT};font-size:{_FS_SM};color:{_INK};line-height:1.35;">
|
||||
<span style="font-weight:700;color:{_WARN};">Точность оценки снижена.</span>
|
||||
{detail} — данные ниже приведены с этой оговоркой.
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# ── Page 2: Listings (market) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
n_total = estimate.n_analogs
|
||||
# #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
|
||||
# estimate.n_analogs is the count of listings that drove the HEADLINE math —
|
||||
# it is deliberately 0 when the headline was ceded to the ДКП deals corridor
|
||||
# (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
|
||||
# listings that triggered that cession are still shown below as display cards
|
||||
# (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
|
||||
# guard comment). Printing raw n_analogs there read as "0 шт." above a
|
||||
# non-empty examples table — a client-visible contradiction. n_analogs is
|
||||
# normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
|
||||
# n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
|
||||
# no-op in that common case (count stays the honest FULL n_analogs) and only
|
||||
# changes anything in this one pathological case, where it falls back to
|
||||
# "how many are actually shown" instead of the dishonest zero.
|
||||
n_total = max(estimate.n_analogs, len(estimate.analogs))
|
||||
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
|
||||
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
|
||||
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
|
||||
|
|
@ -1292,6 +1418,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
examples_rows = _examples_rows(top5)
|
||||
|
||||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||||
# #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
|
||||
# (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
|
||||
deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
|
||||
reliability_note = _reliability_note_html(estimate, n_total)
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
|
@ -1306,6 +1436,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(f"{n_total} шт.")}</td></tr>
|
||||
</table>
|
||||
{deals_sourced_note}
|
||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||||
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
|
||||
|
|
@ -1330,6 +1461,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{reliability_note}
|
||||
|
||||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен в объявлениях</p>
|
||||
|
|
@ -1422,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%»,
|
||||
|
|
@ -1503,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>
|
||||
|
|
@ -1838,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ WHAT this is:
|
|||
pipeline, run inside ONE transaction so a crash leaves the table untouched.
|
||||
|
||||
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
|
||||
COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod —
|
||||
confirmed in migration 040 — so address is the real building key). The clean source matters:
|
||||
COALESCE(short_address, full_address, address) — the address is the only building key we
|
||||
have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on
|
||||
prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer
|
||||
rests on it). The clean source matters:
|
||||
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр»
|
||||
→ canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66»
|
||||
(→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon
|
||||
|
|
@ -116,6 +118,43 @@ MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
|
|||
asymmetry — merge allowed without a proximity check — was invisible in data before; now
|
||||
«how many merges happened beyond N metres, on which key» is one query.
|
||||
|
||||
KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
|
||||
#2690 asked for a cluster key that does not come from the normalized address, so that two
|
||||
rows merge on two independent statements of identity rather than one restated twice. Every
|
||||
field `houses` carries was checked against the live table. None qualifies:
|
||||
|
||||
cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance:
|
||||
all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are
|
||||
DaData's answer to our address string, not a second observation of the
|
||||
building. (The other cadastre we hold, listings.building_cadastral_number,
|
||||
is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building;
|
||||
#2674 refused it as an identity key and that stands.)
|
||||
house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same
|
||||
DaData provenance.
|
||||
gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share
|
||||
the canon (the guid restates it), 17 do not — and 5 of those 17 are
|
||||
>250 m apart, worst 5 064 km. Still circular, still noisy.
|
||||
zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the
|
||||
loader sets it WHERE gar_house_guid = <guid>, i.e. it IS the ГАР guid for
|
||||
4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback
|
||||
— keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon,
|
||||
193 come through the fallback, and 30 of the 31 pairs >250 m apart do too.
|
||||
source+ext_house_id, cian_internal_house_id, yandex_jk_id
|
||||
distinct by construction / 39 / 0 rows → nothing to cluster.
|
||||
coordinates a real independent observation, but not an IDENTITY: neighbours share a
|
||||
yard. It is already used the only way it can be — as the guard.
|
||||
year_built+total_floors
|
||||
a FALSE witness, not a corroborator: of the 391 same-canon pairs the
|
||||
guard cannot judge, only 18 agree on both fields (357 have a NULL), while
|
||||
306 pairs the guard rejected at >250 m DO agree — it would confirm merges
|
||||
that are provably wrong.
|
||||
|
||||
Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the
|
||||
canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the
|
||||
residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the
|
||||
guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart
|
||||
(median 1 084 m) — those are not duplicates at all, the canon key is wrong about them.
|
||||
|
||||
IDEMPOTENCY:
|
||||
Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the
|
||||
mapping is empty → every statement touches 0 rows → no-op. Re-running is safe.
|
||||
|
|
@ -165,6 +204,14 @@ _COMPLETENESS_EXPR = """
|
|||
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
|
||||
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
|
||||
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
|
||||
#
|
||||
# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле —
|
||||
# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина
|
||||
# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния).
|
||||
# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого
|
||||
# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить
|
||||
# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом
|
||||
# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей».
|
||||
_KEEPER_ORDER = f"""
|
||||
(h.geom IS NOT NULL) DESC,
|
||||
listing_cnt DESC NULLS LAST,
|
||||
|
|
@ -199,37 +246,17 @@ _CANON_KEY_EXPR = """
|
|||
"""
|
||||
|
||||
|
||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
def _ranked_cte(cluster_key_case: str) -> str:
|
||||
"""Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census
|
||||
(`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census
|
||||
built from its own copy of the clustering would drift from the pass it describes and the
|
||||
drift would be invisible (it is the same class of error as #2690's cluster key: two
|
||||
expressions that look alike and are not).
|
||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
||||
"""
|
||||
geo_guard = (
|
||||
"""
|
||||
-- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
|
||||
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
|
||||
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
|
||||
-- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
|
||||
-- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
|
||||
-- geom on either side, → left as separate rows (conservative — never over-merges).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
|
||||
if apply_geo_guard
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||
WITH clustered AS (
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -281,7 +308,41 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
|||
FROM dup_houses dh
|
||||
JOIN houses h ON h.id = dh.id
|
||||
LEFT JOIN listing_counts lc ON lc.house_id = dh.id
|
||||
)"""
|
||||
|
||||
|
||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
||||
"""
|
||||
geo_guard = (
|
||||
"""
|
||||
-- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
|
||||
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
|
||||
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
|
||||
-- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
|
||||
-- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
|
||||
-- geom on either side, → left as separate rows (conservative — never over-merges).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
|
||||
if apply_geo_guard
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||
{_ranked_cte(cluster_key_case)}
|
||||
-- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but
|
||||
-- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon
|
||||
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
|
||||
|
|
@ -314,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
|
|||
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
|
||||
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
|
||||
|
||||
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
|
||||
#
|
||||
# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY.
|
||||
# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered
|
||||
# out is counted here, bucketed by the reason it survived.
|
||||
#
|
||||
# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there
|
||||
# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a
|
||||
# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the
|
||||
# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821.
|
||||
#
|
||||
# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard
|
||||
# rejected» are opposite facts:
|
||||
# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN.
|
||||
# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are
|
||||
# NOT duplicates — the canon key is wrong about them (prod 2026-08-10:
|
||||
# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt.
|
||||
# residual_cross_fias — provably different buildings (two different ФИАС UUIDs).
|
||||
# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real
|
||||
# run; non-zero is a tripwire on the pass itself, not a census entry.
|
||||
# residual_listings is the user-visible size of the remainder (listings hanging on those rows).
|
||||
_RESIDUAL_SQL = text(
|
||||
f"""
|
||||
{_ranked_cte(_CANON_KEY_EXPR)}
|
||||
SELECT
|
||||
count(*) FILTER (WHERE rn > 1) AS residual_rows,
|
||||
COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings,
|
||||
count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL)
|
||||
AS residual_no_geom,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250)
|
||||
AS residual_mergeable
|
||||
FROM (
|
||||
SELECT rn,
|
||||
COALESCE(lc.listing_cnt, 0) AS lcnt,
|
||||
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
|
||||
THEN ST_DistanceSphere(loser_geom, keeper_geom)
|
||||
END AS dist,
|
||||
(NULLIF(loser_fias, '') IS NOT NULL
|
||||
AND NULLIF(keeper_fias, '') IS NOT NULL
|
||||
AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias
|
||||
FROM ranked
|
||||
LEFT JOIN listing_counts lc ON lc.house_id = ranked.id
|
||||
) r
|
||||
"""
|
||||
)
|
||||
|
||||
# Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op.
|
||||
_STEPS: list[tuple[str, str]] = [
|
||||
# ── Plain re-point (no UNIQUE on the FK column) ───────────────────────────
|
||||
|
|
@ -726,6 +835,15 @@ class DedupMergeResult:
|
|||
listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper
|
||||
children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children
|
||||
children_repointed: int = 0 # survivor child rows moved loser→keeper
|
||||
# Residual census (#2690): same-canon rows STILL in the table after this run, by reason.
|
||||
# Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with,
|
||||
# so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL.
|
||||
residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets)
|
||||
residual_listings: int = 0 # listings hanging on them (the user-visible size)
|
||||
residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates
|
||||
residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building
|
||||
residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings
|
||||
residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0
|
||||
dry_run: bool = False
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -736,6 +854,12 @@ class DedupMergeResult:
|
|||
"listings_repointed": self.listings_repointed,
|
||||
"children_deleted": self.children_deleted,
|
||||
"children_repointed": self.children_repointed,
|
||||
"residual_rows": self.residual_rows,
|
||||
"residual_listings": self.residual_listings,
|
||||
"residual_no_geom": self.residual_no_geom,
|
||||
"residual_far": self.residual_far,
|
||||
"residual_cross_fias": self.residual_cross_fias,
|
||||
"residual_mergeable": self.residual_mergeable,
|
||||
"dry_run": int(self.dry_run),
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -853,6 +977,49 @@ def _run_merge_pass(
|
|||
db.execute(_BACKFILL_ALIASES_SQL)
|
||||
|
||||
|
||||
def _measure_residual(db: Session, result: DedupMergeResult) -> None:
|
||||
"""Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690).
|
||||
|
||||
Read-only; runs after both passes, so it describes the table as the run leaves it (under
|
||||
dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of
|
||||
`_run_merge_pass` because the census is about the CANON key only and must be taken once per
|
||||
call, not once per pass.
|
||||
|
||||
Never fails the merge: the merge itself is the product, the census is instrumentation, and a
|
||||
census that can abort a committed-by-now transaction would be worse than a missing number.
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(_RESIDUAL_SQL).all()
|
||||
except Exception:
|
||||
logger.exception("merge_duplicate_houses: residual census failed — counters left at 0")
|
||||
return
|
||||
if not rows:
|
||||
return
|
||||
r = rows[0]
|
||||
result.residual_rows = int(r.residual_rows or 0)
|
||||
result.residual_listings = int(r.residual_listings or 0)
|
||||
result.residual_no_geom = int(r.residual_no_geom or 0)
|
||||
result.residual_far = int(r.residual_far or 0)
|
||||
result.residual_cross_fias = int(r.residual_cross_fias or 0)
|
||||
result.residual_mergeable = int(r.residual_mergeable or 0)
|
||||
logger.info(
|
||||
"merge_duplicate_houses: residual rows=%d listings=%d "
|
||||
"(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)",
|
||||
result.residual_rows,
|
||||
result.residual_listings,
|
||||
result.residual_no_geom,
|
||||
result.residual_far,
|
||||
result.residual_cross_fias,
|
||||
result.residual_mergeable,
|
||||
)
|
||||
if result.residual_mergeable:
|
||||
logger.warning(
|
||||
"merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass "
|
||||
"left work on the table (expected 0)",
|
||||
result.residual_mergeable,
|
||||
)
|
||||
|
||||
|
||||
def merge_duplicate_houses(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -908,6 +1075,10 @@ def merge_duplicate_houses(
|
|||
result=result,
|
||||
)
|
||||
|
||||
# Census of what is LEFT (read-only). Runs before the no-op early return on purpose:
|
||||
# a run that merged nothing is exactly the run whose remainder needs a number.
|
||||
_measure_residual(db, result)
|
||||
|
||||
if result.losers_deleted == 0:
|
||||
# Clean table — both passes empty. Roll back (we only opened temp tables).
|
||||
db.rollback()
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ def match_or_create_house(
|
|||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
city: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
"""Match existing house or create new canonical record.
|
||||
|
||||
|
|
@ -88,6 +89,18 @@ def match_or_create_house(
|
|||
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
|
||||
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
|
||||
|
||||
Args:
|
||||
city: город-цель развёртки, собравшей эту карточку (`save_listings(city=…)`,
|
||||
он же `listings.city`) — НЕЗАВИСИМОЕ от строки адреса наблюдение города
|
||||
(#2777). Нужен ровно там, где адресный токен города бессилен: областной
|
||||
формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный
|
||||
ключ Tier-2a вырождается в один нормализованный адрес и становится
|
||||
глобально уникальным. Опционален: вызывающие без sweep-контекста
|
||||
(estimate-путь, ad-hoc скрипты) передают None → поведение прежнее.
|
||||
Про независимость: в #2690 доказано, что усиление ключа полем, выведенным
|
||||
из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает —
|
||||
здесь признак приходит другим каналом (какой город запрашивала развёртка).
|
||||
|
||||
Returns:
|
||||
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
||||
'cadastr_exact', 'source_exact', 'fingerprint',
|
||||
|
|
@ -212,18 +225,40 @@ def match_or_create_house(
|
|||
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
|
||||
# scope, only relevant once the oblast sweep is enabled.
|
||||
#
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
|
||||
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
|
||||
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
|
||||
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
|
||||
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
|
||||
# residual needs sweep-context and is out of this fix's scope.
|
||||
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to
|
||||
# be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all
|
||||
# (resolved city None) run Tier-2a/2b exactly as before.
|
||||
#
|
||||
# #2777: the residual the comment above used to describe as out of scope — a BARE oblast
|
||||
# card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg.
|
||||
# The sweep already knows which city it was crawling and stamps it on the listing row
|
||||
# (save_listings → listings.city); that observation just never reached this guard, so
|
||||
# 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod
|
||||
# 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token
|
||||
# at all — i.e. a globally unique 'street + number' that ANY city's card can hit.
|
||||
# The address token still wins when present (it describes THIS card; the sweep city
|
||||
# describes the batch).
|
||||
_resolved_city = None
|
||||
if lat is None and lon is None:
|
||||
_resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None)
|
||||
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
|
||||
# Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со
|
||||
# «сшитыми» городами по метке listings.city):
|
||||
# • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого
|
||||
# города → страж срабатывает;
|
||||
# • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж
|
||||
# молчит: города владельца алиаса мы не знаем (в house_address_aliases его
|
||||
# нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303
|
||||
# бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1).
|
||||
# • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км
|
||||
# разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель
|
||||
# «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый
|
||||
# пункт; это ограничение данных, а не недоделка стража.
|
||||
if _skip_oblast_alias:
|
||||
logger.info(
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s",
|
||||
_resolved_city,
|
||||
city,
|
||||
norm_addr,
|
||||
ext_source,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -217,7 +217,9 @@ 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,
|
||||
)
|
||||
|
||||
|
|
@ -229,6 +231,22 @@ async def _job_deactivate_stale(
|
|||
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||
# источнику пороги приходят из default_params (миграция 219).
|
||||
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
|
||||
# Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию:
|
||||
# незасеянное расписание не должно снимать объявления по порогу ниже собственного
|
||||
# хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0.
|
||||
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(
|
||||
|
|
@ -241,6 +259,9 @@ async def _job_deactivate_stale(
|
|||
segments=segments,
|
||||
staleness_column=staleness_column,
|
||||
min_confirmations=min_confirmations,
|
||||
revisit_floor_quantile=revisit_floor_quantile,
|
||||
null_segment_only=null_segment_only,
|
||||
cap_mult=cap_mult,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -439,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): успех — это
|
||||
# «сделали то, что собирались», а не «не поймали известное исключение».
|
||||
|
|
|
|||
334
tradein-mvp/backend/app/services/proxy_egress.py
Normal file
334
tradein-mvp/backend/app/services/proxy_egress.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""Резолвер egress-прокси по источнику для ad-hoc сессий вне scrape_run (#2825).
|
||||
|
||||
ПРОБЛЕМА (доказана на проде 2026-08-10): `settings.scraper_proxy_url` (и его алиасы
|
||||
`cian_proxy_url`/`yandex_proxy_url`, все три — прямая проекция ENV `SCRAPER_PROXY_URL`,
|
||||
см. `app.core.config`) был ЕДИНСТВЕННЫМ egress для всех curl_cffi/httpx-сессий, которые
|
||||
строятся напрямую в `app/services/*` и `app/tasks/*` МИМО `app.services.proxy_pool` /
|
||||
`scraper_kit`-оркестрации. При этом `scrape_proxy_source_bans` (миграция 210, #2600 п.2)
|
||||
аккуратно вела учёт банов по паре «узел × источник» — но эти прямые сессии её никогда
|
||||
не читали и месяц ходили через узел, забаненный и Avito, и Cian.
|
||||
|
||||
ЧТО ЭТОТ МОДУЛЬ НЕ ДЕЛАЕТ: не берёт lease. `app.services.proxy_pool.acquire()` уже
|
||||
реализует pick-с-учётом-банов, но с полной lease-семантикой (leased_by/release/
|
||||
reap_stale_leases) — она рассчитана на долгоживущие `scrape_run`/`BrowserFetcher`-сессии
|
||||
(см. `RealProxyProvider` в `app.services.scraper_adapters`). Вызывающие здесь — короткие
|
||||
одноразовые fetch'и (проверка cookies, одна detail-страница) без run_id и без
|
||||
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
|
||||
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже —
|
||||
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
|
||||
и без мутаций.
|
||||
|
||||
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
|
||||
(тот же карантинный порог, что у 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` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет":
|
||||
|
||||
1. Пул ПУСТ (в `scrape_proxies` вообще нет строк — dev/staging без БД-пула, легитимный
|
||||
сценарий). Тогда fallback на `settings.scraper_proxy_url` ЛЕГИТИМЕН — пула для этого
|
||||
окружения попросту не существует, идти больше некуда. `logger.warning`.
|
||||
2. Пул НЕ пуст, но НИ ОДИН узел не прошёл фильтр для source (все забанены ИМЕННО для
|
||||
этого источника / нездоровы / выключены). Здесь fallback на `SCRAPER_PROXY_URL`
|
||||
ЗАПРЕЩЁН: инцидент 2026-08-10 — это ровно случай (2), узел статичной переменной был
|
||||
тем же самым забаненным узлом, что и в пуле, «резервный» путь тихо возвращал систему
|
||||
к первопричине. `resolve_proxy_url` в этом случае бросает `ProxyPoolExhaustedError` —
|
||||
вызывающий обязан явно отказаться от запроса (`logger.error`), а не соскользнуть на
|
||||
env в обход учёта банов.
|
||||
|
||||
НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url
|
||||
несёт логин/пароль, в лог никогда не идёт целиком), id узла и ban_count по этому
|
||||
source (0, если истории нет) — чтобы по логу было видно, что узел с историей банов
|
||||
выбран осознанно (пул исчерпан по чистым узлам), а не тихо; при legit-fallback —
|
||||
warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой
|
||||
banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы
|
||||
их нельзя было спутать в логах/алертах.
|
||||
|
||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
from app.core.db import SessionLocal as _SessionLocal
|
||||
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["ProxyPoolExhaustedError", "resolve_proxy_url", "resolve_proxy_url_sync"]
|
||||
|
||||
|
||||
class ProxyPoolExhaustedError(RuntimeError):
|
||||
"""Пул `scrape_proxies` НЕ пуст, но ни один узел не прошёл фильтр для `source`
|
||||
(все забанены именно для этого источника / нездоровы / выключены).
|
||||
|
||||
Fail-closed (#2616): вызывающий обязан явно отказаться от запроса (пропустить run
|
||||
с понятным логом), а НЕ уйти в обход пула через статичный
|
||||
`settings.scraper_proxy_url` — тот самый узел мог быть источником текущего
|
||||
инцидента (см. module docstring, сценарий 2).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: str,
|
||||
*,
|
||||
pool_total: int,
|
||||
banned_for_source: int,
|
||||
unhealthy_or_disabled: int,
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.pool_total = pool_total
|
||||
self.banned_for_source = banned_for_source
|
||||
self.unhealthy_or_disabled = unhealthy_or_disabled
|
||||
super().__init__(
|
||||
f"proxy pool exhausted for source={source!r}: pool_total={pool_total} "
|
||||
f"banned_for_source={banned_for_source} unhealthy_or_disabled={unhealthy_or_disabled}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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:
|
||||
"""host:port для логов — НИКОГДА не credentials из url (userinfo)."""
|
||||
if label:
|
||||
return label
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or "?"
|
||||
return f"{host}:{parts.port}" if parts.port else host
|
||||
except ValueError:
|
||||
return f"proxy#{proxy_id}"
|
||||
|
||||
|
||||
def _pick_candidate(db: Session, source: str) -> _Candidate | None:
|
||||
"""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 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
|
||||
"""
|
||||
),
|
||||
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
# Чистое чтение без блокировок — ничего не коммитим/не откатываем намеренно,
|
||||
# оставляем управление транзакцией вызывающему коду (тот же db может быть в
|
||||
# середине более широкой операции).
|
||||
if row is None:
|
||||
return None
|
||||
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)
|
||||
class _ExhaustionDiag:
|
||||
"""Разбивка причин "кандидата нет" — ТОЛЬКО когда пул реально не пуст (сценарий 2
|
||||
в докстринге модуля). Используется исключительно для diagnostic-лога/исключения."""
|
||||
|
||||
pool_total: int
|
||||
banned_for_source: int
|
||||
unhealthy_or_disabled: int
|
||||
|
||||
|
||||
def _diagnose_no_candidate(db: Session, source: str) -> _ExhaustionDiag:
|
||||
"""Отдельный запрос, вызывается ТОЛЬКО когда основной SELECT кандидата вернул
|
||||
пусто — не платим за агрегаты в happy-path (кандидат найден с первого запроса)."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS pool_total,
|
||||
count(*) FILTER (
|
||||
WHERE NOT enabled
|
||||
OR consecutive_fails >= CAST(:max_fails AS integer)
|
||||
) AS unhealthy_or_disabled,
|
||||
count(*) FILTER (
|
||||
WHERE enabled
|
||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND 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()
|
||||
)
|
||||
) AS banned_for_source
|
||||
FROM scrape_proxies
|
||||
"""
|
||||
),
|
||||
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
if row is None: # pragma: no cover — count(*) всегда возвращает строку
|
||||
return _ExhaustionDiag(pool_total=0, banned_for_source=0, unhealthy_or_disabled=0)
|
||||
return _ExhaustionDiag(
|
||||
pool_total=int(row["pool_total"]),
|
||||
banned_for_source=int(row["banned_for_source"]),
|
||||
unhealthy_or_disabled=int(row["unhealthy_or_disabled"]),
|
||||
)
|
||||
|
||||
|
||||
def resolve_proxy_url(db: Session, source: str) -> str | None:
|
||||
"""Egress-URL для source (avito/cian/yandex/domclick) — пул с учётом банов пары
|
||||
«узел × источник». См. докстринг модуля за разбором двух РАЗНЫХ исходов
|
||||
"кандидата нет":
|
||||
|
||||
- пул пуст (0 строк в `scrape_proxies`) → fallback на
|
||||
`settings.scraper_proxy_url`, `logger.warning`, легитимный dev/staging-сценарий;
|
||||
- пул не пуст, все отсеяны (баны/health/disabled) → `ProxyPoolExhaustedError`
|
||||
(`logger.error`), fail-closed — БЕЗ прохода через статичный env.
|
||||
|
||||
БД пула недоступна (connection error и т.п., напр. dev-окружение без поднятой БД)
|
||||
— трактуем КАК пустой пул (не можем подтвердить exhaustion — небезопасно поднимать
|
||||
error/исключение по неполным данным), `logger.warning` + explicit (не silent
|
||||
failure). Отличается от сценария exhaustion: там мы ТОЧНО знаем, что узлы есть и
|
||||
все отсеяны; здесь мы вообще ничего не знаем о пуле.
|
||||
"""
|
||||
try:
|
||||
candidate = _pick_candidate(db, source)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies недоступен (ошибка БД), "
|
||||
"лечим как пустой пул (fallback на статичный SCRAPER_PROXY_URL)",
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
|
||||
# "current transaction is aborted") — если db переживёт этот вызов
|
||||
# (долгоживущая caller-сессия, напр. avito_detail_backfill/
|
||||
# yandex_detail_backfill), последующие запросы на ней иначе все падали
|
||||
# бы с той же ошибкой, маскируя реальную причину.
|
||||
db.rollback()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- rollback после сбоя пула тоже не удался",
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
return _settings.scraper_proxy_url
|
||||
|
||||
if candidate is not None:
|
||||
logger.info(
|
||||
"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
|
||||
|
||||
diag = _diagnose_no_candidate(db, source)
|
||||
|
||||
if diag.pool_total == 0:
|
||||
# Сценарий 1: пул для этого окружения попросту не сконфигурирован
|
||||
# (dev/staging без БД-пула) — легитимный fallback.
|
||||
fallback = _settings.scraper_proxy_url
|
||||
if fallback:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies ПУСТ (0 записей), "
|
||||
"окружение без БД-пула -- идём через статичный SCRAPER_PROXY_URL "
|
||||
"(fallback)",
|
||||
source,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies пуст и SCRAPER_PROXY_URL "
|
||||
"не задан, идём прямым подключением без прокси",
|
||||
source,
|
||||
)
|
||||
return fallback
|
||||
|
||||
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
|
||||
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
|
||||
logger.error(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
|
||||
"не прошёл фильтр для этого источника (banned_for_source=%d, "
|
||||
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через "
|
||||
"статичный SCRAPER_PROXY_URL (тот самый узел мог быть источником инцидента)",
|
||||
source,
|
||||
diag.pool_total,
|
||||
diag.banned_for_source,
|
||||
diag.unhealthy_or_disabled,
|
||||
)
|
||||
raise ProxyPoolExhaustedError(
|
||||
source,
|
||||
pool_total=diag.pool_total,
|
||||
banned_for_source=diag.banned_for_source,
|
||||
unhealthy_or_disabled=diag.unhealthy_or_disabled,
|
||||
)
|
||||
|
||||
|
||||
def resolve_proxy_url_sync(source: str) -> str | None:
|
||||
"""Как `resolve_proxy_url`, но сама открывает короткую `SessionLocal()` — для
|
||||
вызывающих без готового `db` в сигнатуре (напр. `cian_session.verify_session`).
|
||||
|
||||
`ProxyPoolExhaustedError` из `resolve_proxy_url` пробрасывается как есть (fail-closed) —
|
||||
вызывающий обязан явно её поймать и решить, как деградировать (см. call site'ы).
|
||||
"""
|
||||
db = _SessionLocal()
|
||||
try:
|
||||
return resolve_proxy_url(db, source)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
|
|||
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
|
||||
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
|
||||
|
||||
Проба на ПАРУ «узел × источник» (#2800, продолжение #2723):
|
||||
- #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом
|
||||
для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв»
|
||||
вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500
|
||||
NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и
|
||||
свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен».
|
||||
- Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может
|
||||
достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки
|
||||
(apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а
|
||||
`bff-search-web.domclick.ru`, куда ходит сбор, — 500).
|
||||
- Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой
|
||||
сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый
|
||||
отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки.
|
||||
Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt
|
||||
площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать
|
||||
дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»).
|
||||
- Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение
|
||||
«браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО
|
||||
креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла.
|
||||
Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками
|
||||
вернули то самое схлопывание диагнозов.
|
||||
|
||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||
"""
|
||||
|
||||
|
|
@ -125,6 +147,7 @@ __all__ = [
|
|||
"mark_banned",
|
||||
"mark_browser_health",
|
||||
"mark_health",
|
||||
"mark_source_probe",
|
||||
"reap_stale_leases",
|
||||
"release",
|
||||
"run_proxy_healthcheck",
|
||||
|
|
@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360
|
|||
# намеренно не обновляется (см. mark_browser_health).
|
||||
BROWSER_UNFIT_THRESHOLD = 2
|
||||
|
||||
# ── проба на пару «узел × источник» (#2800) ──────────────────────────────────
|
||||
# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт):
|
||||
# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито;
|
||||
# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть
|
||||
# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch;
|
||||
# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов
|
||||
# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at).
|
||||
# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при
|
||||
# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было.
|
||||
# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и
|
||||
# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором
|
||||
# acquire снова выдаёт узел, не спросив.
|
||||
#
|
||||
# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её
|
||||
# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная
|
||||
# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка
|
||||
# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка
|
||||
# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре.
|
||||
_PROBE_BAN_REASON = "probe:browser"
|
||||
|
||||
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
|
||||
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
||||
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
||||
|
|
@ -228,14 +271,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
||||
чужой affinity (#2600).
|
||||
|
||||
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см.
|
||||
173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1),
|
||||
намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого
|
||||
одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex,
|
||||
domclick останется без прокси вообще — хуже, чем голодание исходного источника,
|
||||
которое фикс призван устранить. Кандидат участвует в fallback, только если его
|
||||
affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) —
|
||||
т.е. выдача не обнулит доступность выделенной affinity целиком.
|
||||
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
|
||||
fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже,
|
||||
чем голодание исходного источника, которое фикс призван устранить. Кандидат
|
||||
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
|
||||
enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной
|
||||
affinity целиком.
|
||||
|
||||
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql —
|
||||
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
|
||||
банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до
|
||||
рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а
|
||||
Авито/Яндекс через него работают — резервация держала узел за источником, которому
|
||||
он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде
|
||||
сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и
|
||||
следующий выделенный узел должен получить её сразу, а не после повторного разбора.
|
||||
|
||||
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
|
||||
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
|
||||
|
|
@ -675,9 +725,43 @@ def mark_browser_health(
|
|||
return "fail"
|
||||
|
||||
|
||||
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||
def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str:
|
||||
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
|
||||
|
||||
Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары
|
||||
принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего
|
||||
узла) | "missing" (нет такого proxy_id).
|
||||
|
||||
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
|
||||
умолчанию 'banned:<source>' (бан распознан боевым сбором), у браузерной пробы —
|
||||
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
|
||||
clear_source_bans умеет фильтровать по ней (`only_reason`).
|
||||
|
||||
ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026:
|
||||
пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через
|
||||
ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр
|
||||
«снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ:
|
||||
присвоенная строка становится «своей», и следующая успешная проба снимает ею бан,
|
||||
который поставил боевой сбор по настоящему отказу площадки. Плюс теряется
|
||||
происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба
|
||||
не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count
|
||||
начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых
|
||||
пары с 6 ч до 12 ч).
|
||||
|
||||
Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет),
|
||||
ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`).
|
||||
Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно»
|
||||
только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен
|
||||
УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего.
|
||||
|
||||
АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее
|
||||
(площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится
|
||||
ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный
|
||||
robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально
|
||||
и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного);
|
||||
обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об
|
||||
эскалации боевых банов пары.
|
||||
|
||||
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
||||
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
||||
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
||||
|
|
@ -736,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
|
||||
(падает как обычно) — caller решает, ловить или нет.
|
||||
"""
|
||||
# Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё
|
||||
# (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ").
|
||||
live_reason = f"banned:{source}"
|
||||
effective_reason = reason or live_reason
|
||||
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
|
||||
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
|
||||
db.execute(
|
||||
|
|
@ -807,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
) AS integer)),
|
||||
reason = CAST(:reason AS text),
|
||||
updated_at = now()
|
||||
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
|
||||
-- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее
|
||||
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
|
||||
WHERE scrape_proxy_source_bans.banned_until <= now()
|
||||
OR scrape_proxy_source_bans.reason = CAST(:reason AS text)
|
||||
OR CAST(:reason AS text) = CAST(:live_reason AS text)
|
||||
RETURNING ban_count, banned_until
|
||||
"""
|
||||
),
|
||||
{
|
||||
"proxy_id": proxy_id,
|
||||
"source": source,
|
||||
"reason": f"banned:{source}",
|
||||
"reason": effective_reason,
|
||||
"live_reason": live_reason,
|
||||
"base_hours": SOURCE_BAN_BASE_HOURS,
|
||||
"max_hours": SOURCE_BAN_MAX_HOURS,
|
||||
"max_fails": MAX_CONSECUTIVE_FAILS,
|
||||
|
|
@ -833,10 +928,40 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
row["banned_until"],
|
||||
row["ban_count"],
|
||||
)
|
||||
return
|
||||
return "banned"
|
||||
|
||||
# 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита
|
||||
# последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога
|
||||
# (на решение уже не влияет), но диагноз должен называть то, что произошло.
|
||||
holder = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT reason, banned_until
|
||||
FROM scrape_proxy_source_bans
|
||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||
AND source = CAST(:source AS text)
|
||||
AND banned_until > now()
|
||||
"""
|
||||
),
|
||||
{"proxy_id": proxy_id, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
if holder is not None and holder["reason"] != effective_reason:
|
||||
logger.info(
|
||||
"proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт "
|
||||
"%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, "
|
||||
"иначе проба присвоила бы чужой бан и потом сняла бы его как свой)",
|
||||
proxy_id,
|
||||
source,
|
||||
holder["reason"],
|
||||
holder["banned_until"],
|
||||
effective_reason,
|
||||
)
|
||||
return "deferred"
|
||||
|
||||
# 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем
|
||||
# текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет).
|
||||
current = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -849,17 +974,25 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
)
|
||||
if current is None:
|
||||
logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
|
||||
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
|
||||
"источнику (голодание хуже, чем работа через забаненный узел).",
|
||||
proxy_id,
|
||||
source,
|
||||
)
|
||||
return "missing"
|
||||
logger.warning(
|
||||
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
|
||||
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
|
||||
"источнику (голодание хуже, чем работа через забаненный узел).",
|
||||
proxy_id,
|
||||
source,
|
||||
)
|
||||
return "protected"
|
||||
|
||||
|
||||
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
|
||||
def clear_source_bans(
|
||||
db: Session,
|
||||
proxy_id: int,
|
||||
*,
|
||||
source: str | None = None,
|
||||
reason: str,
|
||||
only_reason: str | None = None,
|
||||
) -> int:
|
||||
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
|
||||
|
||||
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
||||
|
|
@ -882,6 +1015,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
SOURCE_BAN_BASE_HOURS.
|
||||
|
||||
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
||||
|
||||
`only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
|
||||
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
|
||||
свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
|
||||
распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
|
||||
IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
|
||||
целиком. None — снимать всё, как и раньше.
|
||||
"""
|
||||
rows = db.execute(
|
||||
text(
|
||||
|
|
@ -889,10 +1029,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
DELETE FROM scrape_proxy_source_bans
|
||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
||||
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
|
||||
RETURNING source
|
||||
"""
|
||||
),
|
||||
{"proxy_id": proxy_id, "source": source},
|
||||
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
|
||||
).fetchall()
|
||||
db.commit()
|
||||
if rows:
|
||||
|
|
@ -906,6 +1047,79 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
return len(rows)
|
||||
|
||||
|
||||
def mark_source_probe(
|
||||
db: Session,
|
||||
proxy_id: int,
|
||||
*,
|
||||
source: str,
|
||||
ok: bool,
|
||||
fail_kind: str | None = None,
|
||||
detail: str = "",
|
||||
) -> str:
|
||||
"""Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800).
|
||||
|
||||
Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или
|
||||
не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает
|
||||
`acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим.
|
||||
|
||||
КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница
|
||||
другая — здесь судится ПАРА, а не узел):
|
||||
- "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored".
|
||||
Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз);
|
||||
- "proxy" — через этот узел до площадки не доходит транспорт
|
||||
(NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары;
|
||||
- "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку
|
||||
(200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары.
|
||||
Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для
|
||||
ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя.
|
||||
|
||||
Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
|
||||
боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
|
||||
дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная
|
||||
половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не
|
||||
присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь
|
||||
присвоенная строка уже «своя».
|
||||
|
||||
Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего
|
||||
своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
|
||||
пишется (голодание хуже работы через плохой узел).
|
||||
|
||||
Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned`
|
||||
("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном
|
||||
только реально записанный бан.
|
||||
"""
|
||||
if ok:
|
||||
cleared = clear_source_bans(
|
||||
db,
|
||||
proxy_id,
|
||||
source=source,
|
||||
reason=f"browser probe OK for source={source} ({detail})",
|
||||
only_reason=_PROBE_BAN_REASON,
|
||||
)
|
||||
return "cleared" if cleared else "ok"
|
||||
|
||||
if fail_kind not in ("proxy", "page"):
|
||||
logger.warning(
|
||||
"proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре "
|
||||
"(fail_kind=%s): %s — вердикт не пишем",
|
||||
proxy_id,
|
||||
source,
|
||||
fail_kind,
|
||||
detail,
|
||||
)
|
||||
return "ignored"
|
||||
|
||||
logger.warning(
|
||||
"proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан "
|
||||
"пары, узел остаётся первосортным для остальных площадок (#2800)",
|
||||
proxy_id,
|
||||
source,
|
||||
fail_kind,
|
||||
detail,
|
||||
)
|
||||
return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
|
||||
|
||||
|
||||
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
||||
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
|
||||
|
||||
|
|
@ -969,8 +1183,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
|||
return False, None, None, "other"
|
||||
|
||||
|
||||
async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str:
|
||||
"""Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health.
|
||||
def _probe_sources_for(affinity: str) -> list[str]:
|
||||
"""Источники, которым узел с такой affinity МОЖЕТ достаться (#2800).
|
||||
|
||||
Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`.
|
||||
Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику,
|
||||
которой никто не воспользуется.
|
||||
|
||||
ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих
|
||||
свободных нет) — такая пара останется без вердикта и решится как раньше, по факту
|
||||
прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же
|
||||
только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос
|
||||
бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4.
|
||||
"""
|
||||
from scraper_kit.browser_fetcher import PROBE_SOURCES
|
||||
|
||||
return [s for s in PROBE_SOURCES if affinity in (s, "any")]
|
||||
|
||||
|
||||
async def _run_pair_probes(
|
||||
db: Session, proxy_id: int, url: str, kind: str, affinity: str
|
||||
) -> tuple[str, dict[str, int]]:
|
||||
"""Крест «этот узел × каждая его площадка» + запись вердиктов (#2800).
|
||||
|
||||
Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам).
|
||||
|
||||
Два уровня вердикта, и они не пересекаются:
|
||||
- ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке
|
||||
отдельно, это то, что читает `acquire(source)`;
|
||||
- УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по
|
||||
итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел
|
||||
работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки
|
||||
узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов
|
||||
появилось бы новое.
|
||||
|
||||
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
|
||||
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
|
||||
|
|
@ -978,18 +1223,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) ->
|
|||
"""
|
||||
from scraper_kit.browser_fetcher import probe_proxy_via_browser
|
||||
|
||||
try:
|
||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||
_settings.browser_http_endpoint, url, proxy_kind=kind
|
||||
counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
|
||||
fail_kinds: list[str] = []
|
||||
any_ok = False
|
||||
last_detail = ""
|
||||
|
||||
for source in _probe_sources_for(affinity):
|
||||
try:
|
||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||
)
|
||||
if not ok and fail_kind == "proxy":
|
||||
# Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
|
||||
# флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
|
||||
# идёт по уже поднятому браузеру с тем же прокси — секунды, и только
|
||||
# на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
|
||||
# (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
|
||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
|
||||
proxy_id,
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
counters["pair_checked"] += 1
|
||||
last_detail = detail
|
||||
if ok:
|
||||
any_ok = True
|
||||
else:
|
||||
fail_kinds.append(fail_kind or "other")
|
||||
outcome = mark_source_probe(
|
||||
db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан",
|
||||
proxy_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return "ignored"
|
||||
return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
|
||||
if outcome == "banned":
|
||||
counters["pair_banned"] += 1
|
||||
elif outcome == "cleared":
|
||||
counters["pair_cleared"] += 1
|
||||
|
||||
if counters["pair_checked"] == 0:
|
||||
return "ignored", counters # крест не состоялся — узел не судим
|
||||
|
||||
if any_ok:
|
||||
return mark_browser_health(db, proxy_id, True, detail=last_detail), counters
|
||||
# Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ —
|
||||
# транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле».
|
||||
node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0]
|
||||
return (
|
||||
mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail),
|
||||
counters,
|
||||
)
|
||||
|
||||
|
||||
def _mask(url: str) -> str:
|
||||
|
|
@ -1025,18 +1312,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
||||
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
||||
|
||||
БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером
|
||||
дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же
|
||||
тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на
|
||||
robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и
|
||||
никогда не смешивается с consecutive_fails/enabled. Гейт — settings.
|
||||
use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба
|
||||
измеряла бы то, чем никто не пользуется.
|
||||
БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
|
||||
проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
|
||||
САЙДКАР — по одной навигации на каждую площадку, которую этот узел может
|
||||
обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
|
||||
потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
|
||||
scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные
|
||||
browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт —
|
||||
settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
|
||||
проба измеряла бы то, чем никто не пользуется.
|
||||
|
||||
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
||||
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
|
||||
browser_unfit, browser_refit}.
|
||||
browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}.
|
||||
"""
|
||||
reaped = reap_stale_leases(db)
|
||||
|
||||
|
|
@ -1044,7 +1333,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, kind, enabled, disabled_reason,
|
||||
SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
|
||||
(browser_check_at IS NULL
|
||||
OR browser_check_at < now() - make_interval(
|
||||
mins => CAST(:browser_probe_minutes AS integer)
|
||||
|
|
@ -1075,6 +1364,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
browser_ok = 0
|
||||
browser_unfit = 0
|
||||
browser_refit = 0
|
||||
pair_checked = 0
|
||||
pair_banned = 0
|
||||
pair_cleared = 0
|
||||
for row in proxies:
|
||||
proxy_id = int(row["id"])
|
||||
url = str(row["url"])
|
||||
|
|
@ -1104,8 +1396,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
|
||||
# не тот узел», из-за которого #2723 и появилась.
|
||||
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
|
||||
outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"]))
|
||||
outcome, pair_counters = await _run_pair_probes(
|
||||
db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"])
|
||||
)
|
||||
browser_checked += 1
|
||||
pair_checked += pair_counters["pair_checked"]
|
||||
pair_banned += pair_counters["pair_banned"]
|
||||
pair_cleared += pair_counters["pair_cleared"]
|
||||
if outcome in ("ok", "refit"):
|
||||
browser_ok += 1
|
||||
if outcome == "refit":
|
||||
|
|
@ -1135,7 +1432,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
|
||||
logger.info(
|
||||
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
|
||||
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d",
|
||||
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d "
|
||||
"pair_checked=%d pair_banned=%d pair_cleared=%d",
|
||||
reaped,
|
||||
checked,
|
||||
ok_count,
|
||||
|
|
@ -1146,6 +1444,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
browser_ok,
|
||||
browser_unfit,
|
||||
browser_refit,
|
||||
pair_checked,
|
||||
pair_banned,
|
||||
pair_cleared,
|
||||
)
|
||||
return {
|
||||
"reaped": reaped,
|
||||
|
|
@ -1160,4 +1461,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
"browser_ok": browser_ok,
|
||||
"browser_unfit": browser_unfit,
|
||||
"browser_refit": browser_refit,
|
||||
# Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и
|
||||
# pair_banned=2 одновременно — это не противоречие, а точный диагноз
|
||||
# «браузер через узел работает, но две площадки его не пускают».
|
||||
"pair_checked": pair_checked,
|
||||
"pair_banned": pair_banned,
|
||||
"pair_cleared": pair_cleared,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -179,6 +206,166 @@ def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> Non
|
|||
)
|
||||
|
||||
|
||||
def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
|
||||
"""Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625).
|
||||
|
||||
Возвращает текст причины (для error) либо None, если прогон таким не является.
|
||||
|
||||
Третий исход, у которого не было терминального статуса. Развёртка различает:
|
||||
1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу →
|
||||
`mark_banned` в самих sweep'ах (#2642, cian/yandex);
|
||||
2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений →
|
||||
`done` с нулём, это здоровый результат (в Серове реально 10 объявлений);
|
||||
3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как
|
||||
что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает
|
||||
НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки),
|
||||
и статуса ему никто не выдал — прогон уходил в `done`.
|
||||
|
||||
Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров:
|
||||
`errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что
|
||||
отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ
|
||||
доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус
|
||||
'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается
|
||||
по умолчанию).
|
||||
|
||||
Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала —
|
||||
`errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор).
|
||||
|
||||
Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов
|
||||
(yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с,
|
||||
таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5;
|
||||
yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса).
|
||||
НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной
|
||||
пустоты (errors_count=0) — они остаются 'done'.
|
||||
"""
|
||||
anchors = _pick_int(counters, "anchors_total")
|
||||
errors = _pick_int(counters, "errors_count")
|
||||
if not anchors or anchors <= 0 or errors is None or errors < anchors:
|
||||
return None
|
||||
if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает
|
||||
return None
|
||||
return (
|
||||
f"sweep-honest-status: отказом кончились все {anchors} якорей прогона "
|
||||
f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ "
|
||||
f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или "
|
||||
f"блокировкой площадки — статус 'failed' без диагноза (#2625)"
|
||||
)
|
||||
|
||||
|
||||
# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
|
||||
# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
|
||||
# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
|
||||
# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
|
||||
# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
|
||||
# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
|
||||
_PHASE_MIN_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
|
||||
"""Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
|
||||
|
||||
Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
|
||||
случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на
|
||||
сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
|
||||
|
||||
Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
|
||||
detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала
|
||||
HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
|
||||
прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
|
||||
подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
|
||||
|
||||
Признак — собственная бухгалтерия фазы: `<phase>_failed == <phase>_attempted` при
|
||||
`attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
|
||||
`X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это
|
||||
ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
|
||||
настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
|
||||
|
||||
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
|
||||
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
|
||||
|
||||
Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по
|
||||
боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed'
|
||||
42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не
|
||||
знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 —
|
||||
это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов
|
||||
отказали обе фазы (detail и houses) сразу, и они посчитались дважды.
|
||||
"""
|
||||
for key in sorted(counters):
|
||||
if not key.endswith("_attempted"):
|
||||
continue
|
||||
phase = key[: -len("_attempted")]
|
||||
attempted = _pick_int(counters, key)
|
||||
failed = _pick_int(counters, f"{phase}_failed")
|
||||
if attempted is None or failed is None:
|
||||
continue
|
||||
if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
|
||||
return (
|
||||
f"phase-honest-status: фаза '{phase}' отказала полностью — "
|
||||
f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
|
||||
f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
|
||||
f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
|
||||
f"'failed' без диагноза (#2700)"
|
||||
)
|
||||
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.
|
||||
|
||||
|
|
@ -189,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:
|
||||
|
|
@ -445,7 +643,37 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
|||
|
||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
|
||||
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
|
||||
|
||||
#2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился
|
||||
каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а
|
||||
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
|
||||
страж, который надо не забыть позвать, — это тот же дефект оборванной проводки,
|
||||
из-за которого задача и появилась.
|
||||
|
||||
#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:
|
||||
logger.error("%s run_id=%d", did_nothing, run_id)
|
||||
mark_failed(db, run_id, did_nothing, counters)
|
||||
return
|
||||
phase_dead = _phase_totally_failed(counters)
|
||||
if phase_dead is not 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(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class RealMatcherAdapter:
|
|||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
city: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
# house_id is None when the matcher refuses a numberless address without a
|
||||
# cadastral number (method 'no_house_number', P1). Callers must tolerate None.
|
||||
|
|
@ -80,6 +81,7 @@ class RealMatcherAdapter:
|
|||
year_built=year_built,
|
||||
building_cadastral_number=building_cadastral_number,
|
||||
source_url=source_url,
|
||||
city=city,
|
||||
)
|
||||
|
||||
def upsert_listing_source(
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ async def backfill_yandex_addresses(
|
|||
Returns:
|
||||
YandexAddressBackfillResult with checked/saved/skipped/errors counters.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
|
||||
result = YandexAddressBackfillResult()
|
||||
t0 = time.time()
|
||||
|
|
@ -130,7 +130,23 @@ async def backfill_yandex_addresses(
|
|||
request_delay_sec,
|
||||
)
|
||||
|
||||
_proxy_url = settings.scraper_proxy_url
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если пул
|
||||
# пуст (легитимный dev/staging-сценарий).
|
||||
try:
|
||||
_proxy_url = resolve_proxy_url(db, "yandex")
|
||||
except ProxyPoolExhaustedError as exc:
|
||||
# Fail-closed (#2616, #2825): пул не пуст, но все узлы забанены для yandex/
|
||||
# нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module
|
||||
# docstring). Явный пропуск run'а вместо слепого прохода через egress, который
|
||||
# мог быть источником текущего инцидента.
|
||||
logger.error(
|
||||
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
|
||||
"пропущен, ни один листинг не обработан",
|
||||
exc,
|
||||
)
|
||||
result.duration_sec = time.time() - t0
|
||||
return result
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
|
||||
async with AsyncSession(
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ from sqlalchemy.orm import Session
|
|||
from app.core.config import settings
|
||||
from app.core.shutdown import shutdown_requested
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.proxy_egress import resolve_proxy_url
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
# #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше
|
||||
|
|
@ -108,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
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -268,12 +276,19 @@ async def run_avito_detail_backfill(
|
|||
elif not use_curl:
|
||||
# curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False):
|
||||
# строим shared сессию через auv, как делает run_avito_city_sweep (kit).
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только
|
||||
# если пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все
|
||||
# забанены/нездоровы для avito -- resolve_proxy_url бросает
|
||||
# ProxyPoolExhaustedError (fail-closed, #2616): НАРОЧНО не ловим здесь --
|
||||
# штатный except Exception ниже (mark_failed + logger.exception + raise)
|
||||
# уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен.
|
||||
own_session = True
|
||||
session = AsyncSession(
|
||||
impersonate="chrome120",
|
||||
timeout=25,
|
||||
headers=DOCUMENT_HEADERS,
|
||||
proxies=http_proxies(settings.scraper_proxy_url),
|
||||
proxies=http_proxies(resolve_proxy_url(db, "avito")),
|
||||
)
|
||||
scraper._cffi = session
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
||||
from app.services.scraper_adapters import (
|
||||
RealMatcherAdapter,
|
||||
RealProxyProvider,
|
||||
RealScraperConfig,
|
||||
)
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -230,7 +234,13 @@ async def backfill_cian_history(
|
|||
|
||||
enrichment = None
|
||||
try:
|
||||
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
|
||||
# proxy_provider (#2767): тот же сожжённый env-узел бил и сюда —
|
||||
# это второй вызывающий fetch_newbuilding, чинить надо оба.
|
||||
enrichment = await fetch_newbuilding(
|
||||
zhk_url,
|
||||
config=RealScraperConfig(),
|
||||
proxy_provider=RealProxyProvider(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cian_newbuilding fetch failed for house_id=%s url=%s: %s",
|
||||
|
|
|
|||
|
|
@ -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' (площадка
|
||||
|
|
@ -24,6 +44,7 @@ TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_ST
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
|
@ -126,15 +147,205 @@ 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"
|
||||
|
||||
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
|
||||
|
||||
# ── Пол TTL по измеренному циклу переобхода (#2659) ───────────────────────────
|
||||
# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на
|
||||
# вопрос, из-за которого заведён #2659: «а достаточно ли ttl_days, чтобы молчание
|
||||
# означало снятие?». Пока свип возвращается к строке реже, чем раз в ttl_days,
|
||||
# TTL меряет НАШУ выборку, а не жизнь объявления, — и источник при этом полностью
|
||||
# здоров, так что гейт молчит.
|
||||
#
|
||||
# ЗАМЕР НА ПРОДЕ 2026-08-09, из-за которого этот пол существует.
|
||||
# С момента деплоя гейта (06.08) TTL снял 1 028 строк; 127 из них (12.4%) УЖЕ снова
|
||||
# активны — свип нашёл их живыми через 1-3 суток и вернул сам (upsert в
|
||||
# scraper_kit/base.py ставит is_active = true). В единственном городе с настоящим
|
||||
# покрытием доля ложных снятий 100%:
|
||||
# cian Екатеринбург 103 снято → 103 снова активны
|
||||
# yandex Екатеринбург 24 снято → 24 снова активны
|
||||
# cian/yandex без города 901 снято → 0 вернулись (их свип не обходит вовсе)
|
||||
# Возраст на момент снятия у всех 127: 29.9..30.3 суток при TTL=30 — то есть TTL
|
||||
# срабатывал ровно на границе, а свип возвращался к строке на 31-34-е сутки.
|
||||
#
|
||||
# ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ НОВОЙ КОНСТАНТОЙ. Разрывы переобхода, суток
|
||||
# (listing_source_snapshots, 40 суток, посчитано по срезу TTL-джобы):
|
||||
# источник/сегмент p90 p99 TTL сейчас TTL/p99
|
||||
# domklik vtorichka 1.9 3.1 14 4.5 ← сплошное суточное покрытие
|
||||
# cian vtorichka 10.9 26.6 30 1.1
|
||||
# yandex vtorichka 5.7 43.0 30 0.7
|
||||
# avito vtorichka 29.1 42.1 10 0.24 ← отсюда 9 033 строки
|
||||
# Домклик — контрольная группа: при почти полном суточном обходе TTL=14 лежит в
|
||||
# 4.5 раза выше хвоста, и снятие у него действительно означает снятие. У остальных
|
||||
# трёх порог ниже собственного хвоста обхода — руками подобранное число и есть
|
||||
# корень #2659, поэтому чинить его вторым руками подобранным числом бессмысленно.
|
||||
#
|
||||
# ЧТО МЕРЯЕМ ВМЕСТО КОНСТАНТЫ: факт, а не оценку. «Какой самый большой возраст, при
|
||||
# котором свип за последнее окно ДОКАЗАЛ, что объявление живо» — то есть насколько
|
||||
# старую строку он только что нашёл на площадке. Если свип буквально вчера вернул к
|
||||
# жизни строку, молчавшую 40 суток, то 30 суток молчания не доказывают ничего.
|
||||
# Пол = квантиль этого распределения, эффективный TTL = max(ttl_days, пол).
|
||||
#
|
||||
# Считается по ТОМУ ЖЕ срезу (source + segments) и по ТОЙ ЖЕ колонке свежести, что
|
||||
# и UPDATE. Предыдущее наблюдение берётся из listing_source_snapshots — единственной
|
||||
# истории свежести, что у нас есть; расхождение listings.<col> и
|
||||
# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем
|
||||
# (максимум 0), что на шкале 30-70 суток шум.
|
||||
#
|
||||
# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан
|
||||
# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер
|
||||
# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka.
|
||||
# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params
|
||||
# расписания (revisit_floor_quantile), 0 -> пол выключен.
|
||||
#
|
||||
# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов
|
||||
# распухает (свип разгребает завал и находит очень старые строки), пол поднимается,
|
||||
# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран,
|
||||
# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого
|
||||
# issue просил от «гейта по банам», но выраженное через результат, а не через причину.
|
||||
#
|
||||
# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную
|
||||
# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более
|
||||
# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан.
|
||||
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"
|
||||
|
||||
|
||||
# ── Потолок эффективного 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)» даёт
|
||||
разрыв переобхода в сутках; берём его квантиль по срезу source+segments.
|
||||
Только строки, у которых свежесть реально сдвинулась, — то есть выжившие,
|
||||
а не «мы к ним не приходили».
|
||||
|
||||
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).
|
||||
"""
|
||||
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))
|
||||
WITHIN GROUP (
|
||||
ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at))
|
||||
/ 86400.0
|
||||
)
|
||||
FROM listings l
|
||||
JOIN listing_sources ls
|
||||
ON ls.listing_id = l.id
|
||||
AND ls.ext_source = l.source
|
||||
JOIN listing_source_snapshots prev
|
||||
ON prev.listing_source_id = ls.id
|
||||
AND prev.snapshot_date = (
|
||||
SELECT max(snapshot_date)
|
||||
FROM listing_source_snapshots
|
||||
WHERE snapshot_date
|
||||
<= CURRENT_DATE - CAST(:health_window_days AS integer)
|
||||
)
|
||||
WHERE l.source = :listing_source
|
||||
AND l.{staleness_column}
|
||||
> NOW() - CAST(:health_window_days || ' days' AS interval)
|
||||
AND l.{staleness_column} > prev.last_seen_at{segment_filter}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
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(*)
|
||||
|
|
@ -190,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")
|
||||
|
|
@ -209,6 +447,9 @@ def deactivate_stale_listings(
|
|||
staleness_column: str = "last_seen_at",
|
||||
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 дней.
|
||||
|
||||
|
|
@ -218,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
|
||||
|
|
@ -229,6 +471,27 @@ def deactivate_stale_listings(
|
|||
вызывают старые тесты и совместимая обёртка); реальные значения приходят
|
||||
из default_params расписания, см. миграцию 219 и комментарий выше.
|
||||
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
|
||||
revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659).
|
||||
Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой;
|
||||
эффективный 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
|
||||
|
|
@ -236,14 +499,59 @@ def deactivate_stale_listings(
|
|||
|
||||
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
||||
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
|
||||
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута.
|
||||
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял
|
||||
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),
|
||||
|
|
@ -253,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. Деактивация необратима
|
||||
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
||||
|
|
@ -266,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
|
||||
|
|
@ -281,25 +598,110 @@ 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
|
||||
|
||||
# segments is None -> все сегменты (поведение avito). segments=[...] -> только
|
||||
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
|
||||
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
|
||||
# а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник.
|
||||
if segments is not None:
|
||||
# Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же
|
||||
# срезу. Поднимает порог (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] = {
|
||||
"listing_source": listing_source,
|
||||
"health_window_days": health_window_days,
|
||||
"revisit_quantile": revisit_floor_quantile,
|
||||
}
|
||||
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,
|
||||
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))
|
||||
# Пол поднимает 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 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 сут "
|
||||
"(квантиль %.3f, segments=%r) — при ttl_days=%d снятие означало бы "
|
||||
"«мы не дошли», а не «объявление снято»",
|
||||
listing_source,
|
||||
run_id,
|
||||
ttl_days,
|
||||
effective_ttl_days,
|
||||
health_window_days,
|
||||
counters["revisit_floor_days"],
|
||||
revisit_floor_quantile,
|
||||
segments,
|
||||
ttl_days,
|
||||
)
|
||||
|
||||
# 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": ttl_days,
|
||||
"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,
|
||||
"run_id": run_id,
|
||||
}
|
||||
|
|
@ -307,7 +709,7 @@ def deactivate_stale_listings(
|
|||
else:
|
||||
params = {
|
||||
"listing_source": listing_source,
|
||||
"ttl_days": ttl_days,
|
||||
"ttl_days": effective_ttl_days,
|
||||
"run_id": run_id,
|
||||
}
|
||||
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
||||
|
|
@ -318,12 +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, 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
|
||||
|
|
|
|||
|
|
@ -109,10 +109,17 @@ class NewbuildingEnrichBackfillResult:
|
|||
failed_fetch: int = 0 # fetch returned None / raised
|
||||
failed_save: int = 0 # save raised after a good fetch
|
||||
|
||||
# Row-level deltas (how much actually landed).
|
||||
price_dynamics_rows: int = 0
|
||||
reliability_rows: int = 0
|
||||
review_rows: int = 0
|
||||
# Сколько РЕАЛЬНО записано, по словам самих писателей (#2807). Раньше здесь стоял
|
||||
# прирост COUNT(*) по таблице до/после сохранения — то есть «выросла ли таблица», а
|
||||
# не «сколько записали»: при ON CONFLICT DO UPDATE обновление даёт ноль, а у
|
||||
# reliability ноль давал ещё и _dedup_reliability, схлопывающий дубль сразу после
|
||||
# вставки. Ключи переименованы намеренно: у price_dynamics_rows/reliability_rows/
|
||||
# review_rows в истории прогонов старый смысл, и молча поменять его под тем же
|
||||
# именем — ровно тот дефект, ради которого правка и делается.
|
||||
price_dynamics_inserted: int = 0 # новых точек динамики цен
|
||||
price_dynamics_updated: int = 0 # существующих точек переписано свежей ценой
|
||||
reliability_inserted: int = 0 # строк house_reliability_checks вставлено
|
||||
review_upserted: int = 0 # отзывов записано (вставка+обновление, ключ ext_review_id)
|
||||
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -399,9 +406,18 @@ async def backfill_newbuilding_enrichment(
|
|||
save_newbuilding_enrichment,
|
||||
)
|
||||
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||
|
||||
scraper_config = RealScraperConfig()
|
||||
# #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл
|
||||
# через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток
|
||||
# уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
|
||||
# «включить пул»: реально пул задействуется, только если включён
|
||||
# config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
|
||||
# #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг
|
||||
# use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на
|
||||
# статичном cian_proxy_url, то есть на второй ноге той же цепочки.
|
||||
proxy_provider = RealProxyProvider()
|
||||
|
||||
result = NewbuildingEnrichBackfillResult()
|
||||
t0 = time.time()
|
||||
|
|
@ -478,7 +494,9 @@ async def backfill_newbuilding_enrichment(
|
|||
continue
|
||||
|
||||
try:
|
||||
resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config)
|
||||
resolved = await resolve_cian_zhk_url_via_search(
|
||||
nb_id, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except Exception as exc: # defensive — resolver already catches internally
|
||||
logger.warning(
|
||||
"zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc
|
||||
|
|
@ -525,7 +543,9 @@ async def backfill_newbuilding_enrichment(
|
|||
# ── Fetch (network; anti-bot surface) ──────────────────────────────
|
||||
enrichment = None
|
||||
try:
|
||||
enrichment = await fetch_newbuilding(zhk_url, config=scraper_config)
|
||||
enrichment = await fetch_newbuilding(
|
||||
zhk_url, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc
|
||||
|
|
@ -535,8 +555,12 @@ async def backfill_newbuilding_enrichment(
|
|||
continue
|
||||
|
||||
if enrichment is None:
|
||||
# Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога
|
||||
# читается дальше как факт и один раз уже увела диагноз не туда. Причина
|
||||
# печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers).
|
||||
logger.warning(
|
||||
"newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)",
|
||||
"newbuilding fetch returned None house_id=%s url=%s — причина в строке "
|
||||
"'initialState extraction failed' выше",
|
||||
house_id,
|
||||
zhk_url,
|
||||
)
|
||||
|
|
@ -545,15 +569,15 @@ async def backfill_newbuilding_enrichment(
|
|||
continue
|
||||
|
||||
# ── Save under a SAVEPOINT so one bad house can't poison the batch ──
|
||||
# begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally,
|
||||
# so we snapshot the row counts BEFORE and recompute the delta AFTER its commit
|
||||
# rather than relying on the nested transaction staying open.
|
||||
pd_before, rc_before, rv_before = _house_enrichment_counts(db, house_id)
|
||||
# begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally.
|
||||
# COUNT(*) до сохранения нужен ТОЛЬКО для had_reliability (дедуп ниже): сколько
|
||||
# записано, теперь сообщают сами писатели, а не разница COUNT'ов (#2807).
|
||||
_, rc_before, _ = _house_enrichment_counts(db, house_id)
|
||||
try:
|
||||
had_reliability = rc_before > 0
|
||||
|
||||
# 1) price_dynamics + reliability + houses UPDATE (existing, commits inside).
|
||||
save_newbuilding_enrichment(db, house_id, enrichment)
|
||||
saved = save_newbuilding_enrichment(db, house_id, enrichment)
|
||||
|
||||
# 2) reviews — added here (save_newbuilding_enrichment skips them).
|
||||
# SAVEPOINT around the review write so a malformed review can't lose the
|
||||
|
|
@ -589,16 +613,18 @@ async def backfill_newbuilding_enrichment(
|
|||
sp.rollback()
|
||||
logger.warning("reliability dedup failed house_id=%s: %s", house_id, dexc)
|
||||
|
||||
pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id)
|
||||
result.price_dynamics_rows += max(0, pd_after - pd_before)
|
||||
result.reliability_rows += max(0, rc_after - rc_before)
|
||||
result.review_rows += max(0, rv_after - rv_before)
|
||||
result.price_dynamics_inserted += saved.price_inserted
|
||||
result.price_dynamics_updated += saved.price_updated
|
||||
result.reliability_inserted += saved.reliability_inserted
|
||||
result.review_upserted += review_written
|
||||
result.succeeded += 1
|
||||
logger.info(
|
||||
"enriched house_id=%s: +pd=%d +reliability=%d +reviews=%d (parsed reviews=%d)",
|
||||
"enriched house_id=%s: динамика цен +%d новых / %d обновлено, "
|
||||
"reliability +%d, отзывов записано %d (распознано %d)",
|
||||
house_id,
|
||||
max(0, pd_after - pd_before),
|
||||
max(0, rc_after - rc_before),
|
||||
saved.price_inserted,
|
||||
saved.price_updated,
|
||||
saved.reliability_inserted,
|
||||
review_written,
|
||||
len(enrichment.reviews),
|
||||
)
|
||||
|
|
@ -617,8 +643,8 @@ async def backfill_newbuilding_enrichment(
|
|||
result.duration_sec = time.time() - t0
|
||||
logger.info(
|
||||
"newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d "
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
|
||||
"| %.1fs",
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / "
|
||||
"%d обновлено, reliability +%d, отзывов %d | %.1fs",
|
||||
result.processed,
|
||||
result.succeeded,
|
||||
result.skipped_already_enriched,
|
||||
|
|
@ -626,9 +652,10 @@ async def backfill_newbuilding_enrichment(
|
|||
result.failed_resolve,
|
||||
result.failed_fetch,
|
||||
result.failed_save,
|
||||
result.price_dynamics_rows,
|
||||
result.reliability_rows,
|
||||
result.review_rows,
|
||||
result.price_dynamics_inserted,
|
||||
result.price_dynamics_updated,
|
||||
result.reliability_inserted,
|
||||
result.review_upserted,
|
||||
result.duration_sec,
|
||||
)
|
||||
return result
|
||||
|
|
@ -775,8 +802,8 @@ async def run_newbuilding_enrich(
|
|||
)
|
||||
logger.info(
|
||||
"scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d "
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
|
||||
"| pending=%d %.1fs",
|
||||
"resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / "
|
||||
"%d обновлено, reliability +%d, отзывов %d | pending=%d %.1fs",
|
||||
run_id,
|
||||
result.processed,
|
||||
result.succeeded,
|
||||
|
|
@ -784,9 +811,10 @@ async def run_newbuilding_enrich(
|
|||
result.failed_resolve,
|
||||
result.failed_fetch,
|
||||
result.failed_save,
|
||||
result.price_dynamics_rows,
|
||||
result.reliability_rows,
|
||||
result.review_rows,
|
||||
result.price_dynamics_inserted,
|
||||
result.price_dynamics_updated,
|
||||
result.reliability_inserted,
|
||||
result.review_upserted,
|
||||
result.cian_houses_pending,
|
||||
result.duration_sec,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,21 @@ BATCHING (не единый DELETE по всей таблице):
|
|||
don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier
|
||||
committed batches deleted (correct, not rolled back) and mark_failed records the
|
||||
partial counters reached so far.
|
||||
|
||||
Payments retention (PR #2754): the `created_by IS NULL` population above is EXACTLY
|
||||
the future paying-customer population -- the owner sells this report to
|
||||
individuals for money, and a paid row must outlive the 24h `expires_at` link TTL
|
||||
(a separate column, `retain_until`, set by the -- separate, not-yet-existing --
|
||||
payment fulfillment code to now() + settings.trade_in_paid_retention_days, NOT
|
||||
a change to `expires_at` itself). Two independent safeguards were added to
|
||||
`_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS payments)
|
||||
plus a pre-flight count in `purge_expired_trade_in_data` that refuses to run at
|
||||
all if it finds an ANOMALOUS paid candidate -- see the SQL constants and
|
||||
`_preflight_paid_candidates` below for the mechanics (deep-review finding
|
||||
2026-08-06 MEDIUM on PR #2754: the pre-flight predicate itself must ALSO carry
|
||||
`retain_until IS NULL`, otherwise a perfectly healthy paid row trips it and
|
||||
wedges the job permanently -- see that function's docstring). No payment code
|
||||
lives in this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -74,6 +89,26 @@ logger = logging.getLogger(__name__)
|
|||
# remainder simply drains on the next nightly run (idempotent, no data loss risk).
|
||||
_DEFAULT_MAX_BATCHES = 20
|
||||
|
||||
#
|
||||
# Payments retention (2026-08-06, PR #2754): два независимых предохранителя
|
||||
# добавлены к тому же предикату ПЕРЕД тем, как платёжный код появился в
|
||||
# проекте (мина уже была заряжена: без них джоба удаляла бы будущих платящих
|
||||
# клиентов). Отдельная колонка retain_until (не подъём expires_at) — потому
|
||||
# что expires_at глобальный TTL расчёта на ВСЕ строки (включая неоплаченные)
|
||||
# и печатается в PDF/UI как «актуальность расчёта»; поднять его до года
|
||||
# означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в
|
||||
# документе клиента про срок актуальности цифры:
|
||||
# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная
|
||||
# строка (retain_until IS NOT NULL, migration 240) не удаляется джобой
|
||||
# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем
|
||||
# через год после первой продажи. `retain_until` ставится сервисным
|
||||
# кодом платёжного контура (ещё не существует в этом PR) на now() +
|
||||
# settings.trade_in_paid_retention_days.
|
||||
# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача
|
||||
# забыла проставить retain_until (баг/гонка/ручной INSERT): строка,
|
||||
# которой коснулись деньги, переживёт джобу даже без корректного (1).
|
||||
# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм).
|
||||
# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча.
|
||||
_DELETE_EXPIRED_ESTIMATES_SQL = text(
|
||||
"""
|
||||
DELETE FROM trade_in_estimates
|
||||
|
|
@ -81,12 +116,39 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text(
|
|||
SELECT id FROM trade_in_estimates
|
||||
WHERE expires_at < NOW()
|
||||
AND created_by IS NULL
|
||||
AND retain_until IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id
|
||||
)
|
||||
ORDER BY expires_at
|
||||
LIMIT CAST(:batch_size AS int)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Pre-flight (см. _preflight_paid_candidates). deep-review finding 2026-08-06
|
||||
# MEDIUM (PR #2754): первая редакция считала по БАЗОВОМУ предикату БЕЗ
|
||||
# retain_until вообще -- а это ловит и штатно-здоровые оплаченные строки
|
||||
# (retain_until проставлен, есть payments) точно так же, как настоящую
|
||||
# аномалию (retain_until НЕ проставлен, но payments есть) -- джоба вставала
|
||||
# на первой же честной продаже и больше никогда не запускалась (вместе с ней
|
||||
# вставало и удаление лидов, вызываемое из той же функции ПОСЛЕ этой
|
||||
# проверки -- 180-дневный purge по 152-ФЗ тоже переставал бы работать).
|
||||
# Правильная форма: базовый предикат AND "новый предохранитель НЕ сработал
|
||||
# бы" (retain_until IS NULL) AND "признак аномалии" (payments всё же есть).
|
||||
# Здоровая оплаченная строка (retain_until IS NOT NULL) исключается ЭТИМ
|
||||
# термом -- она и так под DELETE не попадает (см. safeguard 1 выше), тревогу
|
||||
# поднимать не должна.
|
||||
_PREFLIGHT_PAID_CANDIDATES_SQL = text(
|
||||
"""
|
||||
SELECT count(*) FROM trade_in_estimates e
|
||||
WHERE e.expires_at < NOW()
|
||||
AND e.created_by IS NULL
|
||||
AND e.retain_until IS NULL
|
||||
AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)
|
||||
"""
|
||||
)
|
||||
|
||||
_DELETE_EXPIRED_LEADS_SQL = text(
|
||||
"""
|
||||
DELETE FROM trade_in_leads
|
||||
|
|
@ -135,6 +197,25 @@ def _drain_expired(
|
|||
break # caught up -- fewer expired rows left than one batch
|
||||
|
||||
|
||||
def _preflight_paid_candidates(db: Session) -> int:
|
||||
"""Safety gate: count ANOMALOUS purge-candidates -- base predicate, retain_until
|
||||
IS NULL (safeguard 1 did NOT protect the row), AND a payments row exists anyway.
|
||||
|
||||
Runs BEFORE any DELETE batch. A non-zero result means fulfillment failed to set
|
||||
`retain_until` on a row money actually touched (bug/race/manual INSERT) -- this
|
||||
run must not delete anything; see `purge_expired_trade_in_data` below, which
|
||||
aborts before the first batch when this returns non-zero.
|
||||
|
||||
MUST include `retain_until IS NULL` (deep-review finding 2026-08-06 MEDIUM, PR
|
||||
#2754): a healthy paid row (retain_until set, has a payments row) is the EXPECTED
|
||||
steady state one day after every sale -- without this term it counts as a "paid
|
||||
candidate" too, so the very first successful sale permanently wedges this job
|
||||
(mark_failed, zero deletions, forever -- and since leads purge runs from the same
|
||||
function AFTER this check, the unrelated 180-day lead retention would also stop).
|
||||
"""
|
||||
return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one()
|
||||
|
||||
|
||||
def purge_expired_trade_in_data(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
|
|
@ -148,10 +229,32 @@ def purge_expired_trade_in_data(
|
|||
deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed).
|
||||
|
||||
Returns {"estimates_deleted": N, "leads_deleted": M}.
|
||||
|
||||
Payments retention pre-flight (see `_preflight_paid_candidates`): if any
|
||||
purge-candidate estimate has `retain_until IS NULL` AND a `payments` row (the
|
||||
ANOMALY -- fulfillment failed to set retain_until on a row money touched), the
|
||||
run aborts BEFORE the first DELETE batch (estimates OR leads) -- zero rows
|
||||
deleted, `mark_failed` records why. Healthy paid rows (retain_until set) do NOT
|
||||
trip this -- they never matched the check to begin with. This is deliberately
|
||||
checked outside the `try` below so it can never be caught and silently
|
||||
re-reported as a generic mid-run failure -- it is a distinct, actionable
|
||||
pre-condition failure.
|
||||
"""
|
||||
batch_size = batch_size or settings.trade_in_purge_batch_size
|
||||
max_batches = max_batches or _DEFAULT_MAX_BATCHES
|
||||
counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0}
|
||||
|
||||
paid_candidates = _preflight_paid_candidates(db)
|
||||
if paid_candidates:
|
||||
error = (
|
||||
f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates "
|
||||
"row(s) have retain_until IS NULL but a matching payments row -- "
|
||||
"refusing to run, zero rows deleted"
|
||||
)
|
||||
logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error)
|
||||
runs_mod.mark_failed(db, run_id, error, counters)
|
||||
raise RuntimeError(error)
|
||||
|
||||
try:
|
||||
_drain_expired(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -45,12 +51,14 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.proxy_egress import resolve_proxy_url
|
||||
|
||||
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,
|
||||
|
|
@ -203,8 +284,15 @@ async def run_yandex_detail_backfill(
|
|||
max_consecutive_blocks,
|
||||
)
|
||||
|
||||
# Build proxies dict once — mirrors yandex_address_backfill.py
|
||||
_proxy = settings.scraper_proxy_url
|
||||
# Build proxies dict once — mirrors yandex_address_backfill.py.
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если
|
||||
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
|
||||
# нездоровы для yandex -- resolve_proxy_url бросает ProxyPoolExhaustedError
|
||||
# (fail-closed, #2616): НАРОЧНО не ловим здесь -- штатный except Exception ниже
|
||||
# (mark_failed + logger.exception + raise) уже даёт явную деградацию run'а с
|
||||
# понятным логом, отдельный catch не нужен.
|
||||
_proxy = resolve_proxy_url(db, "yandex")
|
||||
_proxies = {"http": _proxy, "https": _proxy} if _proxy else None
|
||||
|
||||
consecutive_none = 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
-- 240_trade_in_estimates_retain_until.sql
|
||||
-- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge
|
||||
-- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен
|
||||
-- жить год на нашей стороне, а не 24ч (см. WHY ниже).
|
||||
-- Номер сверен по `forgejo/main` и всем открытым PR-веткам ДВАЖДЫ: сначала
|
||||
-- как 234 (последняя занятая на момент ветвления была 233_payments.sql), но
|
||||
-- main уехал вперёд и 234 занял `234_scrape_runs_ban_kind_unknown.sql`
|
||||
-- (коммит 0de22f4b) — переименовано в 240 (main max на момент повторной
|
||||
-- сверки — 239, с дырами 235-237; max+1 безопаснее дыр). Урок пятый за
|
||||
-- сутки: сверять номер нужно не только перед первым коммитом, а прямо перед
|
||||
-- пушем/мержем — main не стоит на месте.
|
||||
--
|
||||
-- ── WHY ──────────────────────────────────────────────────────────────────────
|
||||
-- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет
|
||||
-- строки `WHERE expires_at < NOW() AND created_by IS NULL` — это ровно
|
||||
-- популяция будущих платящих физлиц (анонимные B2C-оценки). Владелец продаёт
|
||||
-- отчёт физлицу за 150 ₽: скачанный файл у клиента бессрочно, но ссылка/строка
|
||||
-- на нашей стороне обязана жить дольше 24-часового TTL расчёта — иначе первый
|
||||
-- же прогон purge-джобы после запуска продаж физически и безвозвратно удалит
|
||||
-- уже оплаченное (PDF нигде не хранится, рендерится на лету).
|
||||
--
|
||||
-- `expires_at` НЕ трогаем ни на йоту: это единая глобальная настройка
|
||||
-- (`trade_in_estimate_retention_hours`), она же — печатаемая в PDF/UI дата
|
||||
-- «ДЕЙСТВИТЕЛЕН ДО» (актуальность РАСЧЁТА, а не срок жизни строки), и от неё
|
||||
-- зависит вычисление даты расчёта во фронте (`mappers.ts` fmtDateShift(-24)).
|
||||
-- Поднять её до года означало бы: (а) дать год хранения ВСЕМ строкам, включая
|
||||
-- неоплаченные адреса физлиц — прямое нарушение минимизации по 152-ФЗ;
|
||||
-- (б) напечатать в PDF клиента, что расчёт актуален год.
|
||||
--
|
||||
-- ── WHAT ─────────────────────────────────────────────────────────────────────
|
||||
-- Новая, независимая колонка retain_until — срок жизни ДОСТУПА/СТРОКИ:
|
||||
-- NULL = неоплаченная строка, поведение (чтение/PDF/purge) бит-в-бит текущее.
|
||||
-- Бэкфилла нет — все 1058 существующих строк остаются NULL, ничего не меняется
|
||||
-- для уже созданных оценок (весь B2B pilot-трафик в их числе).
|
||||
-- При оплате (платёжный код — отдельный PR, здесь его нет) сервисный слой
|
||||
-- проставит retain_until = now() + trade_in_paid_retention_days (config.py).
|
||||
--
|
||||
-- Частичный индекс покрывает predicate purge-джобы (migration 231,
|
||||
-- `_DELETE_EXPIRED_ESTIMATES_SQL`) уже С УЧЁТОМ нового терма retain_until —
|
||||
-- заведён вместе с колонкой, а не отдельной миграцией, чтобы purge не начал
|
||||
-- жить без него хотя бы один деплой.
|
||||
--
|
||||
-- ── IDEMPOTENCY ──────────────────────────────────────────────────────────────
|
||||
-- ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS — безопасный re-run.
|
||||
-- Ничего не удаляет, не бэкфиллит, DDL-only (доли секунды на 1058 строках).
|
||||
--
|
||||
-- Dependencies: 001_trade_in_estimates.sql, 233_payments.sql (индекс исключает
|
||||
-- строки со строкой в payments опосредованно через predicate purge-джобы,
|
||||
-- сама таблица payments здесь не читается).
|
||||
-- Apply after: 233_payments.sql.
|
||||
--
|
||||
-- ── lock_timeout — выставлен, хотя гейт (scripts/check-migration-lock-timeout.py)
|
||||
-- этот файл не проверяет ────────────────────────────────────────────────────
|
||||
-- Порог гейта для tradein (NN >= 250) — артефакт: назначен по номеру аварийной
|
||||
-- миграции 250, которую затем сняли с деплоя (#2792, 29f10002). Фактический
|
||||
-- максимум применённого на main — 239, то есть НИ ОДНА миграция в диапазоне
|
||||
-- 240-249 (этот файл включительно) гейтом не проверяется вообще — "проверено
|
||||
-- новых миграций: 0" в логе означает "не проверено ни одного файла", а не
|
||||
-- "все чисты". Сама функция scan() внутри гейта, если прогнать её без
|
||||
-- порогового отсечения, помечает ALTER TABLE ниже как блокирующий DDL без
|
||||
-- lock_timeout. `trade_in_estimates` — самая горячая таблица стека (история,
|
||||
-- история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались
|
||||
-- открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка
|
||||
-- встаёт в очередь ПЕРЕД новыми запросами — за ней начинают ждать обычные
|
||||
-- SELECT приложения (см. `sql.md` § lock_timeout). На 1058 строках сам DDL
|
||||
-- мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Красный
|
||||
-- деплой по таймауту — осознанно принятый в проекте размен (честный отказ
|
||||
-- лучше тихой очереди перед приложением). НЕ убирать как "гейт же не просит".
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS retain_until timestamptz;
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.retain_until IS
|
||||
'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться '
|
||||
'(оплаченный доступ). Семантика expires_at не меняется: это дата '
|
||||
'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, '
|
||||
'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура '
|
||||
'(отдельный PR) на now() + trade_in_paid_retention_days (config.py).';
|
||||
|
||||
-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py):
|
||||
-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW().
|
||||
CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx
|
||||
ON trade_in_estimates (expires_at)
|
||||
WHERE created_by IS NULL AND retain_until IS NULL;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
-- 250_drop_duplicate_expires_at_index.sql
|
||||
-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at).
|
||||
-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791.
|
||||
--
|
||||
-- WHY:
|
||||
-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09)
|
||||
-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль
|
||||
-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql.
|
||||
--
|
||||
-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени):
|
||||
-- name indkey indclass indoption indcollation pred am
|
||||
-- trade_in_estimates_expires_idx 22 3127 0 0 — btree
|
||||
-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree
|
||||
-- Совпадает всё: колонка, класс операторов, направление сортировки,
|
||||
-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация,
|
||||
-- отсутствие частичного предиката, метод доступа. Ни один не привязан к
|
||||
-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них
|
||||
-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует
|
||||
-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты
|
||||
-- FDW-пользователю). Гранты живут на таблице, не на индексе.
|
||||
--
|
||||
-- ── Почему у «нулевого» дубля появились сканы ────────────────────────────────
|
||||
-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у
|
||||
-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый
|
||||
-- +0, новый +4). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через
|
||||
-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на
|
||||
-- новый весь живой трафик, и это устойчивое состояние, а не переходное.
|
||||
--
|
||||
-- Причина не семантическая, а физическая: индексы идентичны, но новый
|
||||
-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого,
|
||||
-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск
|
||||
-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost,
|
||||
-- и при прочих равных выигрывает. Никакого нового запроса не появилось:
|
||||
-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого,
|
||||
-- 0.95 у нового) — это один и тот же класс точечных lookup'ов, просто
|
||||
-- переехавший на более свежий индекс. Со временем новый забронзовеет так же
|
||||
-- и они поменялись бы местами обратно.
|
||||
--
|
||||
-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ────────────────────────────
|
||||
-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу
|
||||
-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE
|
||||
-- делал бы full scan». На проде это НЕ так. Фактический план боевого
|
||||
-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод
|
||||
-- 2026-08-07, перепроверено 2026-08-09 — план тот же):
|
||||
-- Limit → Sort (Sort Key: expires_at)
|
||||
-- → Bitmap Heap Scan Filter: (expires_at < now())
|
||||
-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at
|
||||
-- Index Cond: (created_by IS NULL)
|
||||
-- Задача purge ограничена `AND created_by IS NULL` (134 строки из 1061), и
|
||||
-- планировщик берёт именно этот, более селективный индекс, а expires_at
|
||||
-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не
|
||||
-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён
|
||||
-- под конкретный запрос» не существует — запрос его не использует.
|
||||
-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму
|
||||
-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с
|
||||
-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД.
|
||||
--
|
||||
-- ── Планы ДО и ПОСЛЕ ─────────────────────────────────────────────────────────
|
||||
-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе:
|
||||
-- меняется только имя индекса в строке плана и cost на одну страницу спуска.
|
||||
-- ДО (прод, 2026-08-09 16:54 UTC):
|
||||
-- Limit (cost=0.28..58.98 rows=100 width=24)
|
||||
-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07)
|
||||
-- Index Cond: (expires_at < now())
|
||||
-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и
|
||||
-- cost, отличающимся на спуск по одной лишней странице. Проверено на чистом
|
||||
-- PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым
|
||||
-- перекосом плотности:
|
||||
-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84)
|
||||
-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30)
|
||||
-- Форма плана, Index Cond и Filter идентичны; отличается только имя.
|
||||
--
|
||||
-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ──────────────
|
||||
-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь
|
||||
-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс
|
||||
-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс
|
||||
-- unlink файла, единицы миллисекунд).
|
||||
--
|
||||
-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая
|
||||
-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за
|
||||
-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`,
|
||||
-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные,
|
||||
-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт
|
||||
-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные
|
||||
-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили
|
||||
-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md).
|
||||
--
|
||||
-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено
|
||||
-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после
|
||||
-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным
|
||||
-- autovacuum. Сверху — потолок простоя очереди приложения; против
|
||||
-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не
|
||||
-- влияет вообще.
|
||||
--
|
||||
-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling
|
||||
-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое
|
||||
-- поведение, а не авария: миграция не помечается применённой, повторить
|
||||
-- позже. CONCURRENTLY здесь не нужен и был бы хуже: он не может выполняться
|
||||
-- внутри блока транзакции, а значит файл пришлось бы оставить без
|
||||
-- BEGIN/COMMIT (см. разбор механики раннера в
|
||||
-- 225_listing_source_snapshots_run_id_idx.sql).
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE.
|
||||
-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет.
|
||||
-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы
|
||||
-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует).
|
||||
--
|
||||
-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс),
|
||||
-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль).
|
||||
-- Deploy order: standalone. Ничего не ждёт и никого не блокирует.
|
||||
--
|
||||
-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC):
|
||||
-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation
|
||||
-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0
|
||||
--
|
||||
-- Критерий приёмки (записан ДО применения):
|
||||
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
|
||||
-- 2. EXPLAIN того же запроса показывает Index Scan using
|
||||
-- trade_in_estimates_expires_idx — детерминированная проверка, доступна
|
||||
-- сразу.
|
||||
-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с
|
||||
-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у
|
||||
-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок
|
||||
-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит
|
||||
-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы
|
||||
-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
|
||||
-- и в .claude/rules/sql.md § lock_timeout.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx;
|
||||
|
||||
COMMENT ON INDEX trade_in_estimates_expires_idx IS
|
||||
'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
-- 251_listings_drop_ceiling_height.sql
|
||||
-- Issue #2699 (хвост) — снос DEPRECATED-колонки listings.ceiling_height.
|
||||
--
|
||||
-- Dependencies: 019_listings_alter_cian.sql (завела колонку, numeric(3,2)),
|
||||
-- 238_listings_ceiling_height_unify.sql (перенесла значения в
|
||||
-- канон ceiling_height_m и пометила эту колонку DEPRECATED).
|
||||
-- Apply after: 240_trade_in_estimates_retain_until.sql
|
||||
-- Deploy order: код УЖЕ впереди схемы — писатели сняты PR #2779 (07.08) и с тех
|
||||
-- пор на проде. Это тот случай, когда «код первый» правилен: DROP COLUMN
|
||||
-- безопасен только после того, как ни один живой writer/reader колонки не
|
||||
-- остался. Обратный порядок (снести колонку, потом деплоить код) уронил бы
|
||||
-- скрейпинг.
|
||||
--
|
||||
-- ── ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ ФАЙЛ, А НЕ ЧАСТЬ 238 ───────────────────────────────
|
||||
-- 238 намеренно оставила колонку: «сначала прод должен подтвердить, что колонку
|
||||
-- никто не пишет и не читает. Снос — отдельным шагом». Подтверждение получено,
|
||||
-- ниже — числа.
|
||||
--
|
||||
-- ── КРИТЕРИЙ «ПИСАТЕЛЕЙ НЕТ» (записан ДО, а не после) ───────────────────────
|
||||
-- count(ceiling_height) обязан остаться 8552 (8554 из #2699 минус 2 мусорных
|
||||
-- значения cian, обнулённых шагом 1 миграции 238).
|
||||
-- 2026-08-07 (сразу после 238): 8552
|
||||
-- 2026-08-09 16:54 UTC: 8552
|
||||
-- 2026-08-09 16:57 UTC: 8552
|
||||
-- Двое суток без единой записи. Контроль того, что замер не «мёртвый» (БД жива,
|
||||
-- скрейпинг идёт, просто пишет в канон): за те же 2.5 минуты между двумя
|
||||
-- замерами count(ceiling_height_m) вырос 16133 → 16147, а last_seen_at строк с
|
||||
-- непустым ceiling_height обновлялся в ту же минуту, что и замер. То есть UPDATE
|
||||
-- по этим строкам идут прямо сейчас и НЕ трогают сносимую колонку — это сильнее,
|
||||
-- чем «два дня тишины».
|
||||
--
|
||||
-- ── ПОТРЕБИТЕЛИ: сверка на origin/main перед сносом ─────────────────────────
|
||||
-- `git grep -n 'ceiling_height\b' origin/main -- '*.py' '*.ts' '*.tsx' '*.sql'`
|
||||
-- минус вхождения ceiling_height_m: ни одного обращения к КОЛОНКЕ не осталось.
|
||||
-- Что попало в выдачу и почему это не потребители:
|
||||
-- - имена полей Python-датаклассов enrichment'ов (CianEnrichment.ceiling_height,
|
||||
-- YandexEnrichment.ceiling_height, YandexValuation...) — атрибуты объектов,
|
||||
-- не колонки;
|
||||
-- - `CAST(:ceiling_height AS numeric)` в yandex/detail.py:600 — ИМЯ БИНД-
|
||||
-- ПАРАМЕТРА, а присваивается он колонке ceiling_height_m (соседняя строка);
|
||||
-- то же в cian/detail.py (`:ch`) и base.py (`:ceiling_height_m`);
|
||||
-- - комментарии/докстринги с историей #2699 и тесты, которые как раз
|
||||
-- УТВЕРЖДАЮТ отсутствие колонки в SQL писателей
|
||||
-- (tests/test_ceiling_height_unify_2699.py, test_scraper_admin_apis.py);
|
||||
-- - 019/238 — сами миграции, их переписывать нельзя и не нужно.
|
||||
-- Фронтовых (.ts/.tsx) вхождений нет вообще.
|
||||
--
|
||||
-- ── ЗАВИСИМОСТИ В СХЕМЕ: проверено на проде 2026-08-09, все нули ────────────
|
||||
-- pg_depend по атрибуту listings.ceiling_height ................ 0 объектов
|
||||
-- вьюхи/матвьюхи с 'ceiling' в определении ..................... 0
|
||||
-- индексы listings с 'ceiling' в indexdef ...................... 0
|
||||
-- CHECK/constraint с 'ceiling' ................................. 0
|
||||
-- функции и процедуры с 'ceiling_height' в теле ................ 0
|
||||
-- тела обоих триггеров listings (price_change, set_geom) ....... не упоминают
|
||||
-- pg_publication_rel по listings (column list ломает DROP) ..... 0 (публикаций в БД нет)
|
||||
-- foreign tables НА listings в gendesign-БД (FDW-читатель) ..... 0
|
||||
-- То есть CASCADE не нужен — и не должен появиться: в этом продукте
|
||||
-- `DROP ... CASCADE` уже терял гранты FDW-пользователю (инцидент C3).
|
||||
--
|
||||
-- ── ПОТЕРИ ДАННЫХ НЕТ (проверено, а не предположено) ────────────────────────
|
||||
-- строк, где ceiling_height IS NOT NULL AND ceiling_height_m IS NULL ..... 0
|
||||
-- строк, где заполнены обе ............................................ 8552
|
||||
-- из них расходятся значения .............................................. 0
|
||||
-- Всё содержимое сносимой колонки присутствует в каноне до последнего знака.
|
||||
-- ceiling_height_m на момент написания: 16 147 непустых (после 238 было 15 591 —
|
||||
-- канон растёт, то есть живой).
|
||||
--
|
||||
-- ── СТОИМОСТЬ БЛОКИРОВКИ И ПОЧЕМУ SET LOCAL lock_timeout ───────────────────
|
||||
-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на listings. УДЕРЖАНИЕ
|
||||
-- дёшево и не зависит от размера таблицы: PostgreSQL не переписывает heap, а
|
||||
-- помечает атрибут attisdropped в каталоге (в listings уже 4 таких «пенька» от
|
||||
-- прошлых сносов при 92 живых колонках) — единицы миллисекунд.
|
||||
--
|
||||
-- Дорого ОЖИДАНИЕ выдачи лока, и цена здесь выше, чем у 250: listings — 19 GB,
|
||||
-- 97 540 строк, по ней постоянно идёт скрейпинг (в т.ч. длинные проходы вроде
|
||||
-- avito_full_load). Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
|
||||
-- запросами, поэтому за ним начнут ждать обычные SELECT/UPDATE приложения —
|
||||
-- ровно то, что 2026-08-07 положило деплой на 29 минут (#2791, #2792).
|
||||
-- Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено deadlock_timeout =
|
||||
-- 1 s на проде, сверху — потолок простоя очереди приложения; на работу ПОД
|
||||
-- локом не влияет). Срабатывание = честный красный деплой через 5 секунд,
|
||||
-- миграция не помечается применённой, повторить в окно потише.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- - DROP COLUMN IF EXISTS — безопасный re-run.
|
||||
-- - Без CASCADE: зависимых объектов нет (см. выше), а CASCADE молча снёс бы
|
||||
-- то, что появится позже.
|
||||
-- - Одна DDL-операция внутри BEGIN/COMMIT.
|
||||
-- - Откат: колонку вернуть можно (ALTER TABLE ... ADD COLUMN), но данные в неё
|
||||
-- не восстановятся — они и не нужны, дубликат канона (0 расхождений).
|
||||
--
|
||||
-- Критерий приёмки (записан ДО применения):
|
||||
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
|
||||
-- 2. information_schema.columns по listings: ceiling_height отсутствует,
|
||||
-- ceiling_height_m на месте и count(ceiling_height_m) >= 16 147.
|
||||
-- 3. Скрейпинг продолжает писать: count(ceiling_height_m) растёт после сноса.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
|
||||
-- и в .claude/rules/sql.md § lock_timeout.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE listings DROP COLUMN IF EXISTS ceiling_height;
|
||||
|
||||
COMMENT ON COLUMN listings.ceiling_height_m IS
|
||||
'Высота потолков, метры. ЕДИНСТВЕННАЯ колонка этого признака (#2699): дубль listings.ceiling_height (019) снесён миграцией 251 после того, как 238 перенесла в неё значения. Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m (гейт правдоподобия 2.0-6.0 м). Читает estimator (comp-scoring #2012).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
-- 253_scrape_proxy_domclick_affinity_release.sql
|
||||
-- Снять с узла резервацию provider_affinity='domclick' (#2800).
|
||||
--
|
||||
-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox,
|
||||
-- POST /fetch на robots.txt рабочего хоста каждой площадки):
|
||||
--
|
||||
-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru
|
||||
-- -----+----------+-------+--------------------+------------------+---------------------------
|
||||
-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
|
||||
-- 9 | any | 200 | 200 | 200 | 200
|
||||
-- 10 | any | 200 | 200 | 200 | 200
|
||||
-- 11 | any | 200 | 200 | 200 | 200
|
||||
--
|
||||
-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста
|
||||
-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web —
|
||||
-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при
|
||||
-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный
|
||||
-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его
|
||||
-- за собой, а два источника, которым он годен, его не видели —
|
||||
-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а
|
||||
-- fallback этот узел не берёт (защита последнего узла выделенной affinity).
|
||||
--
|
||||
-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его
|
||||
-- целиком — терять четверть и без того дефицитного пула (#2638).
|
||||
--
|
||||
-- WHAT:
|
||||
-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая.
|
||||
-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если
|
||||
-- в пуле появится узел, который до Домклика реально доходит.
|
||||
--
|
||||
-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная):
|
||||
-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла
|
||||
-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый
|
||||
-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник»
|
||||
-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply
|
||||
-- strict на деплое это требует. Блокирующего DDL нет (см.
|
||||
-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX),
|
||||
-- UPDATE берёт row-lock на единичные строки.
|
||||
--
|
||||
-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_proxies
|
||||
SET provider_affinity = 'any',
|
||||
updated_at = now()
|
||||
WHERE provider_affinity = 'domclick';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
-- 254_listings_backfill_avito_rating_glued_address.sql
|
||||
-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814).
|
||||
--
|
||||
-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же <p>
|
||||
-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
|
||||
-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но
|
||||
-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет
|
||||
-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при
|
||||
-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от
|
||||
-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный
|
||||
-- путь, которым старые строки могут стать чистыми.
|
||||
--
|
||||
-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным):
|
||||
--
|
||||
-- класс адреса (source='avito', is_active) строк с координатами
|
||||
-- ------------------------------------------ ------ --------------
|
||||
-- чистый 7892 6111 (77.4%)
|
||||
-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%)
|
||||
-- NULL 360 0 (0.0%)
|
||||
--
|
||||
-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался
|
||||
-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE.
|
||||
-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет.
|
||||
-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов
|
||||
-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается).
|
||||
--
|
||||
-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь.
|
||||
-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile(
|
||||
-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I)
|
||||
-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t")
|
||||
-- → `return cleaned or None`.
|
||||
-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace =
|
||||
-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов
|
||||
-- в trim, NULLIF(...,'') = `or None`.
|
||||
-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА.
|
||||
-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост
|
||||
-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми
|
||||
-- (проверено в dry-run: 296 до = 296 после).
|
||||
-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после
|
||||
-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений
|
||||
-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем.
|
||||
--
|
||||
-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с
|
||||
-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере:
|
||||
-- docker exec tradein-scraper python /tmp/m2814-parity.py
|
||||
-- → rows=1123 mismatches=0
|
||||
-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде.
|
||||
--
|
||||
-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10):
|
||||
-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296
|
||||
-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17
|
||||
-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б
|
||||
-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103
|
||||
-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5
|
||||
-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60
|
||||
-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2
|
||||
-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2
|
||||
-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов
|
||||
-- → жилой район Сортировочный, мкр-н Старая
|
||||
-- Сортировка, Кунарская ул.,14к2
|
||||
-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов
|
||||
-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18
|
||||
-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес
|
||||
-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то
|
||||
-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты:
|
||||
-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.)
|
||||
--
|
||||
-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится.
|
||||
-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в
|
||||
-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) —
|
||||
-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк
|
||||
-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного.
|
||||
-- Откат (idempotent, безопасен к повторному запуску):
|
||||
--
|
||||
-- UPDATE listings
|
||||
-- SET address = raw_payload->>'address'
|
||||
-- WHERE source = 'avito'
|
||||
-- AND raw_payload->>'address' ~ '·\s*\d'
|
||||
-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM
|
||||
-- regexp_replace(raw_payload->>'address',
|
||||
-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), '');
|
||||
--
|
||||
-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не
|
||||
-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно
|
||||
-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123.
|
||||
-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный,
|
||||
-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская
|
||||
-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО:
|
||||
-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам
|
||||
-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует
|
||||
-- в порчу.
|
||||
-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные.
|
||||
--
|
||||
-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по
|
||||
-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ
|
||||
-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит
|
||||
-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не
|
||||
-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не
|
||||
-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен:
|
||||
-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до
|
||||
-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк.
|
||||
--
|
||||
-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции
|
||||
-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в
|
||||
-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL
|
||||
-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь
|
||||
-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже
|
||||
-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход
|
||||
-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200,
|
||||
-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC.
|
||||
-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит
|
||||
-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245
|
||||
-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт
|
||||
-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53%
|
||||
-- успеха на адрес, гадать точнее не буду.
|
||||
--
|
||||
-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО:
|
||||
-- * не трогает COALESCE в апсерте — поведение осознанное (#2777);
|
||||
-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем
|
||||
-- свипе (замерено: пустых строк '' среди них 0, все именно NULL);
|
||||
-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть
|
||||
-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city),
|
||||
-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у
|
||||
-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это
|
||||
-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь.
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql
|
||||
-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE
|
||||
-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет.
|
||||
-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает
|
||||
-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815.
|
||||
-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт
|
||||
-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та
|
||||
-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт
|
||||
-- громко (ON_ERROR_STOP=on), чем встанет тихо.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
UPDATE listings
|
||||
SET address = NULLIF(
|
||||
trim(both E' ,.\n\t' FROM
|
||||
regexp_replace(
|
||||
address,
|
||||
'\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$',
|
||||
'',
|
||||
'i'
|
||||
)),
|
||||
''),
|
||||
geocode_tried_at = NULL
|
||||
WHERE source = 'avito'
|
||||
AND address ~ '·\s*\d';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
-- 255_trade_in_estimates_revival_relaxations.sql
|
||||
-- Номер сверен по `ls data/sql | sort` (max applied = 254) непосредственно
|
||||
-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap
|
||||
-- (108_*/084_* уже дублировались в прошлом).
|
||||
--
|
||||
-- ── Incident 2026-08-10: «мёртвые» сохранённые оценки ───────────────────────
|
||||
-- Заказчик открыл сохранённую ссылку /trade-in/v2?id=... и увидел «НЕДОСТАТОЧНО
|
||||
-- ДАННЫХ»: запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR
|
||||
-- #2823/#2825) и лежит в БД с median_price=0/NULL, хотя тот же адрес и
|
||||
-- параметры сейчас честно считаются (4 031 157 ₽ / 39 аналогов). 117 из 1071
|
||||
-- строк trade_in_estimates находятся в этом состоянии (29 за последние 30
|
||||
-- дней). GET /api/v1/trade-in/estimate/{id} (app/api/v1/trade_in.py::
|
||||
-- _try_revive_dead_estimate) теперь пересчитывает такую строку на месте и
|
||||
-- пишет результат В ТУ ЖЕ строку (id/ссылка не меняются) — этому нужны две
|
||||
-- новые колонки:
|
||||
--
|
||||
-- revival_attempted_at — throttle: не пересчитывать чаще одного раза в N
|
||||
-- минут (settings.trade_in_revival_throttle_minutes, default 10) на одну
|
||||
-- строку. Заявка на пересчёт — атомарный conditional
|
||||
-- `UPDATE ... WHERE revival_attempted_at IS NULL OR ... < NOW() - N min
|
||||
-- RETURNING id` (тот же паттерн, что account_quota.increment, #747) —
|
||||
-- защищает и от шторма повторных попыток на мёртвый адрес, и от гонки
|
||||
-- двух параллельных GET (второй просто теряет заявку и отдаёт то, что
|
||||
-- есть, без 500).
|
||||
--
|
||||
-- ── relaxations / reliability (открытый хвост PR #2823, найден post-deploy
|
||||
-- 2026-08-10 — см. fixes/Fix_Mera_Studio_Not_Estimated_Never_Block_Aug10) ──
|
||||
-- Обе колонки УЖЕ вычисляются в estimator.estimate_quality() и уходят в POST-
|
||||
-- ответ (AggregatedEstimate.relaxations/reliability), но раньше НЕ
|
||||
-- персистились — на GET-rehydrate (открытие сохранённой ссылки) красный
|
||||
-- баннер «точность снижена» пропадал, хотя цена по-прежнему построена на
|
||||
-- расширенной/тонкой выборке. Теперь пишутся при каждом (re)compute (основной
|
||||
-- INSERT в estimate_quality() + этот revival-путь) и читаются на GET.
|
||||
--
|
||||
-- ── IDEMPOTENCY ───────────────────────────────────────────────────────────
|
||||
-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: все существующие
|
||||
-- строки получают DEFAULT (reliability='ok', relaxations='[]', revival_
|
||||
-- attempted_at=NULL) — честно отражает то, что для них каскад послаблений
|
||||
-- никогда не считался (записи ДО #oblast-F) и revival ещё не запускался.
|
||||
--
|
||||
-- Dependencies: 001_trade_in_estimates.sql. Apply after: 254_*.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS relaxations jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS reliability text NOT NULL DEFAULT 'ok'
|
||||
CHECK (reliability IN ('ok', 'low', 'very_low'));
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS revival_attempted_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.relaxations IS
|
||||
'RU-подписи применённых послаблений подбора (estimator.py #oblast-F cascade) '
|
||||
'— персистится, чтобы GET-rehydrate (?id=) мог восстановить дисклеймер '
|
||||
'«точность снижена». [] = базовой выборки хватило / запись создана до '
|
||||
'#oblast-F (без бэкфилла).';
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.reliability IS
|
||||
'Надёжность итоговой выборки (ok|low|very_low), производная от n_analogs + '
|
||||
'relaxations (estimator.py::estimate_quality) — персистится для GET-rehydrate '
|
||||
'красного баннера. Default ok = запись создана до #oblast-F (без бэкфилла).';
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.revival_attempted_at IS
|
||||
'Момент последней попытки пересчитать «мёртвую» (median_price<=0/NULL) '
|
||||
'строку на GET /estimate/{id} (incident 2026-08-10, app/api/v1/trade_in.py::'
|
||||
'_try_revive_dead_estimate). Throttle: не пересчитывать чаще одного раза в '
|
||||
'settings.trade_in_revival_throttle_minutes на одну строку — атомарный '
|
||||
'conditional UPDATE...RETURNING (см. модульный докстринг). NULL = либо '
|
||||
'строка живая (median_price>0) и revival никогда не запускался, либо '
|
||||
'запись создана до этой фичи.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
-- 256_trade_in_estimates_revival_completed_at.sql
|
||||
-- Номер сверен по `ls data/sql | sort` (max applied = 255) непосредственно
|
||||
-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap
|
||||
-- (108_*/084_* уже дублировались в прошлом).
|
||||
--
|
||||
-- ── fix/tradein-created (2026-08-11): created_at перезаписывался revival'ом ──
|
||||
-- `_try_revive_dead_estimate` (app/api/v1/trade_in.py, migration 255) писал
|
||||
-- пересчитанные поля в ИСХОДНУЮ строку trade_in_estimates, и вместе с ними —
|
||||
-- `created_at`, скопированный из временной строки (estimate_quality() ставит
|
||||
-- туда NOW() на момент пересчёта). Факт с прода: оценка
|
||||
-- ff421062-cc38-4c4c-ad2e-0cfac52d14ff создана 2026-08-10 12:54:47, после
|
||||
-- revival'а на GET её created_at стал 2026-08-11 04:30:03 — «дата обращения»
|
||||
-- клиента (печатается в /history и в схеме AggregatedEstimate.created_at,
|
||||
-- см. app/schemas/trade_in.py:317-318 «для метки «отчёт от DD.MM» в UI»)
|
||||
-- подменилась датой служебного пересчёта. Заодно ломался ORDER BY created_at
|
||||
-- DESC в GET /history — оживлённая старая заявка выпрыгивала в начало списка.
|
||||
--
|
||||
-- Фикс (app/api/v1/trade_in.py): created_at исключён из UPDATE SET revival'а,
|
||||
-- исходная дата больше не трогается. Момент, когда revival РЕАЛЬНО пересчитал
|
||||
-- строку (не просто "попытался" — revival_attempted_at из 255 ставится на
|
||||
-- claim'е ДО вызова estimate_quality(), в том числе при throttle-проигрыше и
|
||||
-- при неудачном пересчёте), нужен для аудита отдельно — новая колонка.
|
||||
--
|
||||
-- ── IDEMPOTENCY ───────────────────────────────────────────────────────────
|
||||
-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: NULL = либо
|
||||
-- строка живая и revival никогда успешно не пересчитывал, либо запись
|
||||
-- создана до этой колонки.
|
||||
--
|
||||
-- Dependencies: 255_trade_in_estimates_revival_relaxations.sql. Apply after: 255_*.
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE trade_in_estimates
|
||||
ADD COLUMN IF NOT EXISTS revival_completed_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN trade_in_estimates.revival_completed_at IS
|
||||
'Момент УСПЕШНОГО пересчёта «мёртвой» (median_price<=0/NULL) строки '
|
||||
'revival''ом (app/api/v1/trade_in.py::_try_revive_dead_estimate) — '
|
||||
'выставляется, когда пересчёт реально записал новые значения в строку. '
|
||||
'Отличается от revival_attempted_at (255): тот ставится на claim''е ДО '
|
||||
'вызова estimate_quality() и фиксирует ЛЮБУЮ попытку (включая throttled-'
|
||||
'проигрыш гонки и неудачный пересчёт), этот — только успех. created_at '
|
||||
'строки при этом НЕ меняется (исходная дата обращения клиента, печатается '
|
||||
'в /history) — см. fix/tradein-created 2026-08-11.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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;
|
||||
|
|
@ -241,3 +241,17 @@
|
|||
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
|
||||
257_listings_backfill_yandex_source_url.sql
|
||||
258_houses_imv_transient_attempts.sql
|
||||
259_data_quality_drop_pct_cadastr.sql
|
||||
260_houses_drop_has_panorama.sql
|
||||
261_listings_search_mv_drop_placeholder_columns.sql
|
||||
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
|
||||
263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
|
||||
264_deactivate_stale_avito_cap_mult.sql
|
||||
265_deactivate_stale_yandex_cap_mult.sql
|
||||
266_seed_deactivate_stale_null_segment_yandex_cian.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,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue