Слияние main в fix/2683-manifest-drift
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Successful in 1m0s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m41s
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI Trade-In / backend-tests (pull_request) Successful in 5m31s
CI / backend-tests (pull_request) Successful in 17m12s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Successful in 1m0s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m41s
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI Trade-In / backend-tests (pull_request) Successful in 5m31s
CI / backend-tests (pull_request) Successful in 17m12s
main принёс PR #2751 (ruff-шаг в ci-tradein.yml/backend-tests) параллельно с fetch-depth: 0 из этой ветки в том же job'е — не противоречат друг другу, слились автоматически. Единственное ручное разрешение — _manifest_applied.txt (modify/delete): main дописал файл, ветка его удаляет. Разрешение — удаление, это и есть предмет PR: гейт номеров миграций берёт эталон из git (origin/main), а не из ручного манифеста, который отставал и по построению не мог покраснеть (#2683, живой инцидент 15.08 — коллизия 264_ между двумя независимыми ветками).
This commit is contained in:
commit
90133c4c2e
141 changed files with 15500 additions and 1101 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 → НЕ триггерит деплой
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ jobs:
|
|||
# бы, а тесты всё равно скипались.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
|
||||
postgis/postgis:16-3.4
|
||||
|
|
@ -198,13 +198,22 @@ jobs:
|
|||
restore-keys: |
|
||||
uv-tradein-${{ runner.os }}-
|
||||
|
||||
- name: Sync deps (incl. dev group — pytest)
|
||||
- name: Sync deps (incl. dev group — pytest, ruff)
|
||||
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
|
||||
# вверх от cwd.
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint (ruff check)
|
||||
# Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint]
|
||||
# select = E F I B UP N RUF), но до этого шага их никто не гонял в CI —
|
||||
# "дерево чистое" было непроверенным утверждением, а не гарантией.
|
||||
# Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше),
|
||||
# т.е. ровно то, что видит `uv sync --frozen` в Dockerfile.
|
||||
# Blocking: любое нарушение → job RED (не декоративно).
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
|
||||
#
|
||||
|
|
@ -236,7 +245,7 @@ jobs:
|
|||
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
|
||||
if: always()
|
||||
working-directory: .
|
||||
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||
|
||||
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
|
||||
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -232,10 +232,49 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Build & push tradein-backend
|
||||
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
|
||||
# валит весь шаг ДО push нового образа — деплой тогда молча
|
||||
# пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай
|
||||
# без cache-from — ниже.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
|
||||
|
|
@ -256,6 +295,52 @@ jobs:
|
|||
${{ env.IMAGE_BACKEND }}:latest
|
||||
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push tradein-backend без кеша (битый buildcache, #2841)
|
||||
# cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2,
|
||||
# issue #2): успешный ретрай перезаписывает битый buildcache-тег своими
|
||||
# слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча
|
||||
# оставалась навсегда, следующий прогон снова падал на том же cache-from.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp
|
||||
file: ./tradein-mvp/backend/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_BACKEND }}:latest
|
||||
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
|
||||
# проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше).
|
||||
# Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при
|
||||
# упавшем build — этот шаг единственный это заметит: манифеста с этим SHA
|
||||
# не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy
|
||||
# ниже пропускается вместо накатки старого :latest на прод.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -272,8 +357,41 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
|
||||
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
|
||||
|
|
@ -284,6 +402,10 @@ jobs:
|
|||
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
|
||||
|
||||
- name: Build & push tradein-frontend
|
||||
# id + continue-on-error — см. tradein-backend (#2841): битый blob в
|
||||
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/frontend
|
||||
|
|
@ -308,6 +430,47 @@ jobs:
|
|||
${{ env.IMAGE_FRONTEND }}:latest
|
||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841)
|
||||
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
|
||||
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
|
||||
# слоями (mode=max), это и есть самолечение.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/frontend
|
||||
push: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_FRONTEND }}:latest
|
||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
build-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -326,10 +489,47 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Build & push tradein-browser
|
||||
# id + continue-on-error — см. tradein-backend выше (#2841): битый blob
|
||||
# в удалённом buildcache не должен ронять сборку и молча пропускать деплой.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/browser
|
||||
|
|
@ -340,6 +540,41 @@ jobs:
|
|||
${{ env.IMAGE_BROWSER }}:latest
|
||||
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push tradein-browser без кеша (битый buildcache, #2841)
|
||||
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
|
||||
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
|
||||
# слоями (mode=max), это и есть самолечение.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tradein-mvp/browser
|
||||
push: true
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_BROWSER }}:latest
|
||||
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, test, build-backend, build-frontend, build-browser]
|
||||
|
|
@ -875,3 +1110,33 @@ jobs:
|
|||
# The changes job reads this file on the next run to compute cumulative diff.
|
||||
echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha
|
||||
echo "→ Deployed SHA marker updated: $GITHUB_SHA"
|
||||
|
||||
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
|
||||
# молча (result=skipped), когда `test` или один из build-* падает (например,
|
||||
# битый blob в buildcache роняет `docker/build-push-action` — до ретрая
|
||||
# выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это
|
||||
# было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя
|
||||
# tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`,
|
||||
# кроме отмены прогона) и сама падает, если deploy не завершился success —
|
||||
# неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/
|
||||
# health-check/сверка образов #2679). Красная точка встаёт именно там, где
|
||||
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
|
||||
deploy-status:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, build-backend, build-frontend, build-browser, deploy]
|
||||
if: always() && !cancelled()
|
||||
steps:
|
||||
- name: Итог прогона — деплой обязан быть success, не skipped/failure
|
||||
run: |
|
||||
echo "test: ${{ needs.test.result }}"
|
||||
echo "build-backend: ${{ needs.build-backend.result }}"
|
||||
echo "build-frontend: ${{ needs.build-frontend.result }}"
|
||||
echo "build-browser: ${{ needs.build-browser.result }}"
|
||||
echo "deploy: ${{ needs.deploy.result }}"
|
||||
if [ "${{ needs.deploy.result }}" != "success" ]; then
|
||||
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
|
||||
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
|
||||
"Смотри логи test/build-backend/build-frontend/build-browser/deploy выше."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,19 @@ on:
|
|||
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
|
||||
# до прода до следующего чужого коммита в backend/.
|
||||
- "ops/db-bootstrap/**"
|
||||
# RBAC roles config (auth/roles.yaml, bind-mounted read-only ТОЛЬКО в backend —
|
||||
# см. docker-compose.prod.yml; worker монтирует лишь ./data и ./reports).
|
||||
# app.core.auth кэширует парсинг на весь lifetime процесса (@lru_cache) — без
|
||||
# этого триггера правка ролей вступала бы в силу в случайный момент, только на
|
||||
# следующий чужой деплой (`up -d --force-recreate --no-deps backend worker beat`
|
||||
# ниже сбрасывает кэш перезапуском процесса; сам файл в образ не запекается,
|
||||
# ребилда картинок для этого не нужно).
|
||||
- "auth/**"
|
||||
# То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM
|
||||
# по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard`
|
||||
# шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами
|
||||
# исполнял бы старую версию — молча и без единого сигнала.
|
||||
- "ops/docker-prune.sh"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -71,10 +84,49 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Build & push backend (lean — без Chromium)
|
||||
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
|
||||
# (registry cache, не local) валит весь шаг ДО push нового образа —
|
||||
# деплой тогда молча пропускается (#2841), хотя код собрать можно, просто
|
||||
# без кеша. cache-from нефатален: при падении ретраим БЕЗ него ниже.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
|
|
@ -86,6 +138,53 @@ jobs:
|
|||
${{ env.IMAGE_BACKEND }}:latest
|
||||
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push backend без кеша (битый buildcache, #2841)
|
||||
# cache-from опущен (источник падения), а cache-to ОСТАВЛЕН: успешный
|
||||
# ретрай пушит свежие слои в buildcache-тег и тем самым сам перезаписывает
|
||||
# битый blob (mode=max — полная перезапись манифеста). Раньше cache-to был
|
||||
# опущен и здесь тоже — но следующий обычный прогон опять получает cache-from
|
||||
# на детерминированно битый тег и падает СНОВА: самолечения не было НИКОГДА
|
||||
# (ревью #2841 R2, issue #2). Если и retry упадёт — шаг красный БЕЗ
|
||||
# continue-on-error, job честно FAILURE, и deploy ниже корректно
|
||||
# пропускается (уже настоящая причина, не кеш).
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
target: runner
|
||||
push: true
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_BACKEND }}:latest
|
||||
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
|
||||
# проверяет РЕАЛЬНОЕ состояние registry напрямую через buildx (уже настроен
|
||||
# выше). Если act_runner не заполняет outcome (не проверено живым прогоном,
|
||||
# см. ревью), ретрай выше молча НЕ побежит при упавшем build, а этот шаг —
|
||||
# единственный, кто это заметит: манифеста с этим SHA не будет → шаг падает
|
||||
# БЕЗ continue-on-error → job честно FAILURE → deploy ниже пропускается
|
||||
# вместо накатки старого :latest на прод.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -102,10 +201,47 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Build & push worker (с Chromium для Playwright)
|
||||
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
|
||||
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
|
|
@ -117,6 +253,45 @@ jobs:
|
|||
${{ env.IMAGE_WORKER }}:latest
|
||||
${{ env.IMAGE_WORKER }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push worker без кеша (битый buildcache, #2841)
|
||||
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
|
||||
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
|
||||
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
|
||||
# навсегда — следующий прогон снова падал на том же cache-from.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
target: runner-with-chromium
|
||||
push: true
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_WORKER }}:latest
|
||||
${{ env.IMAGE_WORKER }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что worker:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
|
||||
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
|
||||
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_WORKER }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -133,10 +308,47 @@ jobs:
|
|||
run: |
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||
|
||||
- name: Подобрать протёкшие buildx-билдеры (#2869)
|
||||
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
|
||||
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
|
||||
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
|
||||
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
|
||||
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
|
||||
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
|
||||
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
|
||||
# `no space left on device`.
|
||||
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
|
||||
# принадлежит живому прогону (самый долгий job — ~17 минут).
|
||||
run: |
|
||||
now=$(date +%s); reaped=0; kept=0
|
||||
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
|
||||
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
|
||||
ts=$(date -d "$created" +%s 2>/dev/null) || continue
|
||||
age_h=$(( (now - ts) / 3600 ))
|
||||
if [ "$age_h" -ge 6 ]; then
|
||||
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
|
||||
if docker rm -f "$c" >/dev/null 2>&1; then
|
||||
reaped=$((reaped+1))
|
||||
else
|
||||
echo "buildx: не удалось убрать $c (не фатально)"
|
||||
fi
|
||||
docker volume rm "${c}_state" >/dev/null 2>&1 || true
|
||||
else
|
||||
kept=$((kept+1))
|
||||
fi
|
||||
done
|
||||
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Build & push frontend
|
||||
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
|
||||
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
|
||||
id: build
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./frontend
|
||||
|
|
@ -150,6 +362,47 @@ jobs:
|
|||
${{ env.IMAGE_FRONTEND }}:latest
|
||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||
|
||||
- name: Retry build & push frontend без кеша (битый buildcache, #2841)
|
||||
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
|
||||
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
|
||||
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
|
||||
# навсегда — следующий прогон снова падал на том же cache-from.
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
|
||||
NEXT_PUBLIC_ENVIRONMENT=production
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
${{ env.IMAGE_FRONTEND }}:latest
|
||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||
|
||||
- name: Проверить, что frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
|
||||
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
|
||||
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
|
||||
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
|
||||
|
||||
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
|
||||
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
|
||||
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
|
||||
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
|
||||
# их `_state`-томах — диск ушёл на 94%, деплой упал с
|
||||
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
|
||||
# чтобы уборка не могла уронить прогон.
|
||||
if: always()
|
||||
run: |
|
||||
name="${{ steps.buildx.outputs.name }}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
|
||||
exit 0
|
||||
fi
|
||||
echo "buildx: убираю билдер $name"
|
||||
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, build-backend, build-worker, build-frontend]
|
||||
|
|
@ -467,8 +720,50 @@ jobs:
|
|||
docker image prune -af || true
|
||||
docker builder prune -af || true
|
||||
|
||||
# Health check
|
||||
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214,
|
||||
# уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3).
|
||||
# `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не
|
||||
# последняя команда &&-списка, а POSIX прямо освобождает от errexit
|
||||
# все команды AND/OR-списка кроме последней. После 30 неуспешных
|
||||
# попыток цикл завершался кодом последнего sleep (0) — скрипт тихо
|
||||
# продолжался, деплой уходил success с мёртвым бэкендом.
|
||||
healthy=""
|
||||
for i in $(seq 1 30); do
|
||||
curl -fsS http://localhost:8000/health && break
|
||||
if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
|
||||
healthy="yes"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$healthy" ]; then
|
||||
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ backend healthy на /health."
|
||||
|
||||
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
|
||||
# молча (result=skipped), когда build падает (например, битый blob в
|
||||
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
|
||||
# skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на
|
||||
# первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился.
|
||||
# Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает,
|
||||
# если deploy не завершился success — неважно, пропущен он (build упал) или
|
||||
# упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где
|
||||
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
|
||||
deploy-status:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-backend, build-worker, build-frontend, deploy]
|
||||
if: always() && !cancelled()
|
||||
steps:
|
||||
- name: Итог прогона — деплой обязан быть success, не skipped/failure
|
||||
run: |
|
||||
echo "build-backend: ${{ needs.build-backend.result }}"
|
||||
echo "build-worker: ${{ needs.build-worker.result }}"
|
||||
echo "build-frontend: ${{ needs.build-frontend.result }}"
|
||||
echo "deploy: ${{ needs.deploy.result }}"
|
||||
if [ "${{ needs.deploy.result }}" != "success" ]; then
|
||||
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
|
||||
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
|
||||
"Смотри логи build-backend/build-worker/build-frontend/deploy выше."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
91
.github/workflows/ci.yml
vendored
91
.github/workflows/ci.yml
vendored
|
|
@ -1,91 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/**'
|
||||
- 'fix/**'
|
||||
- 'refactor/**'
|
||||
- 'chore/**'
|
||||
- 'docs/**'
|
||||
- 'perf/**'
|
||||
- 'test/**'
|
||||
- 'hotfix/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_DB: gendesign
|
||||
POSTGRES_USER: gendesign
|
||||
POSTGRES_PASSWORD: gendesign
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U gendesign"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install system deps for geo + WeasyPrint
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
|
||||
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Type check (mypy strict on core)
|
||||
run: |
|
||||
uv run mypy \
|
||||
app/services/generative \
|
||||
app/services/site_finder/scorer.py
|
||||
|
||||
- name: Test (pytest)
|
||||
run: uv run pytest -q
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci || npm install
|
||||
- run: npm run lint
|
||||
- run: npm run type-check
|
||||
- run: npm run build
|
||||
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
|
||||
|
|
|
|||
|
|
@ -85,12 +85,10 @@ docker-compose.prod.yml main стек (backend, frontend, postgres, redis, work
|
|||
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
|
||||
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
|
||||
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
|
||||
├── ci.yml lint (ruff) + mypy + pytest на PR
|
||||
├── ci.yml lint (ruff) + pytest на PR
|
||||
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
|
||||
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations)
|
||||
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
|
||||
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
|
||||
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -158,7 +156,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
|
|||
|
||||
**Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026):
|
||||
|
||||
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
|
||||
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + pytest (coverage gate ≥65%). mypy strict в гейте не гоняется (доступен вручную — `uv run mypy app/services/generative app/services/site_finder/scorer.py`). Блокирует merge при провале.
|
||||
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
|
||||
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
|
||||
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*
|
||||
|
|
|
|||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
.coverage
|
||||
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
|
||||
coverage.xml
|
||||
|
|
|
|||
|
|
@ -2319,12 +2319,31 @@ def analyze_parcel(
|
|||
-- (303 строки = 303 distinct) → COUNT(*) по дедуп-физлотам корректен.
|
||||
SELECT
|
||||
np.domrf_obj_id,
|
||||
ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
|
||||
-- #2464-D: границы правдоподобия, как в двух соседних запросах
|
||||
-- по этой же таблице (BETWEEN 30000 AND 600000) — здесь их не было.
|
||||
-- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп +
|
||||
-- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827,
|
||||
-- из них 118 в 10 замапленных проектах и 86 — в незамапленных.
|
||||
-- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на
|
||||
-- 2.4%, market_avg_price (среднее средних) 138 056 → 138 008;
|
||||
-- NULL не появляется нигде. Ставим границы не ради этих 48 ₽,
|
||||
-- а потому что среднее считается ПО ПРОЕКТУ и один лот держит
|
||||
-- группу без ограничения сверху: максимум в таблице —
|
||||
-- 19 198 429 ₽/м² (ЖК «Дебют»), и он вне экрана только потому,
|
||||
-- что проект пока не замаплен (замаплено 308 имён из 881, список
|
||||
-- растёт). Одна строка маппинга — и это число на экране.
|
||||
-- FILTER, а не WHERE: строки нужны целиком, иначе поедут
|
||||
-- units_sold / units_available, считающие ВСЕ лоты.
|
||||
ROUND(AVG(oll.price_per_m2_rub) FILTER (
|
||||
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
|
||||
)::numeric, 0) AS avg_price_per_m2_rub,
|
||||
ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd,
|
||||
COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold,
|
||||
COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available,
|
||||
-- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик
|
||||
-- обещал бы выборку шире, чем на самом деле участвовала.
|
||||
COUNT(*) FILTER (
|
||||
WHERE oll.price_per_m2_rub IS NOT NULL
|
||||
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
|
||||
) AS lots_with_price
|
||||
FROM nearby_projects np
|
||||
JOIN obj_lots_latest oll
|
||||
|
|
|
|||
|
|
@ -508,3 +508,24 @@ async def health() -> dict[str, str]:
|
|||
"environment": settings.environment,
|
||||
"version": app.version,
|
||||
}
|
||||
|
||||
|
||||
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
||||
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
||||
# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health {
|
||||
# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на
|
||||
# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor
|
||||
# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по
|
||||
# статусу. media_type="application/json" — Content-Type совпадает с GET;
|
||||
# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы
|
||||
# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload-
|
||||
# заголовки (Content-Length) для HEAD, требует совпадения только заголовков
|
||||
# представления (Content-Type).
|
||||
# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть
|
||||
# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в
|
||||
# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации
|
||||
# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута,
|
||||
# который фронт никогда не вызывает.
|
||||
@app.head("/health", include_in_schema=False)
|
||||
async def health_head() -> Response:
|
||||
return Response(status_code=200, media_type="application/json")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
|
||||
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkServerError
|
||||
from app.scrapers.nspd_bulk_client import (
|
||||
NSPDBulkClient,
|
||||
NspdBulkRateLimitError,
|
||||
NspdBulkServerError,
|
||||
NspdBulkWafError,
|
||||
)
|
||||
from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -182,6 +187,13 @@ async def harvest_quarter(
|
|||
try:
|
||||
cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id)
|
||||
result.snapshot_requests += 1
|
||||
except (NspdBulkWafError, NspdBulkRateLimitError):
|
||||
# #2464-A: бан IP / исчерпанные ретраи — НЕ «этот cat не дошёл».
|
||||
# Контракт harvest_quarter (Raises:) обещает пробросить их наверх,
|
||||
# а голый except ниже их глотал: прогон доходил до status='done'
|
||||
# с частичными данными. Прод-замер 13.08: 23 job'а, 50 WAF-блоков,
|
||||
# 0 упавших — то есть бан ни разу не остановил сбор.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s",
|
||||
|
|
@ -279,6 +291,9 @@ async def harvest_quarter(
|
|||
logger.info(
|
||||
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||
)
|
||||
except (NspdBulkWafError, NspdBulkRateLimitError):
|
||||
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||
|
||||
|
|
@ -399,6 +414,18 @@ async def _grid_walk_category(
|
|||
requests += 1
|
||||
server_errors += 1
|
||||
continue
|
||||
except (NspdBulkWafError, NspdBulkRateLimitError):
|
||||
# #2464-A: 403 WAF — бан IP, а не «этот cell не дошёл». Продолжать
|
||||
# обход значит углублять бан и дописать в БД ложный нулевой слой.
|
||||
# Зеркало уже исправленных nspd_bulk_client.get_features_in_bbox_grid
|
||||
# и nspd_client.get_features_in_bbox_grid (#2464-G).
|
||||
logger.warning(
|
||||
"_grid_walk_category: WAF/rate-limit layer=%d quarter=%s cell=%d — прерываем",
|
||||
layer_id,
|
||||
quarter,
|
||||
idx,
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Прочие (сетевые / parse) ошибки одного cell — тоже не валим квартал,
|
||||
# но это НЕ server-side 500 → не учитываем в server_errors (иначе сеть
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
|
||||
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
|
||||
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
|
||||
покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
|
||||
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
|
||||
покрытие domrf↔objective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987.
|
||||
цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987.
|
||||
|
||||
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x —
|
||||
движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только
|
||||
|
|
@ -28,8 +28,10 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ,
|
|||
мало сделок → скоростные метрики статистически ненадёжны.
|
||||
• analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low
|
||||
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример).
|
||||
• domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics
|
||||
docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён.
|
||||
• domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
|
||||
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
|
||||
domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
|
||||
медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже.
|
||||
• history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий
|
||||
ряд → связь rate↔sales / тренды не установлены.
|
||||
• confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный
|
||||
|
|
@ -91,9 +93,11 @@ _DEAL_COUNT_LOW: int = 15
|
|||
_ANALOG_COUNT_HIGH: int = 3
|
||||
_ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low
|
||||
|
||||
# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%).
|
||||
# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог
|
||||
# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства).
|
||||
# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое,
|
||||
# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК.
|
||||
# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6.
|
||||
# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина
|
||||
# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз.
|
||||
_DOMRF_COVERAGE_HIGH: float = 0.6
|
||||
_DOMRF_COVERAGE_LOW: float = 0.2
|
||||
|
||||
|
|
@ -252,23 +256,36 @@ _QUALITY_WORD: dict[Confidence, str] = {
|
|||
|
||||
|
||||
def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
|
||||
"""domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
|
||||
"""Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
|
||||
|
||||
Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ
|
||||
(структурный §15-пример «покрытие domrf↔objective 2.5%»). None → low.
|
||||
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
|
||||
маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот
|
||||
`supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в
|
||||
`orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из
|
||||
`analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`,
|
||||
то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective.
|
||||
|
||||
Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% ·
|
||||
max 70%. Это не «~2.5% покрытия domrf↔objective», как было написано здесь
|
||||
раньше, — другая величина другого порядка.
|
||||
|
||||
Ключ фактора НЕ переименован намеренно: его читает фронт
|
||||
(`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт.
|
||||
Порог и значение не меняются — правится только то, что читает человек.
|
||||
None → low.
|
||||
"""
|
||||
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
|
||||
if coverage is None:
|
||||
note = (
|
||||
"Доля будущих проектов с известными планировками и площадями неизвестна — "
|
||||
"оценка будущего предложения и конкуренции менее надёжна"
|
||||
"Доля ближних ЖК с известной ценой из Objective неизвестна — "
|
||||
"оценка рынка и конкуренции менее надёжна"
|
||||
)
|
||||
else:
|
||||
pct = round(float(coverage) * 100.0, 1)
|
||||
note = (
|
||||
f"Известные планировки и площади есть у {pct}% будущих проектов "
|
||||
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
|
||||
"будущего предложения и конкуренции"
|
||||
f"Цена из Objective известна у {pct}% ближних ЖК "
|
||||
f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки "
|
||||
"рынка и конкуренции"
|
||||
)
|
||||
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
|
||||
|
||||
|
|
@ -294,9 +311,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor:
|
|||
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
|
||||
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
|
||||
)
|
||||
return ConfidenceFactor(
|
||||
name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
|
||||
)
|
||||
return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note)
|
||||
|
||||
|
||||
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
|
||||
|
|
@ -479,7 +494,9 @@ def compute_report_confidence(
|
|||
deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
|
||||
в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
|
||||
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
||||
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа
|
||||
историческое — про маппинг domrf↔objective, продьюсера для которого
|
||||
нет и не было (#2464-H, см. _coverage_factor).
|
||||
history_months: глубина ряда (мес).
|
||||
confounded: True, если окно ряда пересекает шок-период (PR2).
|
||||
advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда).
|
||||
|
|
|
|||
|
|
@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None
|
|||
|
||||
|
||||
def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None:
|
||||
"""Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE.
|
||||
"""Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE.
|
||||
|
||||
Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ
|
||||
per-branch — НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8%
|
||||
спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе,
|
||||
который §15 призван флагать):
|
||||
• `supply_layers.domrf_coverage` — уже ДОЛЯ ∈ [0,1] (0.025) → берём как есть.
|
||||
• `analyze.market_data_coverage_pct` — всегда ПРОЦЕНТ (2.5 == 2.5%) → /100 → доля.
|
||||
Нет сигнала → None (#990 → тянет в low: слой §9.3 недооценён).
|
||||
Источники по приоритету (единица ЯВНАЯ per-branch — НЕ угадываем по величине,
|
||||
иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%):
|
||||
• `supply_layers.domrf_coverage` — ДОЛЯ ∈ [0,1] → берём как есть.
|
||||
• `analyze.market_data_coverage_pct` — ПРОЦЕНТ (40 == 40%) → /100 → доля.
|
||||
Нет сигнала → None.
|
||||
|
||||
#2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот
|
||||
`supply_layers.domrf_coverage` никто не заполняет — `_summarize_supply_layers`
|
||||
в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим — нет
|
||||
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
|
||||
величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как
|
||||
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective —
|
||||
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
|
||||
|
||||
Порядок веток оставлен: если продьюсер появится, приоритет у него.
|
||||
"""
|
||||
if supply_layers is not None:
|
||||
coverage = supply_layers.get("domrf_coverage")
|
||||
|
|
|
|||
|
|
@ -559,7 +559,11 @@ class NSPDClient:
|
|||
"""
|
||||
# Импортируем здесь чтобы избежать circular import:
|
||||
# nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain)
|
||||
from app.scrapers.nspd_bulk_client import NSPDBulkClient
|
||||
from app.scrapers.nspd_bulk_client import (
|
||||
NSPDBulkClient,
|
||||
NspdBulkServerError,
|
||||
NspdBulkWafError,
|
||||
)
|
||||
|
||||
xmin, ymin, xmax, ymax = bbox
|
||||
width_m = xmax - xmin
|
||||
|
|
@ -607,10 +611,45 @@ class NSPDClient:
|
|||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
features: list[NSPDFeature] = []
|
||||
for r in results:
|
||||
if isinstance(r, Exception):
|
||||
logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r)
|
||||
# #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход
|
||||
# возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился
|
||||
# неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669
|
||||
# с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл.
|
||||
# Ниже — зеркало уже исправленного близнеца
|
||||
# nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror).
|
||||
server_errors = 0
|
||||
ok_cells = 0
|
||||
first_server_error: NspdBulkServerError | None = None
|
||||
for idx, r in enumerate(results):
|
||||
if isinstance(r, NspdBulkWafError):
|
||||
# 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход
|
||||
# бессмысленно, а пустой результат соврал бы про отсутствие зон.
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
r,
|
||||
)
|
||||
raise r
|
||||
if isinstance(r, NspdBulkServerError):
|
||||
server_errors += 1
|
||||
if first_server_error is None:
|
||||
first_server_error = r
|
||||
logger.debug(
|
||||
"get_features_in_bbox_grid layer=%d cell=%d server error: %s",
|
||||
layer_id,
|
||||
idx,
|
||||
r,
|
||||
)
|
||||
continue
|
||||
if isinstance(r, Exception):
|
||||
# Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ
|
||||
# считаем server-side, иначе сеть ложно поднимет layer_failed.
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r
|
||||
)
|
||||
continue
|
||||
ok_cells += 1
|
||||
for bulk_feat in r:
|
||||
raw = {
|
||||
"id": bulk_feat.id,
|
||||
|
|
@ -618,6 +657,20 @@ class NSPDClient:
|
|||
"properties": bulk_feat.properties,
|
||||
}
|
||||
features.append(NSPDFeature.from_raw(raw))
|
||||
|
||||
# Были server-side отказы И ни одна ячейка не прошла — лёг слой или
|
||||
# весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто
|
||||
# ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого.
|
||||
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
|
||||
logger.warning(
|
||||
"get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
|
||||
"(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого",
|
||||
layer_id,
|
||||
effective_n,
|
||||
effective_n,
|
||||
server_errors,
|
||||
)
|
||||
raise first_server_error
|
||||
return features
|
||||
|
||||
raw_features = asyncio.run(_run_grid())
|
||||
|
|
@ -679,6 +732,10 @@ class NSPDClient:
|
|||
dict[layerId, list[NSPDFeature]]. Ключи — все запрошенные layerId
|
||||
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
|
||||
"""
|
||||
# Локальный импорт по той же причине, что в get_features_in_bbox_grid:
|
||||
# nspd_client ← nspd_bulk_client дало бы circular import на top-level.
|
||||
from app.scrapers.nspd_bulk_client import NspdBulkServerError
|
||||
|
||||
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
|
||||
result: dict[int, list[NSPDFeature]] = {}
|
||||
for layer_id in layer_ids:
|
||||
|
|
@ -686,7 +743,15 @@ class NSPDClient:
|
|||
feats = self.get_features_in_bbox_grid(
|
||||
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
|
||||
)
|
||||
except (NspdLiteError, NspdLiteWafError) as exc:
|
||||
except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc:
|
||||
# #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError
|
||||
# («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение —
|
||||
# пустой список на слой, — потому что именно это обещает докстрока
|
||||
# («пустой list если слой пуст / упал») и на это опирается вызывающий.
|
||||
# NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать
|
||||
# обход остальных слоёв значит углублять бан.
|
||||
# Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему
|
||||
# неразличимы — у функции нет канала для флага. Отдельным заходом.
|
||||
logger.warning(
|
||||
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
|
||||
layer_id,
|
||||
|
|
@ -840,9 +905,19 @@ class NSPDClient:
|
|||
`layers_fetched` в этом случае содержит только `('search',)`.
|
||||
|
||||
Raises:
|
||||
NspdLiteWafError при 403/429 на любом из layer запросов — caller
|
||||
должен делать backoff. Partial-success НЕ возвращается; вся
|
||||
операция атомарна (failure → exception).
|
||||
NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings) —
|
||||
caller должен делать backoff.
|
||||
NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) —
|
||||
бан IP, обход прерывается сразу.
|
||||
NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и
|
||||
ни одна ячейка не прошла) — иначе вернулся бы пустой список,
|
||||
неотличимый от честного «здесь ничего нет».
|
||||
|
||||
До #2464-G это место обещало атомарность, которой не было: grid-walk
|
||||
глушил любое исключение ячейки и отдавал []. Теперь обещание верно
|
||||
для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН:
|
||||
если часть ячеек упала по сети, а часть прошла, вернётся то, что
|
||||
собралось, с warning'ом в лог на каждую упавшую ячейку.
|
||||
|
||||
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
|
||||
E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.
|
||||
|
|
|
|||
|
|
@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text("""
|
|||
SELECT
|
||||
a.room_bucket,
|
||||
SUM(a.deals_window) AS deals_window,
|
||||
-- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2
|
||||
-- объявлен как float (не Optional), и NULL ронял бы контракт API.
|
||||
-- Пустые комнатности получают площадь 0 м², и это тоже неправда — но
|
||||
-- честный NULL требует правки схемы + перегенерации типов фронта
|
||||
-- и решения, что писать в area_bin. Отдельным заходом: #2867.
|
||||
COALESCE(
|
||||
SUM(a.area_weighted_sum)
|
||||
/ NULLIF(SUM(a.deals_window), 0),
|
||||
0
|
||||
)::numeric(10, 2) AS avg_area_m2,
|
||||
COALESCE(
|
||||
-- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL →
|
||||
-- средней цены нет, и это NULL, а не «0 ₽/м²». Схема так и объявлена
|
||||
-- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже
|
||||
-- умеет None (пропускает строку во взвешенном роллапе) — но COALESCE
|
||||
-- делал эту ветку недостижимой.
|
||||
-- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет — зависит
|
||||
-- от того, сколько замапленных проектов попало в радиус, поэтому цифры
|
||||
-- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635;
|
||||
-- 323 проекта имеют хотя бы одну пустую комнатность, 80 — пустые ВСЕ.
|
||||
-- При объединении по два пустых остаётся 255 из 1267, по всему городу —
|
||||
-- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0.
|
||||
(
|
||||
SUM(a.price_weighted_sum)
|
||||
/ NULLIF(SUM(a.deals_window), 0),
|
||||
0
|
||||
/ NULLIF(SUM(a.deals_window), 0)
|
||||
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
|
||||
array_agg(DISTINCT a.project_name) AS matched_project_names,
|
||||
MIN(a.window_start) AS window_start,
|
||||
|
|
|
|||
|
|
@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object:
|
|||
def _pct_share_to_percent(value: object) -> float | None:
|
||||
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
|
||||
|
||||
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
|
||||
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1).
|
||||
|
||||
#2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E
|
||||
раньше писалось в `load_index`, но это категориальная колонка
|
||||
('open'|'limited'|'closed'|NULL) — число в ней фронт отбрасывает в
|
||||
«неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`.
|
||||
Функцию оставляю с тестами: она описывает формат листа, и она понадобится
|
||||
в тот момент, когда под процент загрузки заведут числовую колонку.
|
||||
Если такого решения не будет — удалить вместе с тестом, а не держать молча.
|
||||
|
||||
None/мусор → None.
|
||||
"""
|
||||
num = parse_reserve_number(value)
|
||||
|
|
@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
|
|||
rows_seen += 1
|
||||
|
||||
district = _cell(row, 1) # B
|
||||
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
|
||||
# Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места
|
||||
# под неё в power_supply_centers нет — load_index категориальный,
|
||||
# current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже).
|
||||
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
|
||||
name_norm = normalize_sc_name(str(sc_name))
|
||||
|
||||
|
|
@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
|
|||
"reserve": reserve,
|
||||
"asof": reserve_asof,
|
||||
"district": str(district).strip() if district else None,
|
||||
"load_pct": load_pct,
|
||||
"name_norm": name_norm,
|
||||
}
|
||||
|
||||
|
|
@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
|
|||
reserve_unit = 'МВт',
|
||||
installed_capacity_mva = :installed,
|
||||
district = :district,
|
||||
load_index = COALESCE(
|
||||
load_index,
|
||||
CAST(:load_pct AS text)
|
||||
),
|
||||
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
|
||||
-- load_index — категориальная колонка
|
||||
-- ('open'|'limited'|'closed'|NULL, см.
|
||||
-- data/sql/180_connection_capacity.sql:35), её
|
||||
-- заполняет rosseti_wfs_loader._map_load_index.
|
||||
-- Раньше тут стоял COALESCE(load_index,
|
||||
-- CAST(:load_pct AS text)) — при пустой ячейке
|
||||
-- в колонку легло бы число строкой ("72.5"),
|
||||
-- а фронтовый classifyLoadIndex такое значение
|
||||
-- отбрасывает в null («неизвестно»), и в
|
||||
-- power_summary.by_load_index появился бы
|
||||
-- бакет с именем "72.5".
|
||||
-- Сегодня не стреляло только потому, что у всех
|
||||
-- 3416 строк load_index уже заполнен
|
||||
-- (open 2741 / limited 346 / closed 329, NULL 0)
|
||||
-- и COALESCE не проваливался.
|
||||
capacity_source = 'eesk_35_220',
|
||||
reserve_asof = :asof
|
||||
WHERE sc_name_norm = :name_norm
|
||||
|
|
|
|||
|
|
@ -36,6 +36,48 @@ from sqlalchemy.orm import Session
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы
|
||||
# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`.
|
||||
# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`,
|
||||
# которого внутри CTE нет (`missing FROM-clause entry for table "o"`).
|
||||
_COMPETITORS_SQL_TMPL = """
|
||||
WITH latest_obj AS (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id,
|
||||
comm_name,
|
||||
dev_name,
|
||||
-- #38: эффективный класс — реальный, иначе fallback
|
||||
COALESCE(obj_class, obj_class_fallback) AS obj_class,
|
||||
latitude,
|
||||
longitude,
|
||||
district_name
|
||||
FROM domrf_kn_objects
|
||||
WHERE latitude IS NOT NULL
|
||||
AND longitude IS NOT NULL
|
||||
AND region_cd = 66
|
||||
{class_filter}
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
)
|
||||
SELECT
|
||||
o.obj_id,
|
||||
o.comm_name,
|
||||
o.dev_name,
|
||||
o.obj_class,
|
||||
o.district_name,
|
||||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
|
||||
) AS distance_m
|
||||
FROM latest_obj o
|
||||
WHERE ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
|
||||
:radius_m
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT 200
|
||||
"""
|
||||
|
||||
# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
|
||||
# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
|
||||
# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
|
||||
|
|
@ -173,9 +215,17 @@ def compute_velocity(
|
|||
# только если явно передан. #38: при NULL реального класса используем
|
||||
# obj_class_fallback (yandex_match / price_inference) — реальный obj_class
|
||||
# в приоритете (COALESCE), поведение для размеченных ЖК не меняется.
|
||||
class_filter = (
|
||||
"AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else ""
|
||||
)
|
||||
# Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM —
|
||||
# голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT,
|
||||
# и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"`
|
||||
# (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity
|
||||
# молча выпадал из отчёта. Не срабатывало только потому, что единственный
|
||||
# вызывающий (parcels.py) obj_class не передаёт.
|
||||
# NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а
|
||||
# в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870,
|
||||
# «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9,
|
||||
# «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки.
|
||||
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else ""
|
||||
# SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
|
||||
# db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
|
||||
# (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
|
||||
|
|
@ -183,45 +233,7 @@ def compute_velocity(
|
|||
with db.begin_nested():
|
||||
comp_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH latest_obj AS (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id,
|
||||
comm_name,
|
||||
dev_name,
|
||||
-- #38: эффективный класс — реальный, иначе fallback
|
||||
COALESCE(obj_class, obj_class_fallback) AS obj_class,
|
||||
latitude,
|
||||
longitude,
|
||||
district_name
|
||||
FROM domrf_kn_objects
|
||||
WHERE latitude IS NOT NULL
|
||||
AND longitude IS NOT NULL
|
||||
AND region_cd = 66
|
||||
{class_filter}
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
)
|
||||
SELECT
|
||||
o.obj_id,
|
||||
o.comm_name,
|
||||
o.dev_name,
|
||||
o.obj_class,
|
||||
o.district_name,
|
||||
ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
|
||||
) AS distance_m
|
||||
FROM latest_obj o
|
||||
WHERE ST_DWithin(
|
||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
|
||||
:radius_m
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT 200
|
||||
"""
|
||||
),
|
||||
text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)),
|
||||
{
|
||||
"parcel_wkt": parcel_geom_wkt,
|
||||
"radius_m": radius_km * 1000.0,
|
||||
|
|
|
|||
|
|
@ -406,16 +406,17 @@ def build_beat_schedule() -> dict:
|
|||
|
||||
# Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц.
|
||||
# kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д.
|
||||
# Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления.
|
||||
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
||||
# за ~5 недель полного обновления.
|
||||
#
|
||||
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
|
||||
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
|
||||
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет
|
||||
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
|
||||
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
|
||||
# cooldown 24-48h (проверить через targeted test).
|
||||
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
||||
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
||||
# "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC
|
||||
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
||||
# "kwargs": {"region_code": 66, "max_objects": 300},
|
||||
# "options": {"queue": "celery"},
|
||||
# }
|
||||
|
|
@ -430,10 +431,10 @@ def build_beat_schedule() -> dict:
|
|||
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
||||
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
|
||||
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
||||
# напр. четверг 04:00 UTC.
|
||||
# напр. четверг 04:00 МСК.
|
||||
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
||||
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
|
||||
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
|
||||
# "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК
|
||||
# "kwargs": {"region_code": 66, "max_flats": 300},
|
||||
# "options": {"queue": "celery"},
|
||||
# }
|
||||
|
|
@ -542,13 +543,20 @@ def build_beat_schedule() -> dict:
|
|||
}
|
||||
|
||||
# Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings.
|
||||
# Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
|
||||
# 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
|
||||
# Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды,
|
||||
# оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due
|
||||
# task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три
|
||||
# часа раньше обещанного.
|
||||
# Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК
|
||||
# завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC
|
||||
# = 03:00-04:00 МСК), с которым она делит источник — tradein.houses.
|
||||
# Не в job_settings (технический ETL, не требует конфигурации UI).
|
||||
# Идемпотентен через ON CONFLICT (source, ext_house_id).
|
||||
# Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения.
|
||||
schedule["newbuilding-crossload-nightly"] = {
|
||||
"task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload",
|
||||
"schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК
|
||||
"schedule": _parse_cron("30 0 * * *"), # 00:30 МСК
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ class TestCompetitorsSortOrder:
|
|||
|
||||
sorted_rows = sorted(_ROWS_MIXED, key=_sort_key)
|
||||
first = dict(sorted_rows[0].items())
|
||||
assert first["site_status"] == "Строящиеся", (
|
||||
f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'"
|
||||
)
|
||||
assert (
|
||||
first["site_status"] == "Строящиеся"
|
||||
), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'"
|
||||
|
||||
def test_flat_count_desc_would_break_order(self) -> None:
|
||||
"""Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми."""
|
||||
|
|
@ -180,14 +180,44 @@ class TestObjPricingPushdown:
|
|||
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
|
||||
physflat-дедуп CTE obj_lots_latest (alias oll) — см. test_obj_pricing_*_physflat
|
||||
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
|
||||
|
||||
#2464-D: у среднего цены появились границы правдоподобия (те же, что в двух
|
||||
соседних запросах по objective_lots) — см. test_price_avg_has_sanity_bounds.
|
||||
"""
|
||||
sql = self._competitor_sql()
|
||||
assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
|
||||
assert "AS avg_price_per_m2_rub" in sql
|
||||
assert "lots_with_price" in sql
|
||||
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
|
||||
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
|
||||
assert "GROUP BY np.domrf_obj_id" in sql
|
||||
|
||||
def test_price_avg_has_sanity_bounds(self) -> None:
|
||||
"""#2464-D: среднее цены считается по лотам в границах правдоподобия.
|
||||
|
||||
Среднее считается ПО ПРОЕКТУ, поэтому один лот держит группу без ограничения
|
||||
сверху: максимум в objective_lots — 19.2 млн ₽/м² (замер 13.08). Границы
|
||||
30000..600000 уже стоят в двух соседних запросах по этой же таблице; здесь
|
||||
их не было. Дальше значение уходит в market_avg_price и на экран.
|
||||
"""
|
||||
sql = self._competitor_sql()
|
||||
bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000"
|
||||
assert (
|
||||
f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql
|
||||
), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)"
|
||||
# Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем
|
||||
# реально участвовало в среднем.
|
||||
assert (
|
||||
f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql
|
||||
), "lots_with_price должен считать ту же популяцию, что и среднее"
|
||||
# FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены
|
||||
# молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты.
|
||||
assert (
|
||||
"COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
|
||||
), "units_sold не должен зависеть от границ цены"
|
||||
assert (
|
||||
"COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
|
||||
), "units_available не должен зависеть от границ цены"
|
||||
|
||||
def test_obj_pricing_dedups_physflat_inline(self) -> None:
|
||||
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.
|
||||
|
||||
|
|
|
|||
|
|
@ -96,15 +96,24 @@ def pytest_sessionfinish(session, exitstatus) -> None:
|
|||
unlisted = sorted(_observed_skips - _allowed_skips())
|
||||
if not unlisted:
|
||||
return
|
||||
print(
|
||||
f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
|
||||
head = (
|
||||
f"НЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
|
||||
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
|
||||
)
|
||||
print(f"\n{head}")
|
||||
for nodeid in unlisted:
|
||||
print(f" - {nodeid}")
|
||||
print(
|
||||
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
|
||||
"пропуск без записи неотличим от пройденной проверки."
|
||||
)
|
||||
# #2871: под Actions дублируем в ::error:: — иначе сообщение тонет.
|
||||
# 13.08 этот сторож четыре прогона подряд ронял job'у совершенно правильно,
|
||||
# а его строка лежала посреди тысячи других (обычный print, по-русски) —
|
||||
# и поиск по «FAILED / ERROR» её не находил. Причину искали три часа
|
||||
# в диске, раннере, покрытии и кэше. Сторож, который роняет прогон,
|
||||
# обязан кричать так, чтобы его нашли.
|
||||
if os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"):
|
||||
print(f"::error::{head} " + "; ".join(unlisted))
|
||||
if exitstatus == 0:
|
||||
session.exitstatus = 1
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL
|
||||
from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL
|
||||
from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL
|
||||
from tests.integration.conftest import requires_test_db
|
||||
|
||||
# NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time
|
||||
|
|
@ -103,9 +104,9 @@ class TestNeighborsSummarySql:
|
|||
for kw in forbidden_aliases:
|
||||
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
|
||||
# формы CTE-биндинга.
|
||||
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
|
||||
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
|
||||
)
|
||||
assert (
|
||||
f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql
|
||||
), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
|
||||
|
||||
|
||||
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
|
||||
|
|
@ -167,3 +168,39 @@ class TestPsycopg3CastAntipattern:
|
|||
f"{name} содержит psycopg v3 antipattern: {matches}. "
|
||||
f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md."
|
||||
)
|
||||
|
||||
|
||||
# ── velocity: конкуренты в радиусе (#2464-G) ─────────────────────────────────
|
||||
|
||||
|
||||
class TestVelocityCompetitorsSql:
|
||||
"""``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``.
|
||||
|
||||
Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G
|
||||
не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который
|
||||
существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE
|
||||
``latest_obj`` (FROM domrf_kn_objects, без алиаса) →
|
||||
``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08).
|
||||
|
||||
Почему это не падало в проде: единственный вызывающий
|
||||
(``analyze_parcel``) ``obj_class`` не передаёт → ветка мёртвая.
|
||||
Падало бы молча — исключение глотает ``except`` в ``compute_velocity``,
|
||||
и блок velocity просто исчезал бы из отчёта с одной строкой в логе.
|
||||
|
||||
Тест закрывает обе ветки, а не только ту, что сегодня исполняется.
|
||||
"""
|
||||
|
||||
@requires_test_db
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
"class_filter",
|
||||
["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"],
|
||||
ids=["no_class_filter", "with_class_filter"],
|
||||
)
|
||||
def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None:
|
||||
"""Обе подстановки шаблона парсятся и планируются против реальной схемы."""
|
||||
_explain_text(
|
||||
phantom_check_session,
|
||||
_COMPETITORS_SQL_TMPL.format(class_filter=class_filter),
|
||||
{"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -190,6 +190,70 @@ class TestGetFeaturesInBboxGrid:
|
|||
# 4 cells: 1 error + 3 good_feat → 1 unique feature
|
||||
assert any(f.feature_id == "feat-ok" for f in result)
|
||||
|
||||
# ── #2464-G: отказ слоя больше не маскируется пустым результатом ──────────
|
||||
|
||||
def _grid(self, side_effect: Any, *, grid_n: int = 2) -> list[NSPDFeature]:
|
||||
"""Прогнать grid-walk с подменённым wms_feature_info."""
|
||||
mock_client_instance = AsyncMock()
|
||||
mock_client_instance.wms_feature_info = AsyncMock(side_effect=side_effect)
|
||||
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
|
||||
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
|
||||
with patch(
|
||||
"app.scrapers.nspd_bulk_client.NSPDBulkClient",
|
||||
return_value=mock_client_instance,
|
||||
):
|
||||
return NSPDClient().get_features_in_bbox_grid(
|
||||
36328, self.BBOX, grid_n=grid_n, step_m=1.0
|
||||
)
|
||||
|
||||
def test_waf_403_aborts_grid_instead_of_empty_result(self) -> None:
|
||||
"""403 WAF на ячейке — бан IP, обход прерывается.
|
||||
|
||||
До #2464-G исключение глушилось и метод отдавал [] — «зон здесь нет»,
|
||||
неотличимое от честного пустого слоя. На проде это 124 дампа из 669
|
||||
с territorial_zones_count=0, у 50 из которых соседний legacy-слой
|
||||
данные всё-таки нашёл.
|
||||
"""
|
||||
from app.scrapers.nspd_bulk_client import NspdBulkWafError
|
||||
|
||||
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
|
||||
raise NspdBulkWafError("HTTP 403 WAF")
|
||||
|
||||
with pytest.raises(NspdBulkWafError):
|
||||
self._grid(_wms)
|
||||
|
||||
def test_all_cells_5xx_raises_instead_of_empty_result(self) -> None:
|
||||
"""Все ячейки упали с 5xx — слой лёг целиком, а не «пуст»."""
|
||||
from app.scrapers.nspd_bulk_client import NspdBulkServerError
|
||||
|
||||
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
|
||||
raise NspdBulkServerError("HTTP 500 ServiceException")
|
||||
|
||||
with pytest.raises(NspdBulkServerError):
|
||||
self._grid(_wms)
|
||||
|
||||
def test_partial_5xx_keeps_data_and_does_not_raise(self) -> None:
|
||||
"""Часть ячеек 5xx, часть прошла — отдаём собранное, не бросаем.
|
||||
|
||||
Контроль к двум тестам выше: правка НЕ превращает любую ошибку в отказ.
|
||||
Именно этот тест ловил бы обратную крайность — «чуть что, роняем обход».
|
||||
"""
|
||||
from app.scrapers.nspd_bulk_client import NspdBulkServerError
|
||||
|
||||
good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"})
|
||||
call_n: list[int] = [0]
|
||||
|
||||
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
|
||||
call_n[0] += 1
|
||||
if call_n[0] <= 2:
|
||||
raise NspdBulkServerError("HTTP 500 ServiceException")
|
||||
return [good_feat]
|
||||
|
||||
result = self._grid(_wms)
|
||||
assert any(
|
||||
f.feature_id == "feat-ok" for f in result
|
||||
), "успешные ячейки должны попасть в результат, даже если часть слоя упала"
|
||||
|
||||
def test_returns_nspd_feature_instances(self) -> None:
|
||||
"""Метод возвращает list[NSPDFeature] а не NSPDBulkFeature."""
|
||||
bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"})
|
||||
|
|
|
|||
|
|
@ -133,19 +133,33 @@ class TestFactorFromCount:
|
|||
assert "12.5 мес истории" in f_frac.note
|
||||
|
||||
|
||||
# ── _coverage_factor — покрытие domrf↔objective в % ────────────────────────────
|
||||
# ── _coverage_factor — покрытие рынка ценами Objective в % ─────────────────────
|
||||
|
||||
|
||||
class TestCoverageFactor:
|
||||
def test_low_coverage_percent_in_note(self) -> None:
|
||||
# Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15).
|
||||
# 2.5% покрытия → low, % в ноте (структурный §15).
|
||||
f = _coverage_factor(0.025)
|
||||
assert f.level == "low"
|
||||
assert f.value == 0.025
|
||||
assert "2.5%" in f.note
|
||||
# #1963: нота человеческая, без внутр.жаргона «domrf↔objective».
|
||||
assert "domrf" not in f.note
|
||||
assert "будущ" in f.note # говорит про будущее предложение/проекты
|
||||
|
||||
def test_note_names_what_is_actually_measured(self) -> None:
|
||||
"""#2464-H: нота называет ближние ЖК и цену, а не «будущие проекты».
|
||||
|
||||
Значение фактора ВСЕГДА приходит из `analyze.market_data_coverage_pct`
|
||||
= competitors_priced / competitors_total, то есть доля ближних ЖК (3 км)
|
||||
с ценой из Objective. Слот `supply_layers.domrf_coverage`, под который
|
||||
писалась старая формулировка, никто не заполняет.
|
||||
"""
|
||||
f = _coverage_factor(0.4)
|
||||
assert "ближних ЖК" in f.note, f.note
|
||||
assert "Objective" in f.note, f.note
|
||||
assert (
|
||||
"будущ" not in f.note
|
||||
), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
|
||||
|
||||
def test_high_coverage(self) -> None:
|
||||
f = _coverage_factor(0.75)
|
||||
|
|
@ -158,6 +172,14 @@ class TestCoverageFactor:
|
|||
assert "неизвестн" in f.note
|
||||
assert "domrf" not in f.note
|
||||
|
||||
def test_factor_key_unchanged(self) -> None:
|
||||
"""Ключ фактора остаётся `domrf_coverage` — его читает фронт.
|
||||
|
||||
Контроль к правке #2464-H: меняем только человеческий текст, не контракт
|
||||
(ForecastConfidenceBlock / ConfidencePanel маппят имя в RU-подпись).
|
||||
"""
|
||||
assert _coverage_factor(0.4).name == "domrf_coverage"
|
||||
|
||||
def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
|
||||
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
|
||||
# До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) —
|
||||
|
|
|
|||
|
|
@ -1393,6 +1393,65 @@ async def test_grid_walk_marks_layer_failed_when_all_cells_500() -> None:
|
|||
assert layer_failed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grid_walk_reraises_waf_instead_of_swallowing() -> None:
|
||||
"""#2464-A: 403 WAF прерывает обход, а не превращается в «cell не дошёл».
|
||||
|
||||
Контракт harvest_quarter (Raises:) обещает пробросить NspdBulkWafError, но
|
||||
голый `except Exception` в цикле ячеек его глотал. Прод-замер 13.08:
|
||||
23 job'а в cadastre_jobs, суммарно 50 WAF-блоков — и НИ ОДНОГО упавшего
|
||||
job'а. То есть бан ни разу не остановил сбор, как обещано.
|
||||
"""
|
||||
from app.scrapers.nspd_bulk_client import NspdBulkWafError
|
||||
from app.services.cadastre.bulk_harvest import _grid_walk_category
|
||||
|
||||
db = _mock_db_grid_bbox()
|
||||
client = AsyncMock()
|
||||
client.wms_feature_info = AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF"))
|
||||
|
||||
with pytest.raises(NspdBulkWafError):
|
||||
await _grid_walk_category(
|
||||
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grid_walk_reraises_rate_limit() -> None:
|
||||
"""#2464-A: исчерпанные ретраи — тоже не «пустой слой» (caller может retry)."""
|
||||
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError
|
||||
from app.services.cadastre.bulk_harvest import _grid_walk_category
|
||||
|
||||
db = _mock_db_grid_bbox()
|
||||
client = AsyncMock()
|
||||
client.wms_feature_info = AsyncMock(side_effect=NspdBulkRateLimitError("429"))
|
||||
|
||||
with pytest.raises(NspdBulkRateLimitError):
|
||||
await _grid_walk_category(
|
||||
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grid_walk_still_tolerates_network_error_per_cell() -> None:
|
||||
"""Контроль обратной крайности: сетевая ошибка ячейки обход НЕ роняет.
|
||||
|
||||
Зелёный с обеих сторон правки — проверяет, что #2464-A не превратил любое
|
||||
исключение в отказ квартала.
|
||||
"""
|
||||
from app.services.cadastre.bulk_harvest import _grid_walk_category
|
||||
|
||||
db = _mock_db_grid_bbox()
|
||||
client = AsyncMock()
|
||||
client.wms_feature_info = AsyncMock(side_effect=OSError("connection reset"))
|
||||
|
||||
upserted, requests, layer_failed = await _grid_walk_category(
|
||||
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
|
||||
)
|
||||
assert upserted == 0
|
||||
assert requests == 9
|
||||
assert layer_failed is False, "сетевые сбои НЕ должны поднимать layer_failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grid_walk_layer_not_failed_when_some_cells_ok() -> None:
|
||||
"""Issue #252: если хоть один cell прошёл — layer_failed=False (слой жив, просто пуст)."""
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ tests/test_layout_tz_pdf.py
|
|||
# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py.
|
||||
# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**.
|
||||
tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap
|
||||
tests/integration/test_analyze_parcels_sql.py::TestVelocityCompetitorsSql::test_explain_competitors
|
||||
tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary
|
||||
tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query
|
||||
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query
|
||||
|
|
|
|||
|
|
@ -184,10 +184,36 @@ def test_load_ps_35_220_parse_and_match() -> None:
|
|||
assert first["installed"] == 40.0
|
||||
assert first["reserve"] == 15.0
|
||||
assert first["district"] == "Ленинский"
|
||||
assert first["load_pct"] == 41.0 # доля 0.41 → 41.0%
|
||||
assert first["asof"] == date(2026, 6, 30)
|
||||
|
||||
|
||||
def test_load_ps_35_220_does_not_write_load_percent() -> None:
|
||||
"""#2464-B: степень загрузки НЕ уходит в UPDATE и не попадает в load_index.
|
||||
|
||||
Раньше значение колонки E писалось как
|
||||
`load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index —
|
||||
категориальная колонка ('open'|'limited'|'closed'|NULL,
|
||||
data/sql/180_connection_capacity.sql:35): число строкой фронт отбрасывает
|
||||
в «неизвестно» (classifyLoadIndex), а в power_summary.by_load_index
|
||||
появлялся бы бакет с именем вроде "41.0".
|
||||
|
||||
На проде не стреляло только потому, что load_index заполнен у всех строк
|
||||
(open 2741 / limited 346 / closed 329, NULL 0 — замер верификации 13.08),
|
||||
и COALESCE не проваливался.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
db = _FakeSession(scalar_value=None, rowcount=1)
|
||||
ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
|
||||
|
||||
# Комментарии из SQL убираем: слово load_index встречается в пояснении,
|
||||
# а проверять надо ИСПОЛНЯЕМЫЙ текст, а не прозу вокруг него.
|
||||
sql_code = "\n".join(line.split("--", 1)[0] for line in str(db.calls[0][0]).splitlines())
|
||||
assert "load_index" not in sql_code, sql_code
|
||||
for _sql, params in db.calls:
|
||||
assert "load_pct" not in params, params
|
||||
|
||||
|
||||
def test_load_ps_35_220_unmatched_counted() -> None:
|
||||
"""ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем."""
|
||||
from datetime import date
|
||||
|
|
|
|||
|
|
@ -9,3 +9,21 @@ def test_health() -> None:
|
|||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_head_ok_no_body() -> None:
|
||||
"""HEAD /health — то, что реально шлёт внешний uptime-monitor через Caddy
|
||||
(`handle /health { reverse_proxy backend:8000 }`, Caddyfile:60), не GET.
|
||||
|
||||
Starlette не добавляет HEAD автоматически к `@app.get()` (в отличие от
|
||||
низкоуровневого `Route(methods=["GET"])`) — без явного `@app.head()`
|
||||
прод-эндпоинт отдаёт 405 на HEAD.
|
||||
"""
|
||||
client = TestClient(app)
|
||||
response = client.head("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.content == b""
|
||||
# RFC 9110 §9.3.2 — заголовки представления (Content-Type) должны совпадать
|
||||
# с GET; Content-Length допустимо не совпадать (payload header field, MAY
|
||||
# быть опущен для HEAD).
|
||||
assert response.headers["content-type"] == "application/json"
|
||||
|
|
|
|||
44
backend/tests/test_skip_guard_loud.py
Normal file
44
backend/tests/test_skip_guard_loud.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Проверка, что сторож пропусков кричит под Actions (#2871)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import tests.conftest as ct
|
||||
|
||||
|
||||
def _run_guard(monkeypatch, capsys, *, ci: bool, observed: set[str]) -> str:
|
||||
monkeypatch.setattr(ct, "_observed_skips", observed)
|
||||
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
monkeypatch.delenv("CI", raising=False)
|
||||
if ci:
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
session = types.SimpleNamespace(exitstatus=0)
|
||||
ct.pytest_sessionfinish(session, 0)
|
||||
return capsys.readouterr().out, session.exitstatus
|
||||
|
||||
|
||||
def test_guard_emits_error_annotation_under_actions(monkeypatch, capsys) -> None:
|
||||
out, rc = _run_guard(monkeypatch, capsys, ci=True, observed={"tests/x.py::test_y"})
|
||||
assert "::error::" in out, "под Actions сторож обязан подниматься в аннотации"
|
||||
assert "tests/x.py::test_y" in out
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_guard_stays_quiet_locally(monkeypatch, capsys) -> None:
|
||||
"""Контроль: локально ::error:: не нужен, человеческое сообщение остаётся."""
|
||||
out, rc = _run_guard(monkeypatch, capsys, ci=False, observed={"tests/x.py::test_y"})
|
||||
assert "::error::" not in out
|
||||
assert "НЕУЧТЁННЫЙ ПРОПУСК" in out
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_guard_silent_when_all_skips_declared(monkeypatch, capsys) -> None:
|
||||
"""Контроль: без незадекларированных пропусков сторож молчит и не роняет."""
|
||||
monkeypatch.setattr(ct, "_observed_skips", set())
|
||||
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
|
||||
session = types.SimpleNamespace(exitstatus=0)
|
||||
ct.pytest_sessionfinish(session, 0)
|
||||
assert capsys.readouterr().out == ""
|
||||
assert session.exitstatus == 0
|
||||
|
|
@ -78,6 +78,16 @@ services:
|
|||
image: postgis/postgis:16-3.4
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
# #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ,
|
||||
# и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10:
|
||||
# база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один
|
||||
# параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает
|
||||
# в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это
|
||||
# и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices).
|
||||
# 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12.
|
||||
# tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса).
|
||||
# Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер.
|
||||
shm_size: 1gb
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
|
|
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -51,16 +51,24 @@ _PHONE_MAX_DIGITS = 15
|
|||
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
|
||||
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
|
||||
# только в audit-лог (#2497 TODO, теперь закрыт).
|
||||
_CONSENT_POLICY_VERSION = "2026-07"
|
||||
#
|
||||
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
|
||||
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
|
||||
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
|
||||
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
|
||||
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
|
||||
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
|
||||
_CONSENT_POLICY_VERSION = "2026-08-13"
|
||||
|
||||
# Снимок точного текста согласия, который видит пользователь при отправке лида.
|
||||
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
|
||||
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
|
||||
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
|
||||
# будут нести устаревший snapshot под новой version-меткой.
|
||||
# Снимок точного текста согласия, который видит пользователь при отправке лида
|
||||
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
|
||||
# с этой фразой). Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/
|
||||
# src/components/trade-in/v2/LeadForm.tsx) — если текст меняется, здесь нужно
|
||||
# поднять _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые
|
||||
# строки будут нести устаревший snapshot под новой version-меткой.
|
||||
_CONSENT_TEXT_SNAPSHOT = (
|
||||
"Согласен(-на) на обработку персональных данных в соответствии с "
|
||||
"Федеральным законом «О персональных данных» № 152-ФЗ"
|
||||
"Политикой обработки персональных данных"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import asyncio
|
|||
import calendar
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
|
||||
|
|
@ -25,6 +26,8 @@ from app.schemas.trade_in import (
|
|||
AnalogLot,
|
||||
AvitoImvSummary,
|
||||
CianPriceChangeStats,
|
||||
CoverageProbeInput,
|
||||
CoverageProbeResponse,
|
||||
DkpCorridor,
|
||||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
|
|
@ -2549,3 +2552,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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
|
|
@ -66,17 +66,28 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
|||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||||
# frontend), отдельного broker нет → мониторить нечего.
|
||||
if settings.glitchtip_dsn:
|
||||
from app.observability.sentry_scrub import redact_telegram_bot_token
|
||||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
|
||||
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
|
||||
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
|
||||
sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
|
||||
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) +
|
||||
RetryError fingerprint-стабилизация (glitchtip-noise) — см.
|
||||
app/tgbot_main.py._before_send (та же композиция без последнего шага,
|
||||
тот бот geocoder не зовёт). PII/token — тот же риск: теперь этот процесс
|
||||
тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||
include_local_variables=False ниже — первый рубеж защиты. RetryError —
|
||||
этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse),
|
||||
которые ретраят Nominatim через tenacity; см.
|
||||
sentry_scrub.stabilize_retry_error_fingerprint."""
|
||||
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
|
||||
if detokened is None:
|
||||
return None
|
||||
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
|
|
@ -210,6 +221,26 @@ def health() -> dict[str, str]:
|
|||
return {"status": "ok", "environment": settings.environment}
|
||||
|
||||
|
||||
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
|
||||
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
|
||||
# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только
|
||||
# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой
|
||||
# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам —
|
||||
# только pg_isready для postgres) — маршрут пока используется лишь тестами.
|
||||
# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder
|
||||
# (backend/app/main.py, за Caddyfile `handle /health`), не здесь.
|
||||
# media_type="application/json" — Content-Type совпадает с GET; Content-Length
|
||||
# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload)
|
||||
# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для
|
||||
# HEAD, требует совпадения только заголовков представления (Content-Type).
|
||||
# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это
|
||||
# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим
|
||||
# симметрично, чтобы схема двух бэкендов не разъезжалась.
|
||||
@app.head("/health", include_in_schema=False)
|
||||
def health_head() -> Response:
|
||||
return Response(status_code=200, media_type="application/json")
|
||||
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import re
|
|||
from typing import Any
|
||||
|
||||
from sentry_sdk.types import Event
|
||||
from tenacity import RetryError
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||
|
|
@ -76,6 +77,31 @@ _URL_SECRET_QUERY_RE = re.compile(
|
|||
)
|
||||
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
|
||||
|
||||
# httpx error-message URL query stabilization (GlitchTip-noise review round 2,
|
||||
# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`)
|
||||
# bakes the FULL request URL — INCLUDING query string — into the exception message:
|
||||
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
|
||||
# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response
|
||||
# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После
|
||||
# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError
|
||||
# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст
|
||||
# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть
|
||||
# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за
|
||||
# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто
|
||||
# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по
|
||||
# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue,
|
||||
# невозможные при группировке чисто по stacktrace/culprit).
|
||||
#
|
||||
# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных
|
||||
# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле
|
||||
# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет).
|
||||
# Режем query целиком (не только конкретные параметры) — host+path остаются
|
||||
# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт
|
||||
# regex (не трогает произвольные строки с `?`, см. тест
|
||||
# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched).
|
||||
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
|
||||
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
|
||||
|
||||
|
||||
def _scrub(obj: Any) -> None:
|
||||
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
|
||||
|
|
@ -90,32 +116,34 @@ def _scrub(obj: Any) -> None:
|
|||
_scrub(item)
|
||||
|
||||
|
||||
def _redact_url_secrets_inplace(obj: Any) -> None:
|
||||
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
|
||||
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
|
||||
КАЖДОЙ строке event — не ключ-based: секрет утекает через httpx span
|
||||
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
|
||||
request URL), а не только через известные PII-поля формы. Мутирует dict/list
|
||||
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`) —
|
||||
сохраняет identity верхнеуровневого `event`, на что опирается контракт
|
||||
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
|
||||
def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None:
|
||||
"""Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)`
|
||||
по КАЖДОЙ строке event (не ключ-based) — общий обход, переиспользуемый и для
|
||||
URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message
|
||||
URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают
|
||||
через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx
|
||||
содержит полный request URL), а не только через известные PII-поля формы.
|
||||
Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от
|
||||
`_redact_strings`) — сохраняет identity верхнеуровневого `event`, на что
|
||||
опирается контракт `scrub_pii_event`/`before_send` и существующие тесты
|
||||
(`out is event`).
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if isinstance(value, str):
|
||||
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||
redacted = pattern.sub(replacement, value)
|
||||
if redacted != value:
|
||||
obj[key] = redacted
|
||||
else:
|
||||
_redact_url_secrets_inplace(value)
|
||||
_regex_redact_inplace(value, pattern, replacement)
|
||||
elif isinstance(obj, list):
|
||||
for i, value in enumerate(obj):
|
||||
if isinstance(value, str):
|
||||
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||
redacted = pattern.sub(replacement, value)
|
||||
if redacted != value:
|
||||
obj[i] = redacted
|
||||
else:
|
||||
_redact_url_secrets_inplace(value)
|
||||
_regex_redact_inplace(value, pattern, replacement)
|
||||
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
|
||||
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
|
||||
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
|
||||
|
|
@ -123,16 +151,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None:
|
|||
|
||||
|
||||
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
|
||||
"""Redact consumer-PII + URL query-string секретов/nondeterministic-данных из
|
||||
error event перед отправкой.
|
||||
|
||||
Композиция (обе — in-place, сохраняют identity `event`): (1) ключ-based
|
||||
Композиция (все — in-place, сохраняют identity `event`): (1) ключ-based
|
||||
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
|
||||
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
|
||||
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
|
||||
changeip — #security-audit). Второй шаг не завязан на конкретные ключи полей —
|
||||
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
|
||||
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
|
||||
event (не None).
|
||||
changeip — #security-audit), (3) full-text regex-проход, стабилизирующий httpx
|
||||
error-message URL (`for url '...?...'`) — убирает переменный query string
|
||||
(адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на
|
||||
каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у
|
||||
`_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей —
|
||||
ловят секрет/переменные данные в frame locals, breadcrumb, exception message
|
||||
и т.д., где они могут оказаться независимо от
|
||||
include_local_variables/traces_sample_rate. Возвращает event (не None).
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
|
|
@ -141,7 +174,8 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
|||
_scrub(request.get("data"))
|
||||
_scrub(event.get("extra"))
|
||||
_scrub(event.get("contexts"))
|
||||
_redact_url_secrets_inplace(event)
|
||||
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
|
||||
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
|
||||
return event
|
||||
|
||||
|
||||
|
|
@ -174,3 +208,61 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
|
|||
if not isinstance(event, dict):
|
||||
return event
|
||||
return _redact_strings(event) # type: ignore[return-value]
|
||||
|
||||
|
||||
# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ────────
|
||||
# tenacity.RetryError.__str__() тащит repr() последнего Future
|
||||
# (`RetryError[<Future at 0x7f... state=finished raised HTTPStatusError>]`) —
|
||||
# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py
|
||||
# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim
|
||||
# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим
|
||||
# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ
|
||||
# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True`
|
||||
# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает
|
||||
# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само
|
||||
# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность
|
||||
# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в
|
||||
# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1).
|
||||
#
|
||||
# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через
|
||||
# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get`
|
||||
# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на
|
||||
# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query
|
||||
# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE`
|
||||
# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН
|
||||
# persistent issue per (culprit, класс исключения-причины) — culprit обязателен:
|
||||
# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр.
|
||||
# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError)
|
||||
# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2
|
||||
# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration`
|
||||
# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр.
|
||||
# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ
|
||||
# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от
|
||||
# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных
|
||||
# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде
|
||||
# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес,
|
||||
# IP, id объявления и т.п.), иначе проблема повторится в других терминах.
|
||||
def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None:
|
||||
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue per
|
||||
(источник, тип причины) — РАЗНЫЕ источники (geocoder / scraper_kit / будущий
|
||||
retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает.
|
||||
|
||||
Определяет тип exception через `hint["exc_info"]` (реальный объект
|
||||
исключения, тот же контракт что sentry_sdk передаёт в before_send) — не
|
||||
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
|
||||
`isinstance` (не сравнение `type(...).__name__` со строкой) — иначе любой
|
||||
посторонний класс с совпадающим именем ложно матчился бы, а подкласс
|
||||
`tenacity.RetryError` — промахивался бы. Не-RetryError события возвращает без
|
||||
изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает).
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
|
||||
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
|
||||
if not isinstance(exc_value, RetryError):
|
||||
return event
|
||||
cause = exc_value.__cause__ or exc_value.__context__
|
||||
cause_type = type(cause).__name__ if cause is not None else "Unknown"
|
||||
culprit = event.get("logger") or event.get("transaction") or "unknown"
|
||||
event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type]
|
||||
return event
|
||||
|
|
|
|||
|
|
@ -44,7 +44,24 @@ if settings.glitchtip_dsn:
|
|||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
from app.observability.sentry_scrub import (
|
||||
scrub_pii_event,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
|
||||
"""PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise).
|
||||
|
||||
Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни
|
||||
адресов за прогон) — @retry-декорированные Nominatim-хелперы
|
||||
(app/services/geocoder.py) на исчерпанных ретраях исторически плодили
|
||||
по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__()
|
||||
тащит нестабильный repr() Future). См. sentry_scrub docstring.
|
||||
"""
|
||||
scrubbed = scrub_pii_event(event, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return stabilize_retry_error_fingerprint(scrubbed, hint)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
|
|
@ -52,7 +69,7 @@ if settings.glitchtip_dsn:
|
|||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||
traces_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=scrub_pii_event,
|
||||
before_send=_before_send,
|
||||
integrations=[
|
||||
SqlalchemyIntegration(),
|
||||
HttpxIntegration(),
|
||||
|
|
|
|||
|
|
@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
|
|||
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
|
||||
median_ppm2: int # медиана ₽/м²
|
||||
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
|
||||
period_months: int # окно поиска сделок
|
||||
period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date)
|
||||
# #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/
|
||||
# median/high — включая city-wide widen, если сработал), НЕ по всей таблице.
|
||||
# period_months отвечает на «где искали», а не «насколько свежи сделки»: прод
|
||||
# 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок
|
||||
# даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы
|
||||
# враньём в пользу свежести именно для них.
|
||||
# Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день
|
||||
# квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974
|
||||
# сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно
|
||||
# {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи
|
||||
# на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад».
|
||||
# None = сделки без даты (в проде не встречается) — потребитель молчит.
|
||||
latest_deal_date: date | None = None
|
||||
|
||||
|
||||
class PriceTrendPoint(BaseModel):
|
||||
|
|
@ -210,6 +223,13 @@ class AggregatedEstimate(BaseModel):
|
|||
# UI (снизить доверие / переспросить город), НЕ персистится в БД
|
||||
# (ephemeral, только для текущего POST /estimate ответа).
|
||||
target_city_ambiguous: bool = False
|
||||
# #2626: True если координаты дал ПОСЛЕДНИЙ тир geocode() — fallback на `houses`
|
||||
# (см. `app.services.geocoder._local_houses_match`), а не Nominatim/geoportal/
|
||||
# cadastral. Значит адрес пользователя не совпал буквально (разговорное/усечённое
|
||||
# имя улицы или отсутствующий корпус), но был однозначно сопоставлен с домом из
|
||||
# скрейпленных листингов. Честный сигнал для UI («адрес уточнён автоматически»),
|
||||
# НЕ персистится в БД (ephemeral, как и `target_city_ambiguous`).
|
||||
target_address_refined: bool = False
|
||||
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||
# абсолютный timestamp самого свежего парсинга аналогов
|
||||
|
|
@ -736,3 +756,77 @@ class LocationIndexResponse(BaseModel):
|
|||
radius_m: int
|
||||
nearby_poi: list[NearbyPoiOut]
|
||||
poi_status: str
|
||||
|
||||
|
||||
class CoverageProbeInput(BaseModel):
|
||||
"""Вход POST /api/v1/trade-in/coverage (issue #2894) — бесплатная проба покрытия.
|
||||
|
||||
lat/lon — координаты, уже разрезолвленные фронтом (тот же контракт, что
|
||||
TradeInEstimateInput.lat/lon — geocode делает фронт/автокомплит, эта ручка
|
||||
сама НИКОГО не геокодирует). Город (и, соответственно, порог ok/thin) для
|
||||
ответа резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon — см.
|
||||
`app.api.v1.trade_in._resolve_coverage_city`.
|
||||
|
||||
city_hint — ИНФОРМАЦИОННОЕ поле, на результат НЕ влияет (повторная проверка
|
||||
#2894, 2026-08). Раньше оно участвовало в резолве города как фолбэк —
|
||||
убрано вместе с модой `listings.city`: оба источника ненадёжны (`city_hint`
|
||||
— непроверенный клиентский вход, `listings.city` — город свип-контекста
|
||||
скрейпера, не адреса объявления, см. комментарий в trade_in.py). Поле
|
||||
оставлено в схеме, потому что фронт его уже шлёт в других ручках того же
|
||||
автокомплита (см. TradeInEstimateInput.city_hint) — принимаем и молча
|
||||
игнорируем, чтобы не ронять запрос лишней 422.
|
||||
"""
|
||||
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
rooms: int = Field(ge=0, le=10) # 0 = студия
|
||||
area_m2: float = Field(gt=10, lt=500)
|
||||
city_hint: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class CoverageProbeResponse(BaseModel):
|
||||
"""Ответ POST /api/v1/trade-in/coverage.
|
||||
|
||||
НАМЕРЕННО без единой цены (ни медианы, ни диапазона, ни ₽/м²) — продуктовое
|
||||
правило issue #2894: бесплатный шаг доказывает, что похожие квартиры есть
|
||||
и как быстро они уходят, а саму цену продукт продаёт на платном шаге.
|
||||
|
||||
status:
|
||||
- "ok" — n_listings >= порога для этого города (зелёный/жёлтый список).
|
||||
- "thin" — когорта непустая, но n_listings < порога.
|
||||
- "not_covered" — город вне зелёного/жёлтого списка ИЛИ когорта пустая
|
||||
(n_listings == 0) — независимо от того, поддерживается город или нет.
|
||||
|
||||
median_listing_age_days — ЧЕСТНОЕ имя: возраст АКТИВНОГО объявления
|
||||
(days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная
|
||||
выборка (активные объявления ещё висят) всегда завышена относительно
|
||||
реального времени экспозиции проданных — не путать со «сроком продажи».
|
||||
ОГРАНИЧЕНИЕ ДАННЫХ (не продуктовое решение, см. coverage_probe docstring):
|
||||
days_on_market на проде заполнена практически только у источника yandex —
|
||||
возраст известен у меньшинства строк когорты. n_with_age ниже — честный
|
||||
счётчик, по скольким объявлениям посчитана медиана; при n_with_age < порога
|
||||
(COVERAGE_MIN_AGE_SAMPLES) median_listing_age_days принудительно null.
|
||||
|
||||
n_with_age — сколько объявлений когорты реально имеют известный
|
||||
(non-null, не-выброс) days_on_market и вошли в расчёт медианы. Фронт
|
||||
обязан иметь возможность не показывать median_listing_age_days при
|
||||
маленьком n_with_age — цифра "медиана" по 1-2 объявлениям не медиана.
|
||||
|
||||
threshold — n, начиная с которого статус переходит в "ok" для резолвленного
|
||||
города; 0 всегда, когда status == "not_covered" (порог неприменим — ни для
|
||||
города вне зелёного/жёлтого списка, ни для поддерживаемого города с пустой
|
||||
когортой), НЕ только для неподдерживаемого города.
|
||||
|
||||
city — резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon запроса (ближайший центроид из
|
||||
зелёного/жёлтого списка в пределах `COVERAGE_CITY_MATCH_RADIUS_KM`), не из
|
||||
`city_hint` и не из моды `listings.city` найденной когорты — см.
|
||||
`app.api.v1.trade_in._resolve_coverage_city`.
|
||||
"""
|
||||
|
||||
status: Literal["ok", "thin", "not_covered"]
|
||||
n_listings: int
|
||||
median_listing_age_days: int | None
|
||||
n_with_age: int
|
||||
radius_m: int
|
||||
city: str
|
||||
threshold: int
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/sugge
|
|||
_DADATA_TIMEOUT_S = 8.0
|
||||
_DADATA_SUGGEST_TIMEOUT_S = 5.0
|
||||
|
||||
# Троттлинг WARNING «услуга CLEAN выключена на аккаунте» (#dadata-403-noise) —
|
||||
# статичная конфигурация аккаунта, не транзиентный сбой. Первый раз за процесс
|
||||
# логируется на WARNING, дальше — DEBUG, чтобы не заливать логи одним и тем же
|
||||
# сообщением на каждый /estimate (было: logger.error на каждый запрос).
|
||||
_clean_disabled_warned = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DadataAddressResult:
|
||||
|
|
@ -172,15 +178,29 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
|||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
|
||||
#
|
||||
# Это НЕ сбой (аккаунт постоянно живёт с выключенной услугой, enrich_address уже
|
||||
# graceful-деградирует на suggest — см. ниже) — раньше это било logger.error на
|
||||
# КАЖДЫЙ пользовательский запрос (164 события/запрос-волна в проде), из-за чего
|
||||
# ERROR переставал значить «настоящий сбой». WARNING один раз за процесс (дальше —
|
||||
# DEBUG) сохраняет видимость причины без шума на каждый /estimate.
|
||||
if status == 403 and (
|
||||
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
|
||||
):
|
||||
logger.error(
|
||||
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
||||
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
||||
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
|
||||
body_preview,
|
||||
)
|
||||
global _clean_disabled_warned
|
||||
if not _clean_disabled_warned:
|
||||
logger.warning(
|
||||
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
||||
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
||||
"полагайся на suggest-fallback (enrich_address). Ответ: %r "
|
||||
"(повторы этого сообщения в рамках процесса логируются на DEBUG)",
|
||||
body_preview,
|
||||
)
|
||||
_clean_disabled_warned = True
|
||||
else:
|
||||
logger.debug(
|
||||
"dadata: HTTP 403 CLEAN disabled (уже предупреждено WARNING в этом процессе)"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"dadata: HTTP %d — auth/secret rejected. "
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ from scraper_kit.providers.cian.valuation import (
|
|||
estimate_via_cian_valuation,
|
||||
)
|
||||
from scraper_kit.providers.yandex.valuation import (
|
||||
ValuationHouseMeta,
|
||||
YandexValuationResult,
|
||||
YandexValuationScraper,
|
||||
)
|
||||
|
|
@ -488,6 +487,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
|
|||
return _REPAIR_COEF.get(repair_state, 1.0)
|
||||
|
||||
|
||||
# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
|
||||
_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
|
||||
|
||||
|
||||
def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
|
||||
"""#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
|
||||
|
||||
Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
|
||||
(`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
|
||||
к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
|
||||
и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента
|
||||
с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до
|
||||
×1.081, а сработавший blend возвращает половину этих −6% обратно вверх.
|
||||
|
||||
Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная
|
||||
эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
|
||||
применил к медиане, — чинится расхождение базисов, а не калибровка.
|
||||
|
||||
Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение).
|
||||
"""
|
||||
base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
|
||||
if base <= 0:
|
||||
return 1.0
|
||||
return _repair_coefficient(target_repair) / base
|
||||
|
||||
|
||||
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
|
||||
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
|
||||
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator
|
||||
|
|
@ -961,13 +986,13 @@ def _save_yandex_history_items(
|
|||
|
||||
Batch semantics: single try/except; on any failure the batch rolls back.
|
||||
|
||||
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
|
||||
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
|
||||
без единого объявления в истории, до записи панорамы не доходила — на проде это
|
||||
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
|
||||
переноса: match_or_create_house теперь вызывается и для таких страниц (может
|
||||
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
|
||||
остальных 90% — новых сущностей класс не появляется, появляется недостающая доля.
|
||||
#2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
|
||||
возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
|
||||
в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с
|
||||
историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
|
||||
«панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
|
||||
match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
|
||||
тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
|
||||
"""
|
||||
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
||||
# — stable across re-runs, distinguishes pages for different addresses.
|
||||
|
|
@ -1004,9 +1029,6 @@ def _save_yandex_history_items(
|
|||
result.address,
|
||||
)
|
||||
|
||||
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
|
||||
_save_yandex_house_panorama(db, house_id, result.house)
|
||||
|
||||
if not result.history_items:
|
||||
return 0
|
||||
|
||||
|
|
@ -1091,58 +1113,13 @@ def _save_yandex_history_items(
|
|||
return 0
|
||||
|
||||
|
||||
# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в
|
||||
# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но
|
||||
# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь —
|
||||
# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету.
|
||||
#
|
||||
# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text
|
||||
# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн,
|
||||
# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
|
||||
# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
|
||||
# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
|
||||
def _save_yandex_house_panorama(
|
||||
db: Session,
|
||||
house_id: int | None,
|
||||
meta: ValuationHouseMeta,
|
||||
) -> None:
|
||||
"""Пишет houses.has_panorama по разобранной мете yandex_valuation.
|
||||
|
||||
No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
|
||||
выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за
|
||||
справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
|
||||
только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
|
||||
"""
|
||||
if house_id is None:
|
||||
return
|
||||
if meta.year_built is None and meta.total_floors is None:
|
||||
logger.debug(
|
||||
"yandex_valuation: has_panorama не пишем для house_id=%s — "
|
||||
"страница не подтверждена (нет ни года, ни этажности)",
|
||||
house_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE houses
|
||||
SET has_panorama = CAST(:panorama AS boolean)
|
||||
WHERE id = CAST(:hid AS bigint)
|
||||
AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
|
||||
"""
|
||||
),
|
||||
{"hid": house_id, "panorama": meta.has_panorama},
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
|
||||
house_id,
|
||||
e,
|
||||
)
|
||||
db.rollback()
|
||||
# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
|
||||
# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
|
||||
# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
|
||||
# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
|
||||
# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
|
||||
# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
|
||||
# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
|
||||
|
||||
|
||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
|
||||
|
|
@ -1158,14 +1135,39 @@ def _fetch_house_imv_anchor(
|
|||
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
|
||||
|
||||
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
|
||||
скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
|
||||
house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
|
||||
запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
|
||||
самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
|
||||
house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress).
|
||||
скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
|
||||
WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
|
||||
None при любой ошибке / отсутствии house_id / пустой таблице — estimator
|
||||
продолжает на гео-tier'ах (no regress).
|
||||
|
||||
СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
|
||||
~2951 домов, fresh» — неправда по обоим пунктам, #2674):
|
||||
2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) —
|
||||
поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из
|
||||
них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после
|
||||
фикса параметров 2026-08-06, всего 47.
|
||||
Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
|
||||
и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
|
||||
объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
|
||||
(46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
|
||||
«панель» вместо «монолита»).
|
||||
|
||||
ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
|
||||
· Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка
|
||||
находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
|
||||
224 из 224, то есть просто выключил бы механизм.
|
||||
· Массовая переоценка — не чинит корень и упирается в очередь: 1628 из
|
||||
2633 домов уже в imv_status='ok', а батч берёт только pending/
|
||||
transient_error, где сейчас 7144 дома при темпе ~25/прогон.
|
||||
· Корень — не свежесть, а разные базисы ремонта; починено в #2677
|
||||
(`_anchor_repair_factor`), см. ниже.
|
||||
|
||||
Returns dict {recommended_price, lower_price, higher_price, market_count,
|
||||
rooms, area_m2} или None.
|
||||
rooms, area_m2, renovation_type} или None.
|
||||
|
||||
#2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
|
||||
Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
|
||||
(см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
|
||||
"""
|
||||
if target_house_id is None:
|
||||
return None
|
||||
|
|
@ -1175,7 +1177,7 @@ def _fetch_house_imv_anchor(
|
|||
text(
|
||||
"""
|
||||
SELECT recommended_price, lower_price, higher_price,
|
||||
market_count, rooms, area_m2
|
||||
market_count, rooms, area_m2, renovation_type
|
||||
FROM house_imv_evaluations
|
||||
WHERE house_id = CAST(:hid AS bigint)
|
||||
AND recommended_price > 0
|
||||
|
|
@ -1565,7 +1567,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
|
|||
"""#794: monthly {period_month: index_value} for region from sber_price_index.
|
||||
|
||||
Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error.
|
||||
#audit-5a: если latest месяц серии старее sber_index_max_age_days → warning.
|
||||
|
||||
#2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest
|
||||
периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по
|
||||
построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на
|
||||
закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых
|
||||
наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени —
|
||||
нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip
|
||||
(event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor,
|
||||
и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт),
|
||||
а не календарь.
|
||||
"""
|
||||
for dash in SBER_COEFF_DASHBOARDS:
|
||||
try:
|
||||
|
|
@ -1593,21 +1604,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
|
|||
series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
|
||||
if not series:
|
||||
continue
|
||||
# #audit-5a: data-age guard — предупреждаем о stale СберИндексе.
|
||||
latest = max(series)
|
||||
today = datetime.now(tz=UTC).date()
|
||||
age_days = (today - latest).days
|
||||
if age_days > settings.sber_index_max_age_days:
|
||||
logger.warning(
|
||||
"sber_index stale #audit-5a: latest=%s age=%d days"
|
||||
" (> sber_index_max_age_days=%d) region=%s dash=%s"
|
||||
" — time-adjustment may be outdated",
|
||||
latest.isoformat(),
|
||||
age_days,
|
||||
settings.sber_index_max_age_days,
|
||||
region,
|
||||
dash,
|
||||
)
|
||||
return series
|
||||
return {}
|
||||
|
||||
|
|
@ -1772,6 +1768,10 @@ def _fetch_dkp_corridor(
|
|||
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
|
||||
adjusted: list[float] = []
|
||||
factors_applied: list[float] = []
|
||||
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
|
||||
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
|
||||
# заявленную свежесть коридора, не участвуя в его границах.
|
||||
latest_deal: date | None = None
|
||||
for r in rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1783,6 +1783,8 @@ def _fetch_dkp_corridor(
|
|||
factor = _sber_time_factor(series, deal_month)
|
||||
adjusted.append(float(ppm2) * factor)
|
||||
factors_applied.append(factor)
|
||||
if dd is not None and (latest_deal is None or dd > latest_deal):
|
||||
latest_deal = dd
|
||||
ppm2_values = sorted(adjusted)
|
||||
|
||||
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
||||
|
|
@ -1835,6 +1837,7 @@ def _fetch_dkp_corridor(
|
|||
city_rows = []
|
||||
|
||||
city_adjusted: list[float] = []
|
||||
city_latest: date | None = None
|
||||
for r in city_rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1845,6 +1848,8 @@ def _fetch_dkp_corridor(
|
|||
deal_month = date(dd.year, dd.month, 1)
|
||||
factor = _sber_time_factor(series, deal_month)
|
||||
city_adjusted.append(float(ppm2) * factor)
|
||||
if dd is not None and (city_latest is None or dd > city_latest):
|
||||
city_latest = dd
|
||||
|
||||
if len(city_adjusted) > len(ppm2_values):
|
||||
logger.info(
|
||||
|
|
@ -1855,6 +1860,10 @@ def _fetch_dkp_corridor(
|
|||
city,
|
||||
)
|
||||
ppm2_values = sorted(city_adjusted)
|
||||
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||
# выборки, которую на витрине уже никто не видит.
|
||||
latest_deal = city_latest
|
||||
|
||||
if not ppm2_values:
|
||||
return None
|
||||
|
|
@ -1877,6 +1886,7 @@ def _fetch_dkp_corridor(
|
|||
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
||||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||
"period_months": period_months,
|
||||
"latest_deal_date": latest_deal,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3106,6 +3116,7 @@ def _price_from_inputs(
|
|||
anchor_total: int | None = None
|
||||
anchor_higher: int | None = None
|
||||
anchor_label: str | None = None
|
||||
anchor_repair_note: str = ""
|
||||
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
||||
anchor_total = int(imv_anchor["recommended_price"])
|
||||
anchor_higher = (
|
||||
|
|
@ -3124,6 +3135,22 @@ def _price_from_inputs(
|
|||
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
|
||||
),
|
||||
)
|
||||
# #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
|
||||
# не правим), а в blend уходит якорь, приведённый к базису ремонта target'а —
|
||||
# тому же, в котором median_price уже посчитан выше (repair_coef).
|
||||
_rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
|
||||
if _rep_k != 1.0:
|
||||
anchor_total = round(anchor_total * _rep_k)
|
||||
anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
|
||||
anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
|
||||
logger.info(
|
||||
"imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
|
||||
" → anchor %d",
|
||||
imv_anchor.get("renovation_type"),
|
||||
repair_state,
|
||||
_rep_k,
|
||||
anchor_total,
|
||||
)
|
||||
elif imv_eval is not None and imv_eval.recommended_price:
|
||||
anchor_total = int(imv_eval.recommended_price)
|
||||
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
|
||||
|
|
@ -3173,8 +3200,8 @@ def _price_from_inputs(
|
|||
median_price = new_median
|
||||
median_ppm2 = new_ppm2
|
||||
explanation = (explanation or "") + (
|
||||
f" Оценка скорректирована по {anchor_label} "
|
||||
f"({anchor_used / 1_000_000:.1f} млн ₽)."
|
||||
f" Оценка скорректирована по {anchor_label}"
|
||||
f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
|
||||
)
|
||||
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
|
||||
# Диапазон расширяем даже если медиану не двигали.
|
||||
|
|
@ -4735,6 +4762,7 @@ async def estimate_quality(
|
|||
target_lat=geo.lat,
|
||||
target_lon=geo.lon,
|
||||
target_city_ambiguous=geo.city_ambiguous,
|
||||
target_address_refined=geo.address_refined,
|
||||
sources_used=sources_used,
|
||||
data_freshness_minutes=freshness_min,
|
||||
last_scraped_at=last_scraped_at,
|
||||
|
|
|
|||
|
|
@ -238,6 +238,11 @@ _FS_MD = "10.5pt" # базовый текст (body), значения в та
|
|||
_FS_LG = "13pt" # заголовки страниц (h2, PT Serif)
|
||||
_FS_XL = "16pt" # главный заголовок cover (h1, PT Serif)
|
||||
_FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок)
|
||||
# Намеренное исключение из 6-шаговой шкалы: running-footer — @page margin-box с
|
||||
# фиксированной высотой (19mm ≈ 53.9pt), делить с mono-мета-строкой/wordmark
|
||||
# практически нечем (см. _page_footer). 135-ФЗ дисклеймер (Блок 4.2) должен
|
||||
# влезать в ~380-450 симв. на каждой странице без пятой пустой страницы.
|
||||
_FS_XXS = "5pt" # ТОЛЬКО 135-ФЗ футер-дисклеймер (_page_footer) — не переиспользовать
|
||||
|
||||
|
||||
# ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ──────────────
|
||||
|
|
@ -528,6 +533,20 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
|
|||
)
|
||||
|
||||
|
||||
# Блок 4.2 юр-требований: должен печататься в подвале КАЖДОЙ страницы отчёта
|
||||
# (не только cover). Текст утверждён владельцем продукта дословно — не менять
|
||||
# формулировку без явного запроса. Заведён как модульная константа (не inline
|
||||
# в _page_footer), чтобы не расползалась по нескольким билдерам страниц.
|
||||
_PDF_135FZ_FOOTER_NOTICE = (
|
||||
"Документ содержит индикативный (ориентировочный) расчёт стоимости объекта, "
|
||||
"сформированный автоматически сервисом «МЕРА». Не является отчётом об оценке "
|
||||
"по Федеральному закону № 135-ФЗ и не имеет установленной этим законом "
|
||||
"юридической силы. Не предназначен для использования при ипотечном "
|
||||
"кредитовании, в судебных разбирательствах, нотариальных действиях и иных "
|
||||
"случаях, где законом предусмотрено обязательное проведение независимой оценки."
|
||||
)
|
||||
|
||||
|
||||
def _page_footer(
|
||||
brand, # type: ignore[no-untyped-def]
|
||||
report_num: str,
|
||||
|
|
@ -542,7 +561,10 @@ def _page_footer(
|
|||
|
||||
строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная
|
||||
линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ
|
||||
хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь
|
||||
хардкод «МЕРА», white-label остаётся рабочим); строка 3 — 135-ФЗ дисклеймер
|
||||
(Блок 4.2, _PDF_135FZ_FOOTER_NOTICE) — печатается на КАЖДОЙ странице, т.к.
|
||||
footer рендерится один раз как running @page margin-box (см. вызов в
|
||||
generate_trade_in_pdf), а не per-page. Номер версии продукта здесь
|
||||
НЕ дублируется — единственное место вывода версии в PDF — running-header
|
||||
(_page_header → product_version_line()); раньше рядом с wordmark висел
|
||||
decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной
|
||||
|
|
@ -552,6 +574,17 @@ def _page_footer(
|
|||
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
|
||||
удалён — вынесен приглушённой строкой НАД HUD-баром, чтобы не терять
|
||||
полезную для печатного многостраничного отчёта информацию.
|
||||
|
||||
#footer-height-budget (2026-08-14, Блок 4.2): @bottom-center margin-box
|
||||
высотой = page margin-bottom (см. _build_css). Добавление 135-ФЗ текста
|
||||
(~440 симв.) потребовало И сжать существующий HUD-хром (margin-top
|
||||
6→4pt, padding-top 8→6pt, line-height мета/wordmark строк 1.35→1.15,
|
||||
градиент-разделитель margin 6pt 0→3pt 0 — экономия ~13pt), И минимально
|
||||
поднять @page margin-bottom (19mm→21mm, +2mm/+5.67pt) — сжатия одного
|
||||
подвала было недостаточно без деградации до нечитаемого. Риск: margin-bottom
|
||||
режет тело КАЖДОЙ из 4 страниц → потенциальный откат к 5-й почти пустой
|
||||
странице (регрессия, чинившаяся в 42a50cf8) — реальным рендером
|
||||
(WeasyPrint/Pango, недоступен на Windows-деве) не подтверждено, см. PR.
|
||||
"""
|
||||
note_html = ""
|
||||
if page_note:
|
||||
|
|
@ -583,18 +616,18 @@ def _page_footer(
|
|||
# тела страницы) и был источником сложности; заменён на простую тонкую
|
||||
# градиентную линию-разделитель между строками meta/wordmark.
|
||||
return f"""
|
||||
<div style="margin-top:6pt;">
|
||||
<div style="margin-top:4pt;">
|
||||
{note_html}
|
||||
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
|
||||
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:6pt;
|
||||
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
|
||||
color:{_MUTED_2};">
|
||||
color:{_MUTED_2};line-height:1.15;">
|
||||
<div style="white-space:nowrap;overflow:hidden;">
|
||||
ОТЧЁТ <span style="color:{_MUTED};">{_html.escape(report_num)}</span>
|
||||
<span style="margin-left:16pt;">ДАТА
|
||||
<span style="color:{_MUTED};">{report_date.strftime("%d.%m.%Y")}</span></span>
|
||||
{valid_until_html}
|
||||
</div>
|
||||
<div style="height:1pt;margin:6pt 0;background:linear-gradient(90deg,
|
||||
<div style="height:1pt;margin:3pt 0;background:linear-gradient(90deg,
|
||||
transparent,{_LINE_DOTTED} 15%,{_ACCENT} 50%,{_LINE_DOTTED} 85%,
|
||||
transparent);"></div>
|
||||
<div style="display:flex;align-items:center;gap:7pt;min-width:0;">
|
||||
|
|
@ -606,6 +639,11 @@ def _page_footer(
|
|||
{_html.escape(brand.name).upper()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:3pt;font-family:'PT Sans','DejaVu Sans',sans-serif;
|
||||
font-size:{_FS_XXS};line-height:1.15;color:{_MUTED_2};
|
||||
overflow-wrap:anywhere;">
|
||||
{_html.escape(_PDF_135FZ_FOOTER_NOTICE)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -1516,11 +1554,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
|
|||
|
||||
# ── Page 3: Deals ────────────────────────────────────────────────────────────
|
||||
|
||||
_ROMAN_QUARTER = ("I", "II", "III", "IV")
|
||||
|
||||
|
||||
def deals_as_of_label(estimate: AggregatedEstimate) -> str | None:
|
||||
"""#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки.
|
||||
|
||||
Раньше страница печатала «Период сделок: 08.2025 – 08.2026» — окно ПОИСКА,
|
||||
посчитанное как `today - period_months*30 … today`. Правым концом оно обещало
|
||||
сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде
|
||||
(замер 2026-08-12) — I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно
|
||||
по тем сделкам, из которых страница строит диапазон и таблицу примеров.
|
||||
|
||||
Гранулярность — квартал: rosreestr пишет deal_date = первый день квартала
|
||||
(#1995, ровно то, что помечает AnalogLot.date_precision == "quarter").
|
||||
Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания —
|
||||
сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока
|
||||
такого нет) подписывается месяцем.
|
||||
|
||||
None — сделок нет либо ни у одной нет даты: подписывать нечего.
|
||||
"""
|
||||
dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date]
|
||||
if not dated:
|
||||
return None
|
||||
newest, precision = max(dated, key=lambda p: p[0])
|
||||
if precision == "day":
|
||||
return f"по {newest.strftime('%m.%Y')}"
|
||||
return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}"
|
||||
|
||||
|
||||
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
n_deals = len(estimate.actual_deals)
|
||||
today = dt.date.today()
|
||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
||||
# #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
|
||||
# Реальная граница — as-of по показанным сделкам; окна поиска на странице
|
||||
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
|
||||
deals_as_of = deals_as_of_label(estimate)
|
||||
|
||||
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||||
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
||||
|
|
@ -1597,9 +1665,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
|
||||
<tr><td style="padding:4pt 0;">Период сделок</td>
|
||||
<td class="bold" style="text-align:right;">
|
||||
{_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}</td></tr>
|
||||
{f'''<tr><td style="padding:4pt 0;">Сделки</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
|
||||
if deals_as_of else ""}
|
||||
</table>
|
||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||||
|
|
@ -1932,7 +2000,11 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
|
|||
}}
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 25mm 18mm 19mm 18mm;
|
||||
/* bottom 19mm→21mm (#footer-height-budget, Блок 4.2): +2mm — минимум,
|
||||
которого не хватило внутри @bottom-center margin-box (высота margin-box
|
||||
= margin-bottom) даже после сжатия HUD-хрома _page_footer под 135-ФЗ
|
||||
дисклеймер на каждой странице. См. арифметику в _page_footer(). */
|
||||
margin: 25mm 18mm 21mm 18mm;
|
||||
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
|
||||
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,22 @@ class GeocodeResult:
|
|||
# результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог
|
||||
# понизить confidence / переспросить город у пользователя. См. `_resolve_city_for_geocode`.
|
||||
city_ambiguous: bool = False
|
||||
# #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses`
|
||||
# (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/
|
||||
# cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы
|
||||
# («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует
|
||||
# корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по
|
||||
# нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён
|
||||
# автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`.
|
||||
# Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный
|
||||
# источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4),
|
||||
# поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого
|
||||
# адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) —
|
||||
# если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined`
|
||||
# вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см.
|
||||
# `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не
|
||||
# из кэша).
|
||||
address_refined: bool = False
|
||||
|
||||
|
||||
# ── EKB bounding boxes ───────────────────────────────────────────────────────
|
||||
|
|
@ -726,7 +742,23 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
|||
return oblast_fallback
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
|
||||
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
|
||||
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
|
||||
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
|
||||
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
|
||||
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
|
||||
# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError
|
||||
# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query
|
||||
# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на
|
||||
# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py
|
||||
# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет
|
||||
# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review
|
||||
# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion).
|
||||
# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай
|
||||
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
|
||||
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||||
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||
|
||||
|
|
@ -925,7 +957,8 @@ async def _nominatim_query_city_aware(
|
|||
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||||
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
|
||||
async def _nominatim_suggest(
|
||||
query: str, limit: int = 8, city_hint: str | None = None
|
||||
) -> list[GeocodeSuggestion]:
|
||||
|
|
@ -1188,12 +1221,13 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
|||
|
||||
ВНИМАНИЕ, цепочки различаются — не путать:
|
||||
* `geocode()` : geoportal → cadastral → `_cadastral_forward_sync`
|
||||
→ Nominatim → None. Тира DaData тут НЕТ.
|
||||
→ Nominatim → `_local_houses_match` (#2626, houses-фолбэк)
|
||||
→ None. Тира DaData тут НЕТ.
|
||||
* `suggest()` : cadastral → DaData → Nominatim (единственный вызов
|
||||
`_dadata_suggest`).
|
||||
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
|
||||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None —
|
||||
оценка не строится. Это сознательный выбор: честный отказ вместо
|
||||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку,
|
||||
даёт None — оценка не строится. Это сознательный выбор: честный отказ вместо
|
||||
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет —
|
||||
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` →
|
||||
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
|
||||
|
|
@ -1310,6 +1344,283 @@ def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
|||
)
|
||||
|
||||
|
||||
# ── Local `houses` fallback (#2626) — последний тир geocode() ───────────────
|
||||
# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов,
|
||||
# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses):
|
||||
# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1»
|
||||
# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»);
|
||||
# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР),
|
||||
# пользователь пишет только последнее слово имени.
|
||||
# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с
|
||||
# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не
|
||||
# резолвят, а houses чаще содержит именно то написание, которым реально пользуются
|
||||
# люди (агрегировано из объявлений, а не из официального ГАР).
|
||||
#
|
||||
# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/
|
||||
# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата
|
||||
# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`.
|
||||
# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем
|
||||
# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house`
|
||||
# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже).
|
||||
_LOCAL_HOUSE_TOKEN_RE = re.compile(
|
||||
r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _norm_local_house(raw: str) -> str:
|
||||
"""Канон номера дома для houses-фолбэка.
|
||||
|
||||
«49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»; «35А» → «35а».
|
||||
"""
|
||||
s = raw.strip().lower()
|
||||
s = re.sub(r"\s+", "", s)
|
||||
s = re.sub(r"корпус|корп\.?", "к", s)
|
||||
s = re.sub(r"-(к\d+)", r"\1", s)
|
||||
s = re.sub(r"-([а-яё])$", r"\1", s)
|
||||
return s
|
||||
|
||||
|
||||
# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж.
|
||||
# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут
|
||||
# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE`
|
||||
# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки
|
||||
# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер
|
||||
# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая,
|
||||
# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15».
|
||||
_RE_LOCAL_APT_TAIL = re.compile(
|
||||
r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_local_house_token(address: str) -> str | None:
|
||||
"""Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов,
|
||||
которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у
|
||||
`_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение — номер дома в русском
|
||||
адресе почти всегда в хвосте строки. None, если цифр нет вовсе.
|
||||
|
||||
Квартирный/этажный/подъездный хвост зачищается ДО поиска номера
|
||||
(`_RE_LOCAL_APT_TAIL`) — иначе «последнее число в строке» это номер
|
||||
квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`).
|
||||
"""
|
||||
s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.")
|
||||
if not s:
|
||||
return None
|
||||
s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.")
|
||||
if not s:
|
||||
return None
|
||||
matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s))
|
||||
if not matches:
|
||||
return None
|
||||
return _norm_local_house(matches[-1].group(1))
|
||||
|
||||
|
||||
# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением
|
||||
# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого
|
||||
# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё
|
||||
# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ
|
||||
# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа.
|
||||
# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом
|
||||
# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b`
|
||||
# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) —
|
||||
# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .»)
|
||||
# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses).
|
||||
_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _clean_local_house_street(segment: str) -> str:
|
||||
"""«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» /
|
||||
«начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы.
|
||||
|
||||
Общая нормализация и для запроса пользователя (уже typeless из
|
||||
`_parse_street_house`, но повторный проход — no-op), и для `houses.address`.
|
||||
"""
|
||||
s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower())
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _row_local_house(address: str) -> tuple[str, str] | None:
|
||||
"""Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm).
|
||||
|
||||
Номер дома — ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1»,
|
||||
«X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки) —
|
||||
покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор
|
||||
после номера отбрасывается). Известный неполный случай (не встретился в
|
||||
выборке): номер дома БЕЗ запятой перед ним — вернёт None, строка просто не
|
||||
станет кандидатом (не ложный матч).
|
||||
"""
|
||||
segments = [s.strip() for s in address.split(",") if s.strip()]
|
||||
if len(segments) < 2:
|
||||
return None
|
||||
m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1])
|
||||
if not m:
|
||||
return None
|
||||
house_norm = _norm_local_house(m.group(1))
|
||||
street_norm = _clean_local_house_street(" ".join(segments[:-1]))
|
||||
if not street_norm or not house_norm:
|
||||
return None
|
||||
return street_norm, house_norm
|
||||
|
||||
|
||||
def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool:
|
||||
"""True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы
|
||||
в `houses` — «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя),
|
||||
регистронезависимо. Точное равенство тоже проходит (частый случай — короткие
|
||||
однословные улицы, «Малышева» == «Малышева»)."""
|
||||
return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm)
|
||||
|
||||
|
||||
# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер,
|
||||
# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) —
|
||||
# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у
|
||||
# нас в вводе просто нет данных, какой именно».
|
||||
_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$")
|
||||
|
||||
|
||||
def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
|
||||
"""Последний локальный тир `geocode()` (#2626) — fallback на `houses`
|
||||
(скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW).
|
||||
|
||||
Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата.
|
||||
Допущения, все defensive (при неоднозначности — None, не гадаем):
|
||||
|
||||
1. Улица матчится «по хвосту» (`_street_tail_matches`) — ловит расхождение
|
||||
разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в
|
||||
houses («Начдива Онуфриева»).
|
||||
2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox
|
||||
(`is_within_ekb_bbox_wide`) — `houses` НЕ ЕКБ-only реестр (в отличие от
|
||||
geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ,
|
||||
местами вплоть до другого региона (#2626 review R2 #2 — прод-пример
|
||||
«улица Маяковского, 7» в houses это Серов, а не запрошенный
|
||||
Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС
|
||||
пользователя, не страхует от грязной строки-источника.
|
||||
3. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый
|
||||
случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но
|
||||
ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же
|
||||
номера («24к2», «24/2» и т.п.) — иначе «к1» такая же угадайка, как и
|
||||
любой другой корпус, и реальные дома могут быть в 250-400м друг от друга
|
||||
(#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3
|
||||
— три разных здания).
|
||||
4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым
|
||||
координатам — разные source-строки ОДНОГО дома не в счёт), возвращает
|
||||
None — угадывать нельзя.
|
||||
|
||||
SQL — дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на
|
||||
`houses.address`, но тир последний и редкий — не на каждый запрос) с
|
||||
детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо
|
||||
выбирал бы, какая из двух ~идентичных source-строк станет ответом —
|
||||
#2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство
|
||||
номера) — в Python, что и делает её юнит-тестируемой без реальной БД
|
||||
(см. `test_geocoder_local_houses_fallback.py`).
|
||||
|
||||
Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной
|
||||
(см. `geocode()`) — `houses`-координаты из скрейпленных объявлений менее
|
||||
надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный
|
||||
(#2626 review R2 #4).
|
||||
"""
|
||||
query_street_norm = _clean_local_house_street(street)
|
||||
if not query_street_norm:
|
||||
return None
|
||||
query_house_norm = _norm_local_house(house)
|
||||
if not query_house_norm:
|
||||
return None
|
||||
last_word = query_street_norm.split()[-1]
|
||||
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT address, lat, lon
|
||||
FROM houses
|
||||
WHERE address ILIKE CAST('%' || :w || '%' AS text)
|
||||
AND lat IS NOT NULL AND lon IS NOT NULL
|
||||
ORDER BY address, id
|
||||
"""),
|
||||
{"w": last_word},
|
||||
).fetchall()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"local houses fallback query failed for street=%r house=%r",
|
||||
street,
|
||||
house,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
# Street-tail + bbox фильтр — один проход, дальше переиспользуется и для
|
||||
# точного совпадения, и для corpus-1 догадки, и для sibling-guard.
|
||||
street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr)
|
||||
for r in rows:
|
||||
parsed = _row_local_house(str(r.address or ""))
|
||||
if parsed is None:
|
||||
continue
|
||||
row_street_norm, row_house_norm = parsed
|
||||
if not _street_tail_matches(row_street_norm, query_street_norm):
|
||||
continue
|
||||
lat, lon = float(r.lat), float(r.lon)
|
||||
if not is_within_ekb_bbox_wide(lat, lon):
|
||||
continue
|
||||
street_rows.append((row_house_norm, lat, lon, str(r.address)))
|
||||
|
||||
def _candidates(house_norm: str) -> list[tuple[str, float, float]]:
|
||||
out: list[tuple[str, float, float]] = []
|
||||
seen_coords: set[tuple[float, float]] = set()
|
||||
for row_house_norm, lat, lon, addr in street_rows:
|
||||
if row_house_norm != house_norm:
|
||||
continue
|
||||
coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников
|
||||
if coord_key in seen_coords:
|
||||
continue
|
||||
seen_coords.add(coord_key)
|
||||
out.append((addr, lat, lon))
|
||||
return out
|
||||
|
||||
exact = _candidates(query_house_norm)
|
||||
if len(exact) == 1:
|
||||
addr, lat, lon = exact[0]
|
||||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||||
if len(exact) > 1:
|
||||
logger.info(
|
||||
"local houses fallback: %d неоднозначных кандидата для %r %r — skip",
|
||||
len(exact),
|
||||
street,
|
||||
house,
|
||||
)
|
||||
return None
|
||||
|
||||
# Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО
|
||||
# если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»).
|
||||
if query_house_norm.isdigit():
|
||||
corpus1 = f"{query_house_norm}к1"
|
||||
siblings = {
|
||||
row_house_norm
|
||||
for row_house_norm, _lat, _lon, _addr in street_rows
|
||||
if row_house_norm != corpus1
|
||||
and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None
|
||||
and m.group(1) == query_house_norm
|
||||
}
|
||||
if siblings:
|
||||
logger.info(
|
||||
"local houses fallback: корпус-1 %r неоднозначен — есть другие "
|
||||
"корпуса/дроби %s — skip",
|
||||
corpus1,
|
||||
sorted(siblings),
|
||||
)
|
||||
return None
|
||||
guessed = _candidates(corpus1)
|
||||
if len(guessed) == 1:
|
||||
addr, lat, lon = guessed[0]
|
||||
logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr)
|
||||
return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house")
|
||||
if len(guessed) > 1:
|
||||
logger.info(
|
||||
"local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip",
|
||||
corpus1,
|
||||
len(guessed),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
|
||||
"""Reverse lookup via gendesign_cad_buildings FDW.
|
||||
|
||||
|
|
@ -1586,6 +1897,45 @@ async def _geocode_resolve(
|
|||
except Exception:
|
||||
logger.exception("nominatim geocoder failed")
|
||||
|
||||
# 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None.
|
||||
# 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом
|
||||
# был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не
|
||||
# резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий
|
||||
# в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у
|
||||
# geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск
|
||||
# коллизии улица+дом с другим городом региона, что и мотивировал #2582);
|
||||
# координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри
|
||||
# `_local_houses_match` (гейт здесь фильтрует только запрос пользователя,
|
||||
# не грязь в самой таблице — #2626 review R2 #2).
|
||||
if use_local_ekb and parsed is not None:
|
||||
local_street, _parsed_house = parsed
|
||||
local_house = _extract_local_house_token(address) or _parsed_house
|
||||
hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house)
|
||||
if hit is not None:
|
||||
result = GeocodeResult(
|
||||
lat=hit.lat,
|
||||
lon=hit.lon,
|
||||
full_address=hit.full_address,
|
||||
provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal
|
||||
confidence="exact",
|
||||
city_ambiguous=city_ambiguous,
|
||||
address_refined=True,
|
||||
)
|
||||
# НЕ кэшируем: houses-координаты (скрейпленные листинги) менее
|
||||
# надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый
|
||||
# и локальный — кэш только продлевал бы жизнь возможной ошибке
|
||||
# источника (#2626 review R2 #4). Побочный эффект: `address_refined`
|
||||
# переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не
|
||||
# только первый (было известным пределом до этого фикса).
|
||||
logger.info(
|
||||
"geocode local houses fallback: %s → (%.5f, %.5f) [%s]",
|
||||
addr_norm,
|
||||
result.lat,
|
||||
result.lon,
|
||||
hit.full_address,
|
||||
)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -1654,7 +2004,8 @@ def _format_reverse_address(addr: dict) -> str | None:
|
|||
return ", ".join(parts)
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
|
||||
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
|
||||
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
|
|||
"commission_year": ["cian_serp", "yandex_realty_nb"],
|
||||
"commission_month": ["yandex_realty_nb"], # raw RU month name
|
||||
"developer_name": ["cian", "yandex_realty_nb"],
|
||||
"has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag
|
||||
# #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259).
|
||||
# В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала,
|
||||
# источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда,
|
||||
# потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на
|
||||
# проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML).
|
||||
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
|
||||
# Yandex Valuation enrichment (existing house attrs)
|
||||
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ async def _job_deactivate_stale(
|
|||
) -> None:
|
||||
from app.core.config import settings as _settings
|
||||
from app.tasks.deactivate_stale_avito import (
|
||||
CAP_MULT,
|
||||
DEFAULT_MIN_CONFIRMATIONS,
|
||||
DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
deactivate_stale_listings,
|
||||
|
|
@ -236,6 +237,16 @@ async def _job_deactivate_stale(
|
|||
revisit_floor_quantile: float = params.get(
|
||||
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
|
||||
)
|
||||
# Пустой (NULL) listing_segment -- легаси-строки до миграции 011 + жертвы
|
||||
# отсутствующего COALESCE в ON CONFLICT (base.py upsert никогда не перезаписывает
|
||||
# listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не
|
||||
# элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py.
|
||||
null_segment_only: bool = params.get("null_segment_only", False)
|
||||
# Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель,
|
||||
# а не голая константа: источник с непропорционально длинным хвостом переобхода
|
||||
# относительно своего ttl_days переопределяет его через default_params (ключ
|
||||
# "cap_mult"), не трогая дефолт для остальных источников.
|
||||
cap_mult: float = params.get("cap_mult", CAP_MULT)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
|
|
@ -249,6 +260,8 @@ async def _job_deactivate_stale(
|
|||
staleness_column=staleness_column,
|
||||
min_confirmations=min_confirmations,
|
||||
revisit_floor_quantile=revisit_floor_quantile,
|
||||
null_segment_only=null_segment_only,
|
||||
cap_mult=cap_mult,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,19 @@ reap_stale_leases) — она рассчитана на долгоживущие
|
|||
и без мутаций.
|
||||
|
||||
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
|
||||
(тот же карантинный порог, что у acquire), нет активной строки в
|
||||
scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов — меньший consecutive_fails,
|
||||
при равенстве — более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку:
|
||||
это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер.
|
||||
(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now())
|
||||
в scrape_proxy_source_bans для ЭТОГО source — это по-прежнему жёсткий фильтр, не
|
||||
влияющий на порядок. Порядок среди прошедших фильтр (замер 2026-08-10, #2825 доп.):
|
||||
сначала узлы БЕЗ ИСТОРИИ банов по этому source, затем по возрастанию ban_count —
|
||||
даже если сама строка бана истекла (banned_until <= now()), её ban_count всё равно
|
||||
учитывается, ведь строка НЕ удаляется сразу (purge только через 7 суток чистой
|
||||
работы, см. 210-я миграция) и остаётся памятью «этот узел здесь уже банился N раз».
|
||||
Внутри равного ban_count — прежние критерии без изменений: меньший consecutive_fails,
|
||||
при равенстве — более свежий last_ok_at (NULLS LAST). Так хронически банящийся узел
|
||||
(здоров по health-check, но регулярно ловит 403 от конкретной площадки) не всплывает
|
||||
первым сразу после истечения TTL — свежий healthcheck сам по себе больше не решает.
|
||||
Не изобретаем ротацию/балансировку: это резолвер «дай рабочий прокси прямо сейчас»,
|
||||
не lease-менеджер.
|
||||
|
||||
FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный
|
||||
`SCRAPER_PROXY_URL` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет":
|
||||
|
|
@ -39,7 +48,9 @@ FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review
|
|||
env в обход учёта банов.
|
||||
|
||||
НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url
|
||||
несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback —
|
||||
несёт логин/пароль, в лог никогда не идёт целиком), id узла и ban_count по этому
|
||||
source (0, если истории нет) — чтобы по логу было видно, что узел с историей банов
|
||||
выбран осознанно (пул исчерпан по чистым узлам), а не тихо; при legit-fallback —
|
||||
warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой
|
||||
banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы
|
||||
их нельзя было спутать в логах/алертах.
|
||||
|
|
@ -98,6 +109,10 @@ class _Candidate:
|
|||
id: int
|
||||
url: str
|
||||
label: str | None
|
||||
ban_count: int
|
||||
"""ban_count по scrape_proxy_source_bans ДЛЯ ЭТОГО source (0, если строки нет —
|
||||
узел ни разу не банился этой площадкой). Учитывает и истёкшие строки бана
|
||||
(banned_until <= now(), но ещё не спурженные) — см. докстринг модуля."""
|
||||
|
||||
|
||||
def _safe_label(proxy_id: int, label: str | None, url: str) -> str:
|
||||
|
|
@ -113,23 +128,31 @@ def _safe_label(proxy_id: int, label: str | None, url: str) -> str:
|
|||
|
||||
|
||||
def _pick_candidate(db: Session, source: str) -> _Candidate | None:
|
||||
"""READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел."""
|
||||
"""READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел.
|
||||
|
||||
LEFT JOIN (не EXISTS) на scrape_proxy_source_bans — нужен сам ban_count для
|
||||
ранжирования, а не только факт активного бана. Активный бан (banned_until > now())
|
||||
по-прежнему полный фильтр в WHERE, это НЕ меняется; но истёкшая (и ещё не
|
||||
спурженная) строка бана остаётся в ORDER BY как история — см. докстринг модуля.
|
||||
COALESCE(b.ban_count, 0) — узел без единой строки истории по source ранжируется
|
||||
как ban_count=0, естественно раньше любого узла с реальной историей банов.
|
||||
"""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, label
|
||||
FROM scrape_proxies
|
||||
WHERE enabled
|
||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b
|
||||
WHERE b.proxy_id = scrape_proxies.id
|
||||
AND b.source = CAST(:source AS text)
|
||||
AND b.banned_until > now()
|
||||
)
|
||||
ORDER BY consecutive_fails ASC, last_ok_at DESC NULLS LAST, id
|
||||
SELECT sp.id, sp.url, sp.label, COALESCE(b.ban_count, 0) AS ban_count
|
||||
FROM scrape_proxies AS sp
|
||||
LEFT JOIN scrape_proxy_source_bans AS b
|
||||
ON b.proxy_id = sp.id
|
||||
AND b.source = CAST(:source AS text)
|
||||
WHERE sp.enabled
|
||||
AND sp.consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND (b.banned_until IS NULL OR b.banned_until <= now())
|
||||
ORDER BY COALESCE(b.ban_count, 0) ASC,
|
||||
sp.consecutive_fails ASC,
|
||||
sp.last_ok_at DESC NULLS LAST,
|
||||
sp.id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
|
|
@ -143,7 +166,16 @@ def _pick_candidate(db: Session, source: str) -> _Candidate | None:
|
|||
# середине более широкой операции).
|
||||
if row is None:
|
||||
return None
|
||||
return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"])
|
||||
return _Candidate(
|
||||
id=int(row["id"]),
|
||||
url=str(row["url"]),
|
||||
label=row["label"],
|
||||
# .get(..., 0) — не .__getitem__: production-SELECT ВСЕГДА проецирует
|
||||
# ban_count (см. запрос выше), но нулевой default защищает от полного KeyError
|
||||
# у сторонних fake-db в других test-модулях (напр. test_2830_pool_bypass_tails),
|
||||
# которые мокают этот же db.execute() урезанным dict без нового столбца.
|
||||
ban_count=int(row.get("ban_count", 0)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -239,10 +271,11 @@ def resolve_proxy_url(db: Session, source: str) -> str | None:
|
|||
|
||||
if candidate is not None:
|
||||
logger.info(
|
||||
"proxy_egress: source=%s -> pool proxy id=%d (%s)",
|
||||
"proxy_egress: source=%s -> pool proxy id=%d (%s) ban_count=%d",
|
||||
source,
|
||||
candidate.id,
|
||||
_safe_label(candidate.id, candidate.label, candidate.url),
|
||||
candidate.ban_count,
|
||||
)
|
||||
return candidate.url
|
||||
|
||||
|
|
|
|||
|
|
@ -303,6 +303,18 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st
|
|||
|
||||
#1348: blocking psycopg work — must run via asyncio.to_thread, never directly
|
||||
on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard).
|
||||
|
||||
#2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией
|
||||
(limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в
|
||||
DO UPDATE ставил одну и ту же метку всем строкам ряда — на проде все 639 строк
|
||||
несли время последнего прогона, включая период 2017-01. Как признак свежести
|
||||
колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период»,
|
||||
то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым
|
||||
периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка
|
||||
2026-08-06 и она останется — такт публикации до этого PR невосстановим.
|
||||
Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно —
|
||||
scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted),
|
||||
и именно оттуда его берёт tasks/sber_freshness_monitor.
|
||||
"""
|
||||
for city_label, period_month, segment, dash, value in rows_to_upsert:
|
||||
db.execute(
|
||||
|
|
@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st
|
|||
ON CONFLICT (city, period_month, dashboard)
|
||||
DO UPDATE SET
|
||||
index_value_rub_m2 = EXCLUDED.index_value_rub_m2,
|
||||
segment = EXCLUDED.segment,
|
||||
fetched_at = now()
|
||||
segment = EXCLUDED.segment
|
||||
-- fetched_at НЕ трогаем (#2846): она = «впервые увидели период».
|
||||
"""
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -143,6 +143,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
|
|||
# unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше
|
||||
# сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов
|
||||
# реально дали ноль.
|
||||
# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich
|
||||
# (65 прогонов/90д, единственные два писателя ключа на проде,
|
||||
# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и
|
||||
# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в
|
||||
# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта
|
||||
# (rows_fetched=rows_skipped=96974, last_id не двигается неделями),
|
||||
# а не отказ; если бы 'rows_inserted' попал в этот список, сторож
|
||||
# зачитывал бы этот здоровый ноль как измеренный провал и копил бы
|
||||
# практически непрерываемый стрик (rosreestr_dkp_import не
|
||||
# прерывается другим статусом — импорт либо 'done', либо не бежал).
|
||||
# НЕ 'processed' по той же причине с другой стороны: это счётчик
|
||||
# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже
|
||||
# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14,
|
||||
# 44% отказов замаскировались бы под measured-25) — сторож нулевого
|
||||
# результата на нём молчал бы ровно там, где должен сработать, а на
|
||||
# будущем опустении очереди домов (cian_houses_pending) создал бы
|
||||
# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep
|
||||
# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах —
|
||||
# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08,
|
||||
# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше
|
||||
# ни total_seen/lots_fetched/unique_fetched не было, и
|
||||
# _run_result_count всегда возвращал None (honest-run-status)).
|
||||
# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650
|
||||
# прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь
|
||||
# (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched
|
||||
|
|
@ -150,7 +172,12 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
|
|||
# трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ
|
||||
# (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их
|
||||
# словарь, а честно признаёт, что мерить нечем — см. _run_result_count.
|
||||
_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched")
|
||||
_RESULT_COUNTER_KEYS = (
|
||||
"total_seen",
|
||||
"lots_fetched",
|
||||
"unique_fetched",
|
||||
"succeeded",
|
||||
)
|
||||
|
||||
|
||||
def _run_result_count(counters: Mapping[str, Any] | None) -> int | None:
|
||||
|
|
@ -282,6 +309,63 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
# honest-run-status (2026-08-15): доля отказов, которая обесценивает формально ненулевой
|
||||
# сбор. Прод-факт avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,
|
||||
# "blocked":1} — 89% попыток отказали, а mark_backfill_finished всё равно звал mark_done,
|
||||
# потому что "produced != 0" (6 обогащено). Ни _sweep_run_did_nothing (нужны
|
||||
# anchors_total/errors_count, у backfill'ов их нет), ни _phase_totally_failed (нужна пара
|
||||
# "<phase>_attempted"/"<phase>_failed" — здесь голые "attempted"/"failed" без фазового
|
||||
# префикса, `"attempted".endswith("_attempted")` не матчит) эту форму counters не ловят —
|
||||
# обе проверки написаны под СВОИ формы, а не под backfill'овскую.
|
||||
#
|
||||
# Порог 'failed' — половина и больше отказов: сбор для практических целей провалился,
|
||||
# даже если несколько записей всё же обогатились. Порог 'partial' НЕ заведён отдельным
|
||||
# статусом scrape_runs.status — это потребовало бы миграции (DROP+ADD CHECK constraint,
|
||||
# 051_scrape_runs_extend.sql) и обучило бы новому значению ещё 4 места (Literal-фильтр
|
||||
# admin API, хардкод статусов фронта, оба IN-списка сторожей) — тот же класс "оборванной
|
||||
# проводки", из-за которого заведён #2686/ban_kind. Вместо статуса — тот же диагноз, что и
|
||||
# у ban_kind: causa в тексте `error`, терминальный статус один ('failed'). 0.15..0.5 —
|
||||
# та же 'failed', но с другой формулировкой причины ("деградировал", не "провалился"), чтобы
|
||||
# оператор видел разницу читая error, не только status.
|
||||
FAILED_RATIO_FAILED_THRESHOLD = 0.5
|
||||
FAILED_RATIO_DEGRADED_THRESHOLD = 0.15
|
||||
# Минимум попыток, при котором доля вообще что-то значит — иначе 1 отказ из 2 (=0.5)
|
||||
# палит статус на шуме единичного случая. То же рассуждение и то же число, что у
|
||||
# _PHASE_MIN_ATTEMPTS (см. выше).
|
||||
_FAILED_RATIO_MIN_ATTEMPTS = _PHASE_MIN_ATTEMPTS
|
||||
|
||||
|
||||
def _failed_ratio_too_high(counters: Mapping[str, Any]) -> str | None:
|
||||
"""Прогон, у которого доля отказов слишком велика, даже если что-то собрано.
|
||||
|
||||
Возвращает текст причины (для error) либо None. Читает ГОЛЫЕ ключи "attempted"/
|
||||
"failed" (без фазового префикса) — сейчас это словарь только у четырёх
|
||||
detail-backfill'ов (avito/yandex/domclick/newbuilding_enrich), все идут через
|
||||
mark_backfill_finished → mark_done. `attempted < _FAILED_RATIO_MIN_ATTEMPTS` или
|
||||
отсутствие любого из ключей → None (нечем/не о чём судить — счётчики либо не
|
||||
заполнены, либо принадлежат другому источнику со своим словарём).
|
||||
|
||||
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
|
||||
'failed' без диагноза, как и у #2625/#2700/#2764.
|
||||
"""
|
||||
attempted = _pick_int(counters, "attempted")
|
||||
failed = _pick_int(counters, "failed")
|
||||
if attempted is None or failed is None or attempted < _FAILED_RATIO_MIN_ATTEMPTS:
|
||||
return None
|
||||
ratio = failed / max(attempted, 1)
|
||||
if ratio >= FAILED_RATIO_FAILED_THRESHOLD:
|
||||
verb = "провалился"
|
||||
elif ratio >= FAILED_RATIO_DEGRADED_THRESHOLD:
|
||||
verb = "деградировал"
|
||||
else:
|
||||
return None
|
||||
return (
|
||||
f"failed-ratio-honest-status: сбор {verb} — {failed} из {attempted} попыток "
|
||||
f"отказали (доля {ratio:.0%}); формально ненулевой результат этого не искупает. "
|
||||
f"Причина НЕ установлена — статус 'failed' без диагноза"
|
||||
)
|
||||
|
||||
|
||||
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
||||
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
||||
|
||||
|
|
@ -292,13 +376,24 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
|||
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
|
||||
|
||||
Приоритет ключей:
|
||||
- total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched)
|
||||
- new_count ← 'new_count' (если уже есть) иначе 'lots_inserted'
|
||||
- total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched /
|
||||
succeeded)
|
||||
- new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted'
|
||||
(первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex,
|
||||
CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала
|
||||
new_count=0 у трёх подряд cian_full_load при реально сохранённых
|
||||
saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted'
|
||||
у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ,
|
||||
которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов;
|
||||
здесь (для витринной колонки new_count) это безопасно — в отличие от
|
||||
_RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика.
|
||||
|
||||
Возвращает (total_seen, new_count); None для ключа, которого нет в counters —
|
||||
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
|
||||
"""
|
||||
return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted")
|
||||
return _run_result_count(counters), _pick_int(
|
||||
counters, "new_count", "lots_inserted", "saved_inserted", "rows_inserted"
|
||||
)
|
||||
|
||||
|
||||
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
||||
|
|
@ -558,6 +653,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
|||
#2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
|
||||
целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
|
||||
ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет».
|
||||
|
||||
honest-run-status: там же — отказ называть успехом прогон с высокой долей отказов,
|
||||
даже если собрано > 0 (см. _failed_ratio_too_high). Отличие от #2625/#2700: те два
|
||||
смотрят на «всё или ничего» (все якоря / вся фаза), этот — на ДОЛЮ отказов у
|
||||
detail-backfill'ов, где ни один из первых двух признаков не матчит форму counters.
|
||||
"""
|
||||
did_nothing = _sweep_run_did_nothing(counters)
|
||||
if did_nothing is not None:
|
||||
|
|
@ -569,6 +669,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
|||
logger.error("%s run_id=%d", phase_dead, run_id)
|
||||
mark_failed(db, run_id, phase_dead, counters)
|
||||
return
|
||||
ratio_bad = _failed_ratio_too_high(counters)
|
||||
if ratio_bad is not None:
|
||||
logger.error("%s run_id=%d", ratio_bad, run_id)
|
||||
mark_failed(db, run_id, ratio_bad, counters)
|
||||
return
|
||||
total_seen, new_count = _column_counts(counters)
|
||||
row = db.execute(
|
||||
text(
|
||||
|
|
|
|||
|
|
@ -109,9 +109,16 @@ __all__ = [
|
|||
# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ
|
||||
# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный
|
||||
# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан.
|
||||
#
|
||||
# #262 wave 2: avito_slug — Optional в CityLocation (не у каждого oblast-города
|
||||
# подтверждён). Города без avito_slug пропускаем целиком — у них НЕТ avito_city_
|
||||
# sweep schedule (262_), значит НЕТ и avito-листингов с их URL; паттерн для них
|
||||
# был бы либо мёртвым, либо (что хуже) построен из city_slug вместо реального
|
||||
# avito URL-сегмента и создал бы ложный LIKE-матч.
|
||||
_OBLAST_AVITO_URL_PATTERNS = tuple(
|
||||
"%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%"
|
||||
for loc in CITY_LOCATIONS.values()
|
||||
if loc.avito_slug is not None
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,28 @@
|
|||
Ключевые решения:
|
||||
- Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой
|
||||
инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka',
|
||||
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем.
|
||||
TTL=30. novostroyki (9659 активных первичных строк) не трогаем.
|
||||
ИЗВЕСТНЫЙ ПРОБЕЛ (ревью TTL-CAP круг 2, 2026-08-15): этот скоуп уже, чем множество
|
||||
реально протухших строк -- живой замер на проде даёт cian/novostroyki 9 483 активных
|
||||
строки старше 60 суток, ни одна из них не деактивируется НИ ОДНОЙ джобой (внутри
|
||||
скоупа cian/vtorichka и yandex/vtorichka таких строк 0). Потолок cap_mult (см.
|
||||
CAP_MULT ниже) этот пробел не закрывает и закрыть не может -- он сжимает пул ВНУТРИ
|
||||
скоупа джобы, а не расширяет сам скоуп. NULL-сегмент (тот же замер круга 2 давал
|
||||
cian/NULL 211, yandex/NULL 523 строки старше 60 суток) закрыт отдельно ниже
|
||||
(null_segment_only, миграция 266) -- novostroyki-часть пробела остаётся: расширение
|
||||
скоупа туда отдельная задача (нужно сперва выяснить, поддерживает ли cian/yandex
|
||||
full-coverage sweep для novostroyki СЕЙЧАС, иначе паушальный TTL повторит инцидент,
|
||||
ради которого этот DECISION и принят) и намеренно НЕ входит в TTL-CAP.
|
||||
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
||||
- NULL-сегмент (легаси-строки до миграции 011 + жертвы бага в ON CONFLICT -- upsert
|
||||
никогда не пишет listing_segment повторно, поэтому раз рождённая NULL-строка сама
|
||||
себя не чинит даже при живой ежедневной досдаче) деактивируется ОТДЕЛЬНОЙ джобой per
|
||||
source (null_segment_only=True, миграция 266): явный `listing_segment IS NULL`
|
||||
предикат, а не ANY(:segments) -- этот оператор NULL никогда не матчит. Гейт
|
||||
здоровья/пол переобхода для этой джобы выключены (min_confirmations=0,
|
||||
revisit_floor_quantile=0) -- население нерепрезентативно мало (единицы подтверждений
|
||||
в сутки против сотен-тысяч у обычного vtorichka-среза), калиброванный под vtorichka
|
||||
порог держал бы джобу вечно skipped_unhealthy.
|
||||
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
|
||||
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
|
||||
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
|
||||
|
|
@ -127,6 +147,11 @@ DEFAULT_MIN_CONFIRMATIONS = 500
|
|||
|
||||
_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))"
|
||||
|
||||
# NULL-сегмент: `= ANY(...)` НИКОГДА не матчит NULL (SQL, не баг), поэтому
|
||||
# для null_segment_only-режима нужен отдельный явный предикат IS NULL, а не элемент
|
||||
# в :segments. См. _build_null_segment_sql ниже -- тот же принцип для самого UPDATE.
|
||||
_CONFIRMATIONS_NULL_SEGMENT_FILTER = "\n AND listing_segment IS NULL"
|
||||
|
||||
|
||||
# ── Пол TTL по измеренному циклу переобхода (#2659) ───────────────────────────
|
||||
# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на
|
||||
|
|
@ -188,9 +213,72 @@ _CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:seg
|
|||
DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99
|
||||
|
||||
_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))"
|
||||
_REVISIT_FLOOR_NULL_SEGMENT_FILTER = "\n AND l.listing_segment IS NULL"
|
||||
|
||||
|
||||
def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any:
|
||||
# ── Потолок эффективного TTL (положительная обратная связь пола, найдено 2026-08-15) ──
|
||||
# У пола выше нет верхней границы: max(ttl_days, пол) может расти неограниченно.
|
||||
# ЗАМЕР НА ПРОДЕ (уточнён 2026-08-15 после разбора): у yandex counters держали
|
||||
# ttl_days_effective 75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 —
|
||||
# пол реально разгонялся без верхней границы, и потолок закрывает именно это.
|
||||
# ЧЕГО ПОТОЛОК НЕ ДЕЛАЕТ: он НЕ сжимает пул «активных». Замер показал 0
|
||||
# деактивируемых строк на всех четырёх джобах и до, и после калибровки. Цифра
|
||||
# «23 687 из 44 744 не подтверждались >7 суток» относится ко ВСЕМ источникам
|
||||
# сразу, и две трети её — новостройки, которых оценщик не берёт. У avito
|
||||
# просроченных ноль. Раздутый пул, влияющий на оценку, лежит в строках с ПУСТЫМ
|
||||
# сегментом и чинится отдельной джобой, не этим потолком.
|
||||
#
|
||||
# МЕХАНИЗМ ПЕТЛИ: медленный обход поднимает пол (он же квантиль разрывов переобхода)
|
||||
# -> высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться
|
||||
# свежий обход -> пул «активных» раздувается «протухшими» строками -> следующий замер
|
||||
# пола на том же раздутом пуле оказывается ещё выше. Без верхней границы это не
|
||||
# самокорректирующийся пол, а положительная обратная связь.
|
||||
#
|
||||
# CAP_MULT = 2 -- эффективный TTL не может превысить удвоенный заданный оператором
|
||||
# ttl_days. Пол по-прежнему может его поднять (ради #2659 -- см. комментарий выше:
|
||||
# ложные снятия при неполном покрытии обхода), но не бесконечно. Почему именно 2, а
|
||||
# не 3 или 1.5: вдвое — это ещё «мы искренне не уверены, что молчание значит
|
||||
# снятие», не «источник вообще умер». Дальнейший рост пола сигнализирует не о
|
||||
# медленном, но живом обходе, а о мёртвом источнике -- для ЭТОГО случая уже есть
|
||||
# отдельный гейт по здоровью (min_confirmations) выше в этой же функции, который
|
||||
# выключает деактивацию целиком, а не растягивает TTL до бесконечности. Калибровочная
|
||||
# ручка, не догма -- при новом замере можно пересмотреть, как и revisit_floor_quantile.
|
||||
#
|
||||
# ПОЧЕМУ MULT, А НЕ ФИКСИРОВАННОЕ ЧИСЛО СУТОК -- И ГДЕ ЭТА ФОРМА ЛОМАЕТСЯ. Множитель
|
||||
# от ttl_days даёт разный АБСОЛЮТНЫЙ потолок на разных источниках: cian/yandex
|
||||
# (ttl=30) -> 60 суток, avito (ttl=10) -> 20 суток, domklik (ttl=14) -> 28 суток. Это
|
||||
# ломается ровно там, где абсолютный хвост переобхода источника НЕ пропорционален его
|
||||
# ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же
|
||||
# потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже
|
||||
# собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще
|
||||
# заведён (см. комментарий выше). domklik (потолок 28 при хвосте 3.1) разрыва не
|
||||
# имеет -- множитель 2 для него калиброван верно.
|
||||
#
|
||||
# YANDEX -- ТА ЖЕ ДЫРА, НАЙДЕНА ПОЗЖЕ (ревью круга 3, 2026-08-15). Строка выше до
|
||||
# этой правки утверждала, что cian/yandex с потолком 60 тоже в порядке -- это было
|
||||
# верно для cian (live-пол сейчас 31.1), но НЕ для yandex: ЖИВЫЕ полы из
|
||||
# scrape_runs.counters (deactivate_stale_yandex, 2026-08-10..08-15) -- 75/75/75/39/
|
||||
# 52/54, а прямой live-замер той же percentile_disc(0.99)-формулы сегодня даёт 79.2
|
||||
# (n=1961 подтверждений за 3 суток). И то, и другое ВЫШЕ потолка 60 -- тот же
|
||||
# false-kill класс, что у avito, статический p99=43.0 (_REVISIT_TAIL) для yandex
|
||||
# устарел и вводит в заблуждение. cap_mult для yandex откалиброван отдельной
|
||||
# миграцией (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90) --
|
||||
# см. её комментарий про то, почему это НЕ меняет число деактивированных строк
|
||||
# следующим прогоном (0 активных строк источника старше 39 суток на момент замера).
|
||||
#
|
||||
# ПОЭТОМУ cap_mult -- параметр функции (как revisit_floor_quantile, min_confirmations),
|
||||
# не голая константа: default = CAP_MULT для источников, где 2x достаточно (cian,
|
||||
# domklik), но расписание может переопределить через default_params (JSON-колонка
|
||||
# scrape_schedules, ключ "cap_mult") для источника с непропорционально длинным
|
||||
# хвостом -- см. миграции для avito (cap_mult=6, потолок 60, с запасом выше
|
||||
# статического p99=42.1 и живого прод-пика 52, замеренного 2026-08-10..12) и yandex
|
||||
# (cap_mult=3, потолок 90, с запасом выше живого пола 79.2, замеренного 2026-08-15).
|
||||
CAP_MULT = 2
|
||||
|
||||
|
||||
def _build_revisit_floor_sql(
|
||||
staleness_column: str, *, with_segments: bool, null_segment_only: bool = False
|
||||
) -> Any:
|
||||
"""Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива.
|
||||
|
||||
Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт
|
||||
|
|
@ -198,10 +286,20 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A
|
|||
Только строки, у которых свежесть реально сдвинулась, — то есть выжившие,
|
||||
а не «мы к ним не приходили».
|
||||
|
||||
null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments)
|
||||
(ANY никогда не матчит NULL). На практике для null_segment_only-джобы этот запрос
|
||||
не строится вовсе (revisit_floor_quantile=0 -- см. модульный докстринг), но вариант
|
||||
нужен для корректности, если порог когда-нибудь включат.
|
||||
|
||||
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
|
||||
Значения — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type).
|
||||
"""
|
||||
segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else ""
|
||||
if null_segment_only:
|
||||
segment_filter = _REVISIT_FLOOR_NULL_SEGMENT_FILTER
|
||||
elif with_segments:
|
||||
segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER
|
||||
else:
|
||||
segment_filter = ""
|
||||
return text(
|
||||
f"""
|
||||
SELECT percentile_disc(CAST(:revisit_quantile AS double precision))
|
||||
|
|
@ -229,14 +327,25 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A
|
|||
)
|
||||
|
||||
|
||||
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
|
||||
def _build_confirmations_sql(
|
||||
staleness_column: str, *, with_segments: bool, null_segment_only: bool = False
|
||||
) -> Any:
|
||||
"""SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
|
||||
|
||||
null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments).
|
||||
Для null_segment_only-джобы min_confirmations=0 по умолчанию (см. модульный
|
||||
докстринг), так что на практике этот путь не строится -- оставлен для корректности.
|
||||
|
||||
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
|
||||
Значения (:listing_source, :health_window_days, :segments) — param-binding,
|
||||
psycopg v3 safe (CAST(... AS ...), никаких :param::type).
|
||||
"""
|
||||
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else ""
|
||||
if null_segment_only:
|
||||
segment_filter = _CONFIRMATIONS_NULL_SEGMENT_FILTER
|
||||
elif with_segments:
|
||||
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER
|
||||
else:
|
||||
segment_filter = ""
|
||||
return text(
|
||||
f"""
|
||||
SELECT count(*)
|
||||
|
|
@ -292,6 +401,33 @@ def _build_segments_sql(staleness_column: str) -> Any:
|
|||
)
|
||||
|
||||
|
||||
def _build_null_segment_sql(staleness_column: str) -> Any:
|
||||
"""UPDATE строго по listing_segment IS NULL (null_segment_only=True).
|
||||
|
||||
НЕ переиспользует _build_segments_sql: `= ANY(CAST(:segments AS text[]))` никогда
|
||||
не матчит NULL (SQL-семантика, не баг -- та же ловушка задокументирована выше у
|
||||
novostroyki-гарда), поэтому NULL-сегмент не выразить через список segments и нужен
|
||||
отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' --
|
||||
их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах.
|
||||
|
||||
staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе.
|
||||
"""
|
||||
return text(
|
||||
f"""
|
||||
WITH stale AS (
|
||||
UPDATE listings
|
||||
SET is_active = false
|
||||
WHERE source = :listing_source
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
AND listing_segment IS NULL
|
||||
RETURNING id, price_rub
|
||||
)
|
||||
{_STALE_SNAPSHOT_TAIL}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# Дефолтные (last_seen_at) варианты SQL -- сохранены как модульные константы для
|
||||
# обратной совместимости (тесты читают .text, product_handlers/scheduler не менялись).
|
||||
_DEACTIVATE_SQL_ALL_SEGMENTS = _build_all_segments_sql("last_seen_at")
|
||||
|
|
@ -312,6 +448,8 @@ def deactivate_stale_listings(
|
|||
min_confirmations: int = 0,
|
||||
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
||||
revisit_floor_quantile: float = 0.0,
|
||||
null_segment_only: bool = False,
|
||||
cap_mult: float = CAP_MULT,
|
||||
) -> dict[str, int]:
|
||||
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
|
||||
|
||||
|
|
@ -321,6 +459,7 @@ def deactivate_stale_listings(
|
|||
ttl_days: количество дней TTL; объявления старше этого порога деактивируются.
|
||||
segments: если задан -- деактивировать только объявления с указанными
|
||||
listing_segment значениями. None -> все сегменты (поведение avito по умолчанию).
|
||||
Несовместимо с null_segment_only=True (см. ниже).
|
||||
staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist
|
||||
{"last_seen_at", "scraped_at"} — иначе ValueError ДО любого SQL. Дефолт
|
||||
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
|
||||
|
|
@ -334,9 +473,25 @@ def deactivate_stale_listings(
|
|||
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
|
||||
revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659).
|
||||
Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой;
|
||||
эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так
|
||||
эффективный TTL = min(max(ttl_days, этот пол), ttl_days * cap_mult) --
|
||||
пол поднимает TTL, но не выше потолка. 0 -> пол выключен (так
|
||||
вызывают старые тесты и совместимая обёртка), рабочее значение —
|
||||
DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше.
|
||||
null_segment_only: True -> WHERE фильтрует `listing_segment IS NULL` вместо
|
||||
ANY(:segments). Требует segments=None (иначе ValueError -- смешивать
|
||||
бессмысленно, это два непересекающихся среза). Для этого среза гейт/пол
|
||||
обычно держат выключенными (min_confirmations=0, revisit_floor_quantile=0,
|
||||
см. миграцию 266 и модульный докстринг) -- население слишком мало для
|
||||
откалиброванных под полноценный vtorichka-свип порогов.
|
||||
cap_mult: множитель потолка эффективного TTL (см. комментарий у модульной
|
||||
константы CAP_MULT). Дефолт -- сама CAP_MULT=2, но параметр, а НЕ голая
|
||||
константа: источник с непропорционально длинным хвостом переобхода
|
||||
относительно своего ttl_days (avito: p99=42.1 при ttl=10 -> дефолтный
|
||||
потолок 20 режет ниже хвоста) может переопределить его через
|
||||
default_params расписания (ключ "cap_mult"), не трогая остальные
|
||||
источники. Итоговый потолок = ttl_days * cap_mult. Применяется и к
|
||||
null_segment_only-джобе, но там гейт/пол выключены (см. выше), так что
|
||||
на практике не участвует.
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
|
||||
|
|
@ -345,14 +500,58 @@ def deactivate_stale_listings(
|
|||
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
||||
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
|
||||
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял
|
||||
TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}.
|
||||
TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол
|
||||
упёрся в потолок cap_mult: дополнительно {"ttl_floor_capped": 1,
|
||||
"ttl_days_floor_raw": N} -- N это то, во что пол поднял бы TTL БЕЗ потолка.
|
||||
|
||||
Raises:
|
||||
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
|
||||
никакой интерполяции пользовательского ввода в запрос).
|
||||
ValueError: если staleness_column не входит в whitelist, ИЛИ ttl_days <= 0
|
||||
(проверка ДО SQL, никакой интерполяции пользовательского ввода в запрос;
|
||||
ttl_days<=0 в WHERE-условии last_seen_at < NOW() - INTERVAL 'N days'
|
||||
матчит практически весь активный пул -- без явного guard'а потолок
|
||||
(ttl_days * cap_mult <= 0) к тому же перебивал бы пол в формуле min(),
|
||||
снимая защиту, которую max(ttl_days, floor) давал раньше), ИЛИ cap_mult < 1
|
||||
(тот же класс дыры, но со стороны потолка, а не пола: cap_mult приходит из
|
||||
jsonb default_params расписания -- ЕДИНСТВЕННЫЙ запланированный способ его
|
||||
задать, т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода. cap_mult=0
|
||||
даёт capped=0 -> effective_ttl_days=0 -> UPDATE снимает практически весь
|
||||
активный пул источника; cap_mult<1 (например 0.5) опускает потолок НИЖЕ
|
||||
заданного оператором ttl_days -- прямое нарушение инварианта «потолок не
|
||||
может понизить TTL ниже настроенного», который проверяет
|
||||
test_cap_never_lowers_ttl_below_configured_value), ИЛИ ttl_days/cap_mult --
|
||||
bool (найдено ревью круга 3, 2026-08-15: `cap_mult < 1` пропускает `True` --
|
||||
`bool` наследует `int`, `True < 1` ложно, а `ttl_days * True` == `ttl_days`,
|
||||
то есть потолок = сам ttl_days и пол молча отключается, никакого ValueError.
|
||||
jsonb `true`/`false` вместо числа -- ровно та опечатка в расписании, ради
|
||||
которой оба guard'а вообще написаны, поэтому bool отклоняется явной
|
||||
type-проверкой ДО числового сравнения для обоих параметров), ЛИБО если
|
||||
заданы одновременно null_segment_only=True и segments (взаимоисключающие
|
||||
срезы -- IS NULL и ANY(:segments) не композируются).
|
||||
"""
|
||||
counters: dict[str, int] = {"deactivated": 0}
|
||||
try:
|
||||
# bool -- подкласс int в Python, поэтому `True < 1` (False) и `False <= 0`
|
||||
# (True) НЕ ловят опечатку `"ttl_days": true` / `"cap_mult": true` в jsonb:
|
||||
# `ttl_days * True` == `ttl_days`, `cap_mult=True` даёт потолок == ttl_days и
|
||||
# молча отключает пол (см. Raises выше). Проверка типа -- ДО числового
|
||||
# сравнения, иначе bool проскакивает мимо него необнаруженным.
|
||||
if isinstance(ttl_days, bool):
|
||||
raise ValueError(f"ttl_days must be a number, not bool: {ttl_days!r}")
|
||||
if ttl_days <= 0:
|
||||
raise ValueError(f"ttl_days must be positive, got {ttl_days!r}")
|
||||
|
||||
# Тот же класс дыры, что и ttl_days<=0 выше, только со стороны потолка:
|
||||
# cap_mult < 1 может опустить потолок (ttl_days * cap_mult) НИЖЕ заданного
|
||||
# ttl_days, а cap_mult <= 0 -- сделать капнутый потолок <= 0 и победить пол
|
||||
# в min() молча (ровно та дыра, ради которой заведён guard выше). Единственный
|
||||
# запланированный способ задать cap_mult -- вписать его руками в jsonb
|
||||
# default_params расписания (см. миграцию для avito), т.е. именно там опечатка
|
||||
# 0 / 0.5 вместо 6 -- реальный риск, а не гипотетика.
|
||||
if isinstance(cap_mult, bool):
|
||||
raise ValueError(f"cap_mult must be a number, not bool: {cap_mult!r}")
|
||||
if cap_mult < 1:
|
||||
raise ValueError(f"cap_mult must be >= 1, got {cap_mult!r}")
|
||||
|
||||
# Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки
|
||||
# интерполируется f-string'ом. Значения по-прежнему идут через param-binding.
|
||||
# Внутри try -> невалидная колонка финализирует run как failed (mark_failed),
|
||||
|
|
@ -362,6 +561,11 @@ def deactivate_stale_listings(
|
|||
f"invalid staleness_column={staleness_column!r}; "
|
||||
f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}"
|
||||
)
|
||||
# null_segment_only + segments одновременно -- неоднозначный запрос:
|
||||
# IS NULL и ANY(:segments) -- разные, непересекающиеся предикаты, а не
|
||||
# композиция. Явный ValueError лучше молчаливого выбора одного из двух.
|
||||
if null_segment_only and segments is not None:
|
||||
raise ValueError("null_segment_only=True несовместимо с заданным segments")
|
||||
|
||||
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
|
||||
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
||||
|
|
@ -375,7 +579,11 @@ def deactivate_stale_listings(
|
|||
health_params["segments"] = segments
|
||||
confirmations = (
|
||||
db.execute(
|
||||
_build_confirmations_sql(staleness_column, with_segments=segments is not None),
|
||||
_build_confirmations_sql(
|
||||
staleness_column,
|
||||
with_segments=segments is not None,
|
||||
null_segment_only=null_segment_only,
|
||||
),
|
||||
health_params,
|
||||
).scalar()
|
||||
or 0
|
||||
|
|
@ -390,19 +598,23 @@ def deactivate_stale_listings(
|
|||
logger.warning(
|
||||
"deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — "
|
||||
"подтверждений за %d сут %d < порога %d "
|
||||
"(segments=%r, staleness_column=%s); ни одна строка не тронута",
|
||||
"(segments=%r, null_segment_only=%s, staleness_column=%s); "
|
||||
"ни одна строка не тронута",
|
||||
listing_source,
|
||||
run_id,
|
||||
health_window_days,
|
||||
confirmations,
|
||||
min_confirmations,
|
||||
segments,
|
||||
null_segment_only,
|
||||
staleness_column,
|
||||
)
|
||||
return counters
|
||||
|
||||
# Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же
|
||||
# срезу. Поднимает порог, никогда не опускает: max(), а не замена.
|
||||
# срезу. Поднимает порог (max), но не выше потолка cap_mult * ttl_days (min) —
|
||||
# см. комментарий у CAP_MULT про петлю с положительной обратной связью и про
|
||||
# то, почему cap_mult -- параметр, а не голая константа.
|
||||
effective_ttl_days = ttl_days
|
||||
if revisit_floor_quantile > 0:
|
||||
floor_params: dict[str, Any] = {
|
||||
|
|
@ -413,16 +625,50 @@ def deactivate_stale_listings(
|
|||
if segments is not None:
|
||||
floor_params["segments"] = segments
|
||||
floor_days = db.execute(
|
||||
_build_revisit_floor_sql(staleness_column, with_segments=segments is not None),
|
||||
_build_revisit_floor_sql(
|
||||
staleness_column,
|
||||
with_segments=segments is not None,
|
||||
null_segment_only=null_segment_only,
|
||||
),
|
||||
floor_params,
|
||||
).scalar()
|
||||
# NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках).
|
||||
# Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего.
|
||||
if floor_days is not None:
|
||||
counters["revisit_floor_days"] = ceil(float(floor_days))
|
||||
effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
|
||||
# Пол поднимает TTL (max), потолок cap_mult его не пускает выше
|
||||
# ttl_days * cap_mult (min) — без этого пол растёт без ограничения
|
||||
# (см. комментарий у CAP_MULT). capped_ttl_days может быть float,
|
||||
# если cap_mult переопределён нецелым значением из default_params —
|
||||
# effective_ttl_days приводим к int (UPDATE ждёт целые сутки).
|
||||
raw_effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
|
||||
capped_ttl_days = ttl_days * cap_mult
|
||||
effective_ttl_days = int(min(raw_effective_ttl_days, capped_ttl_days))
|
||||
counters["ttl_days_effective"] = effective_ttl_days
|
||||
if effective_ttl_days > ttl_days:
|
||||
|
||||
if raw_effective_ttl_days > capped_ttl_days:
|
||||
# Пол упёрся в потолок -- оба числа в counters (не только в логе),
|
||||
# чтобы это было видно в витрине прогонов, а не только в логах.
|
||||
# 1, а не True -- counters типизирован dict[str, int] (тот же
|
||||
# идиом, что skipped_unhealthy выше).
|
||||
counters["ttl_floor_capped"] = 1
|
||||
counters["ttl_days_floor_raw"] = raw_effective_ttl_days
|
||||
logger.warning(
|
||||
"deactivate_stale source=%s run_id=%d TTL пол упёрся в потолок "
|
||||
"cap_mult=%s: пол поднял бы TTL до %d сут, потолок ограничивает "
|
||||
"заданные %d сут значением %d (квантиль %.3f, segments=%r) — "
|
||||
"растущий без ограничения пол это петля с положительной обратной "
|
||||
"связью, см. комментарий у CAP_MULT",
|
||||
listing_source,
|
||||
run_id,
|
||||
cap_mult,
|
||||
raw_effective_ttl_days,
|
||||
ttl_days,
|
||||
effective_ttl_days,
|
||||
revisit_floor_quantile,
|
||||
segments,
|
||||
)
|
||||
elif effective_ttl_days > ttl_days:
|
||||
logger.warning(
|
||||
"deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: "
|
||||
"свип за %d сут доказал живой строку, молчавшую %d сут "
|
||||
|
|
@ -439,12 +685,21 @@ def deactivate_stale_listings(
|
|||
ttl_days,
|
||||
)
|
||||
|
||||
# segments is None -> все сегменты (поведение avito). segments=[...] -> только
|
||||
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
|
||||
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
|
||||
# а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник.
|
||||
if segments is not None:
|
||||
# null_segment_only -> IS NULL, отдельный явный предикат (ANY(:segments)
|
||||
# никогда не матчит NULL). segments is None -> все сегменты (поведение avito).
|
||||
# segments=[...] -> только перечисленные сегменты. Используем `is not None`
|
||||
# (НЕ truthy): пустой список [] означает "ни один сегмент" (= ANY(ARRAY[])
|
||||
# ничего не матчит, деактивирует 0), а НЕ "все сегменты" — иначе случайный []
|
||||
# стёр бы весь источник.
|
||||
if null_segment_only:
|
||||
params: dict[str, Any] = {
|
||||
"listing_source": listing_source,
|
||||
"ttl_days": effective_ttl_days,
|
||||
"run_id": run_id,
|
||||
}
|
||||
result = db.execute(_build_null_segment_sql(staleness_column), params)
|
||||
elif segments is not None:
|
||||
params = {
|
||||
"listing_source": listing_source,
|
||||
"ttl_days": effective_ttl_days,
|
||||
"segments": segments,
|
||||
|
|
@ -465,13 +720,15 @@ def deactivate_stale_listings(
|
|||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"deactivate_stale source=%s run_id=%d done: deactivated=%d "
|
||||
"(ttl_days=%d эффективный, задан %d, segments=%r, staleness_column=%s)",
|
||||
"(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, "
|
||||
"staleness_column=%s)",
|
||||
listing_source,
|
||||
run_id,
|
||||
counters["deactivated"],
|
||||
effective_ttl_days,
|
||||
ttl_days,
|
||||
segments,
|
||||
null_segment_only,
|
||||
staleness_column,
|
||||
)
|
||||
return counters
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detai
|
|||
wiring into the production scheduled orchestrator (previously only reachable manually).
|
||||
|
||||
Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher
|
||||
per run (async context manager, source="domclick" -- dedicated residential proxy pool,
|
||||
see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via
|
||||
per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула;
|
||||
выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800),
|
||||
на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via
|
||||
domclick_session.load_session(db) and threaded into every fetch_detail() call.
|
||||
|
||||
NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere):
|
||||
|
|
@ -40,9 +41,15 @@ Exception triad differs from Avito:
|
|||
Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished):
|
||||
блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял
|
||||
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
|
||||
No IP-rotation/cooldown recovery step exists here
|
||||
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
|
||||
aborted run simply retries the remaining backlog next window.
|
||||
No IP-rotation/cooldown recovery step exists here -- an aborted run simply
|
||||
retries the remaining backlog next window.
|
||||
УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated
|
||||
residential proxy, not a rotating pool». Это перестало быть правдой на миграции
|
||||
253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть
|
||||
отсутствие ротации больше НЕ следует из «ротировать нечего» — это просто
|
||||
непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой
|
||||
комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по
|
||||
длительности до блока 111-332 с, получает свой бюджет).
|
||||
ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и
|
||||
получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на
|
||||
распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch
|
||||
|
|
|
|||
|
|
@ -1,56 +1,68 @@
|
|||
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
|
||||
"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода).
|
||||
|
||||
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
|
||||
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
|
||||
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
|
||||
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
|
||||
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
|
||||
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
|
||||
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||||
ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846).
|
||||
|
||||
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||||
поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||||
Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток
|
||||
(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по
|
||||
построению: `period_month` — метка ПЕРВОГО числа месяца, поэтому на закрытии месяца
|
||||
возрасту уже ≥30; плюс собственный лаг публикации источника. За 31 сутки прямых
|
||||
наблюдений монитора (07-13 … 08-12, scrape_runs.counters) возраст лежал в 46..76 и
|
||||
НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени — ноль бит.
|
||||
|
||||
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
|
||||
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
|
||||
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
|
||||
застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor)
|
||||
писал ERROR с самого начала — расходилась только эта джоба.
|
||||
Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор
|
||||
мерил не источник, а нашу же пилу. Миграция 212 (такт 28 → 7) обещала потолок
|
||||
возраста ≈46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне
|
||||
загрузки шестидневной давности (08-06, errors=0, upserted=639) — источник просто не
|
||||
опубликовал июль. Двенадцать суток подряд (08-01 … 08-12) монитор писал ERROR при
|
||||
исправной загрузке. Потолок 53 держится, только если источник публикует строго
|
||||
помесячно; он не публикует.
|
||||
|
||||
ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов
|
||||
монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего
|
||||
собственного такта загрузки:
|
||||
13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65
|
||||
17.07 alert=0 age 46 latest=июнь (день загрузки)
|
||||
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же
|
||||
считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок
|
||||
46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать
|
||||
такое до ERROR без починки такта значило бы завести ежедневное ложное событие на
|
||||
две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ
|
||||
такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает
|
||||
«источник/загрузка встали», а не «мы давно не ходили».
|
||||
ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду
|
||||
нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН
|
||||
максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» — это вопрос
|
||||
«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода:
|
||||
|
||||
Порог алерта (документирование выбора):
|
||||
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
||||
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||||
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||||
публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл
|
||||
ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
|
||||
Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
|
||||
~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
|
||||
поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
|
||||
рабочего диапазона — если такт загрузки будут менять, пересчитай потолок
|
||||
(пол + interval_days) и сверь с 60.
|
||||
последний полный прогон свежий → наш max == max источника → источник не публиковал,
|
||||
молчание ПРАВИЛЬНОЕ (возраст = лаг источника);
|
||||
последний полный прогон старый → мы не забрали → тревога про ЗАГРУЗЧИК.
|
||||
|
||||
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||||
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||||
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||||
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||||
ЛОВУШКА: `status='done'` НЕ означает успех — прогон id=37 (2026-05-31) имеет
|
||||
{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий
|
||||
3 табло × 3 региона прошли: errors — счётчик по всему прогону).
|
||||
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи
|
||||
достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||||
оценивать).
|
||||
ПОРОГ — не круглое число, а такт самой загрузки: `scrape_schedules.default_params
|
||||
.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик
|
||||
запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с
|
||||
тактом порог не может: поменяли такт — порог поехал следом. Тревога после
|
||||
MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети)
|
||||
поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14
|
||||
суток; на прод-истории такое состояние ДОСТИЖИМО — разрывы между полными прогонами
|
||||
были 14.8 и 20 суток (05-31→06-15 и 07-17→08-06).
|
||||
|
||||
ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из
|
||||
estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у
|
||||
dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблице маскирует
|
||||
отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же
|
||||
серию — список импортируется из estimator, дублировать его тут нельзя.
|
||||
|
||||
ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а
|
||||
загрузка останется исправной — монитор промолчит: по нашим данным «источник не
|
||||
публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт
|
||||
публикации источника ретроспективно невосстановим — его затёр апсерт
|
||||
(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at
|
||||
не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период»,
|
||||
т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал»
|
||||
имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026).
|
||||
|
||||
ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с
|
||||
LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще.
|
||||
|
||||
Задача синхронная (DB-only) — запускается kit-scheduler'ом через
|
||||
product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает
|
||||
ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД).
|
||||
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed
|
||||
только если у оценщика вообще нет серии (нечего оценивать).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -62,153 +74,239 @@ from datetime import UTC, date, datetime
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LAG_ALLOWANCE_DAYS",
|
||||
"DEFAULT_PULL_INTERVAL_DAYS",
|
||||
"MISSED_PULL_CYCLES",
|
||||
"SBER_FRESHNESS_PULL_SOURCE",
|
||||
"SberFreshnessVerdict",
|
||||
"check_sber_freshness",
|
||||
"evaluate_sber_freshness",
|
||||
]
|
||||
|
||||
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
|
||||
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
|
||||
DEFAULT_LAG_ALLOWANCE_DAYS = 25
|
||||
# Джоба-загрузчик, чей такт и успешность мы и мониторим.
|
||||
SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull"
|
||||
|
||||
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
|
||||
# sber_price_index для областного уровня.
|
||||
SBER_MONITOR_CITY = "Свердловская область"
|
||||
# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг
|
||||
# окна (поглощаем), 2 = загрузка встала (алерт).
|
||||
MISSED_PULL_CYCLES = 2
|
||||
|
||||
_LATEST_SBER_PERIOD_SQL = text("""
|
||||
# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7).
|
||||
DEFAULT_PULL_INTERVAL_DAYS = 7
|
||||
|
||||
_LATEST_PERIOD_SQL = text("""
|
||||
SELECT max(period_month) AS latest
|
||||
FROM sber_price_index
|
||||
WHERE city = CAST(:city AS text)
|
||||
AND dashboard = CAST(:dash AS text)
|
||||
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
|
||||
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
|
||||
-- estimator._load_sber_index_series.
|
||||
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||||
""")
|
||||
|
||||
# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно:
|
||||
# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без
|
||||
# CAST(... AS int): counters других источников планировщик может отфильтровать позже
|
||||
# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел
|
||||
# оператор > численный.
|
||||
_LAST_COMPLETE_PULL_SQL = text("""
|
||||
SELECT max(finished_at) AS last_pull
|
||||
FROM scrape_runs
|
||||
WHERE source = CAST(:src AS text)
|
||||
AND counters @> CAST('{"errors": 0}' AS jsonb)
|
||||
AND counters -> 'upserted' > CAST('0' AS jsonb)
|
||||
""")
|
||||
|
||||
# Такт загрузки — из той же строки, по которой планировщик считает next_run_at.
|
||||
_PULL_INTERVAL_SQL = text("""
|
||||
SELECT default_params ->> 'interval_days' AS interval_days
|
||||
FROM scrape_schedules
|
||||
WHERE source = CAST(:src AS text)
|
||||
""")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SberFreshnessVerdict:
|
||||
"""Вердикт свежести СберИндекса по max(period_month)."""
|
||||
"""Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта."""
|
||||
|
||||
latest_period: date
|
||||
age_days: int
|
||||
age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги
|
||||
pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было
|
||||
max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки
|
||||
stale: bool
|
||||
|
||||
|
||||
def evaluate_sber_freshness(
|
||||
latest_period: date,
|
||||
now: datetime,
|
||||
max_age_days: int,
|
||||
*,
|
||||
last_complete_pull_at: datetime | None,
|
||||
pull_interval_days: int,
|
||||
) -> SberFreshnessVerdict:
|
||||
"""Чистая логика: устарел ли latest период СберИндекса.
|
||||
"""Чистая логика: отстала ли загрузка от собственного такта.
|
||||
|
||||
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
|
||||
`max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
|
||||
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
|
||||
stale = полных прогонов не было ВООБЩЕ, либо последний старше
|
||||
MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в
|
||||
вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш
|
||||
max(period_month) равен максимуму источника по построению, и его возраст — это
|
||||
лаг ПУБЛИКАЦИИ, на который мы повлиять не можем.
|
||||
"""
|
||||
age_days = (now.date() - latest_period).days
|
||||
stale = age_days > max_age_days
|
||||
max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days
|
||||
if last_complete_pull_at is None:
|
||||
return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True)
|
||||
pull_lag_days = (now - last_complete_pull_at).days
|
||||
return SberFreshnessVerdict(
|
||||
latest_period=latest_period,
|
||||
age_days=age_days,
|
||||
stale=stale,
|
||||
pull_lag_days=pull_lag_days,
|
||||
max_pull_lag_days=max_pull_lag_days,
|
||||
stale=pull_lag_days > max_pull_lag_days,
|
||||
)
|
||||
|
||||
|
||||
def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None:
|
||||
"""Табло, которое возьмёт оценщик, и его latest период.
|
||||
|
||||
Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло
|
||||
из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло.
|
||||
"""
|
||||
for dash in SBER_COEFF_DASHBOARDS:
|
||||
row = db.execute(
|
||||
_LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash}
|
||||
).first()
|
||||
latest = row.latest if row is not None else None
|
||||
if latest is not None:
|
||||
return dash, latest
|
||||
return None
|
||||
|
||||
|
||||
def _pull_interval_days(db: Session) -> int:
|
||||
"""Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS)."""
|
||||
row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first()
|
||||
raw = row.interval_days if row is not None else None
|
||||
try:
|
||||
return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d",
|
||||
raw,
|
||||
DEFAULT_PULL_INTERVAL_DAYS,
|
||||
)
|
||||
return DEFAULT_PULL_INTERVAL_DAYS
|
||||
|
||||
|
||||
def check_sber_freshness(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
params: dict | None = None, # type: ignore[type-arg]
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
|
||||
"""Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании.
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
|
||||
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
|
||||
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
|
||||
и финализирует run.
|
||||
Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона
|
||||
sber_index_pull, такт загрузки из scrape_schedules. Вердикт — чистой функцией.
|
||||
|
||||
Params (default_params jsonb):
|
||||
lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25).
|
||||
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
|
||||
`params` больше ничего не настраивает: порог берётся из такта самой загрузки
|
||||
(унаследованный default_params.lag_allowance_days=25 монитора игнорируется —
|
||||
он кодировал мёртвый календарный порог). `now` инъектируется в тестах.
|
||||
|
||||
Returns counters {latest_year, latest_month, age_days, alert}.
|
||||
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
|
||||
Returns counters {latest_year, latest_month, age_days, pull_lag_days,
|
||||
max_pull_lag_days, alert}.
|
||||
mark_failed только если у оценщика нет серии вообще (нечего оценивать);
|
||||
при алерте прогон помечается done (это монитор, не сбой джобы).
|
||||
"""
|
||||
params = params or {}
|
||||
now = now or datetime.now(UTC)
|
||||
counters: dict[str, int] = {
|
||||
"latest_year": 0,
|
||||
"latest_month": 0,
|
||||
"age_days": 0,
|
||||
"pull_lag_days": -1,
|
||||
"max_pull_lag_days": 0,
|
||||
"alert": 0,
|
||||
}
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
|
||||
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
||||
latest: date | None = row.latest if row is not None else None
|
||||
if latest is None:
|
||||
found = _load_estimator_dashboard(db)
|
||||
if found is None:
|
||||
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
|
||||
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
|
||||
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
|
||||
logger.error(
|
||||
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||||
"(вторичка) — оценить свежесть нельзя",
|
||||
SBER_MONITOR_CITY,
|
||||
"sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк "
|
||||
"для region=%s (вторичка); оценить нечего",
|
||||
list(SBER_COEFF_DASHBOARDS),
|
||||
SBER_TIME_ADJUST_REGION,
|
||||
)
|
||||
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
|
||||
return counters
|
||||
|
||||
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
|
||||
max_age_days = settings.sber_index_max_age_days + lag_days
|
||||
verdict = evaluate_sber_freshness(latest, now, max_age_days)
|
||||
dashboard, latest = found
|
||||
last_pull_row = db.execute(
|
||||
_LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}
|
||||
).first()
|
||||
last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None
|
||||
verdict = evaluate_sber_freshness(
|
||||
latest,
|
||||
now,
|
||||
last_complete_pull_at=last_complete_pull_at,
|
||||
pull_interval_days=_pull_interval_days(db),
|
||||
)
|
||||
|
||||
counters = {
|
||||
"latest_year": latest.year,
|
||||
"latest_month": latest.month,
|
||||
"age_days": verdict.age_days,
|
||||
"pull_lag_days": verdict.pull_lag_days,
|
||||
"max_pull_lag_days": verdict.max_pull_lag_days,
|
||||
"alert": int(verdict.stale),
|
||||
}
|
||||
|
||||
if verdict.stale:
|
||||
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
|
||||
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
|
||||
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR).
|
||||
logger.error(
|
||||
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||||
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||||
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||||
"проверь sber_index_pull и доступность новых периодов источника",
|
||||
"sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон "
|
||||
"%s (%s суток назад, порог %d = %d такта × %d суток; "
|
||||
"status='done' с errors>0 за успех НЕ считается). "
|
||||
"Наш max(period_month)=%s (табло %s) мог разойтись с источником — "
|
||||
"проверь sber_index_pull: планировщик, сеть, /api/sowa 404",
|
||||
last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ",
|
||||
verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "∞",
|
||||
verdict.max_pull_lag_days,
|
||||
MISSED_PULL_CYCLES,
|
||||
verdict.max_pull_lag_days // MISSED_PULL_CYCLES,
|
||||
latest,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
settings.sber_index_max_age_days,
|
||||
lag_days,
|
||||
dashboard,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
|
||||
"region=%s — алерта нет",
|
||||
"sber freshness: загрузка в такте — последний полный прогон %d суток назад "
|
||||
"(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен "
|
||||
"максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, "
|
||||
"не наше отставание — алерта нет",
|
||||
verdict.pull_lag_days,
|
||||
verdict.max_pull_lag_days,
|
||||
latest,
|
||||
dashboard,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
SBER_MONITOR_CITY,
|
||||
)
|
||||
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
|
||||
"check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d "
|
||||
"pull_lag_days=%d age_days=%d",
|
||||
run_id,
|
||||
latest,
|
||||
dashboard,
|
||||
counters["alert"],
|
||||
counters["pull_lag_days"],
|
||||
counters["age_days"],
|
||||
)
|
||||
return counters
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -113,9 +113,17 @@ class TestYandexHousePriority:
|
|||
|
||||
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
|
||||
|
||||
def test_house_has_panorama_yandex_valuation_only(self) -> None:
|
||||
out = resolve_house_field("has_panorama", {"yandex_valuation": True})
|
||||
assert out is True
|
||||
def test_has_panorama_removed_from_house_priority(self) -> None:
|
||||
"""#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259).
|
||||
|
||||
В отличие от ceiling_height выше, это правило было ИСПОЛНИМО — колонка
|
||||
существовала, единственный источник её писал. Разрешать было нечего:
|
||||
yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде),
|
||||
потому что слова «панорам» на странице оценки нет вовсе.
|
||||
"""
|
||||
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
|
||||
|
||||
assert "has_panorama" not in HOUSE_FIELD_PRIORITY
|
||||
|
||||
def test_house_yandex_total_listings_yandex_valuation_only(self) -> None:
|
||||
out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42})
|
||||
|
|
@ -217,7 +225,6 @@ class TestYandexListingPriority:
|
|||
"corpus_count",
|
||||
"total_area_ha",
|
||||
"commission_month",
|
||||
"has_panorama",
|
||||
"yandex_total_listings",
|
||||
]
|
||||
for key in yandex_keys:
|
||||
|
|
|
|||
|
|
@ -781,12 +781,19 @@ def _mock_enrich_transport(
|
|||
|
||||
|
||||
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
||||
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
|
||||
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен).
|
||||
|
||||
#dadata-403-noise: это статичная конфигурация аккаунта (не транзиентный сбой) —
|
||||
логируется на WARNING (не ERROR), чтобы ERROR продолжал значить «настоящий сбой»
|
||||
(раньше — logger.error на КАЖДЫЙ пользовательский запрос, 164 события в проде).
|
||||
"""
|
||||
from app.services import dadata
|
||||
|
||||
dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle)
|
||||
|
||||
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||
with caplog.at_level(_logging.WARNING, logger="app.services.dadata"):
|
||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
|
||||
assert result is None
|
||||
|
|
@ -794,6 +801,32 @@ async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
|||
assert "Стандартизация" in text or "выключена" in text
|
||||
# Не должны обвинять токен при feature-disabled.
|
||||
assert "auth/secret rejected" not in text
|
||||
# НЕ ERROR — статичная причина, не сбой (#dadata-403-noise).
|
||||
assert not any(rec.levelno >= _logging.ERROR for rec in caplog.records)
|
||||
|
||||
|
||||
async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog) -> None:
|
||||
"""Второй (и далее) 403 CLEAN-disabled за один процесс → DEBUG, не повторный WARNING.
|
||||
|
||||
#dadata-403-noise: без троттлинга WARNING на каждый /estimate так же шумит логи,
|
||||
как раньше шумел ERROR — цель фикса теряется наполовину.
|
||||
"""
|
||||
from app.services import dadata
|
||||
|
||||
dadata._clean_disabled_warned = False
|
||||
|
||||
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.DEBUG, logger="app.services.dadata"):
|
||||
first = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
caplog.clear()
|
||||
second = await dadata.clean_address("Екатеринбург, Ленина 10")
|
||||
|
||||
assert first is None
|
||||
assert second is None
|
||||
# Второй вызов — НИ ОДНОГО WARNING/ERROR (только DEBUG или тише).
|
||||
assert not any(rec.levelno >= _logging.WARNING for rec in caplog.records)
|
||||
assert dadata._clean_disabled_warned is True
|
||||
|
||||
|
||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616).
|
||||
"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616,
|
||||
ban-history ранжирование доп. #2825 от 2026-08-13).
|
||||
|
||||
Покрытие БЕЗ live-сети/БД: FakeSession эмулирует ДВА запроса над scrape_proxies +
|
||||
scrape_proxy_source_bans — основной SELECT кандидата (`_pick_candidate`) и, только
|
||||
|
|
@ -6,11 +7,16 @@ scrape_proxy_source_bans — основной SELECT кандидата (`_pick_
|
|||
"пул пуст" от "пул не пуст, все отсеяны".
|
||||
|
||||
- выбирается небанненный прокси;
|
||||
- забаненный ДЛЯ ИСТОЧНИКА не выбирается;
|
||||
- забаненный ДЛЯ ИСТОЧНИКА (АКТИВНО, banned_until > now()) не выбирается;
|
||||
- забаненный для ДРУГОГО источника — выбирается (суть #2600 п.2: Авито банит IP,
|
||||
Яндекс через тот же IP ходит чисто);
|
||||
- при нескольких кандидатах — меньший consecutive_fails выигрывает;
|
||||
- при равном consecutive_fails — более свежий last_ok_at выигрывает;
|
||||
Яндекс через тот же IP ходит чисто) — включая случай, когда у него накопилась
|
||||
ИСТОРИЯ банов по другому source: на ранжирование ДЛЯ ТЕКУЩЕГО source это не влияет;
|
||||
- узел с историей банов (даже истёкшей) по ЭТОМУ source уступает чистому узлу без
|
||||
истории, даже когда у чистого узла хуже consecutive_fails/last_ok_at;
|
||||
- при равной истории (ban_count) — работает прежний tie-break: меньший
|
||||
consecutive_fails, затем более свежий last_ok_at;
|
||||
- активный бан (banned_until > now()) по-прежнему полностью исключает узел, вне
|
||||
зависимости от ban_count;
|
||||
- пул ПУСТ (0 строк вообще) → легитимный fallback на settings.scraper_proxy_url,
|
||||
logger.WARNING с текстом «пуст»;
|
||||
- пул пуст И SCRAPER_PROXY_URL не задан → None (прямое подключение), WARNING;
|
||||
|
|
@ -59,6 +65,14 @@ class FakeSession:
|
|||
for b in self.bans
|
||||
)
|
||||
|
||||
def _ban_count(self, pid: int, source: str) -> int:
|
||||
"""COALESCE(b.ban_count, 0) семантика LEFT JOIN — история учитывается ДАЖЕ
|
||||
если сама строка бана уже истекла (banned_until <= now(), ещё не спурженная)."""
|
||||
for b in self.bans:
|
||||
if b["proxy_id"] == pid and b["source"] == source:
|
||||
return int(b.get("ban_count", 1))
|
||||
return 0
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
sql = str(stmt)
|
||||
p = params or {}
|
||||
|
|
@ -67,7 +81,7 @@ class FakeSession:
|
|||
max_fails = p["max_fails"]
|
||||
source = p["source"]
|
||||
|
||||
if "pool_total" in sql: # _diagnose_no_candidate aggregate
|
||||
if "pool_total" in sql: # _diagnose_no_candidate aggregate (активные баны, без истории)
|
||||
unhealthy = sum(
|
||||
1 for r in self.rows if not r["enabled"] or r["consecutive_fails"] >= max_fails
|
||||
)
|
||||
|
|
@ -88,7 +102,9 @@ class FakeSession:
|
|||
]
|
||||
)
|
||||
|
||||
# _pick_candidate primary SELECT
|
||||
# _pick_candidate primary SELECT — LEFT JOIN на bans по source: активный бан
|
||||
# по-прежнему исключает узел (WHERE), а ban_count (в т.ч. от истёкшего бана)
|
||||
# ранжирует прошедших фильтр: сначала без истории (0), затем по возрастанию.
|
||||
cands = [
|
||||
r
|
||||
for r in self.rows
|
||||
|
|
@ -98,12 +114,15 @@ class FakeSession:
|
|||
]
|
||||
cands.sort(
|
||||
key=lambda r: (
|
||||
self._ban_count(r["id"], source),
|
||||
r["consecutive_fails"],
|
||||
-(r["last_ok_at"] or datetime.min.replace(tzinfo=UTC)).timestamp(),
|
||||
r["id"],
|
||||
)
|
||||
)
|
||||
return _FakeResult([dict(r) for r in cands[:1]])
|
||||
return _FakeResult(
|
||||
[{**r, "ban_count": self._ban_count(r["id"], source)} for r in cands[:1]]
|
||||
)
|
||||
|
||||
|
||||
def _proxy(
|
||||
|
|
@ -207,6 +226,155 @@ def test_tiebreak_fresher_last_ok_at_wins_on_equal_fails() -> None:
|
|||
assert result == "http://u:p@fresh.local:8080"
|
||||
|
||||
|
||||
def test_clean_node_beats_node_with_expired_ban_history_for_source() -> None:
|
||||
"""Замер на проде 2026-08-10: asocks-residential-1 отдавал 403 и cian, и avito, но
|
||||
после истечения TTL всплывал первым, потому что consecutive_fails=0 у ОБОИХ узлов
|
||||
и решал только свежий healthcheck. История (ban_count) ДОЛЖНА перевешивать даже
|
||||
когда у банившегося узла лучше consecutive_fails/last_ok_at."""
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[
|
||||
_proxy(
|
||||
1,
|
||||
consecutive_fails=0,
|
||||
last_ok_at=now, # свежее всех — раньше выиграл бы по старому правилу
|
||||
url="http://u:p@chronic.local:8080",
|
||||
),
|
||||
_proxy(
|
||||
2,
|
||||
consecutive_fails=1,
|
||||
last_ok_at=now - timedelta(hours=3),
|
||||
url="http://u:p@clean.local:8080",
|
||||
),
|
||||
],
|
||||
bans=[
|
||||
{
|
||||
"proxy_id": 1,
|
||||
"source": "avito",
|
||||
"banned_until": now - timedelta(hours=1), # ИСТЁК, но ban_count остаётся
|
||||
"ban_count": 4,
|
||||
}
|
||||
],
|
||||
)
|
||||
result = resolve_proxy_url(db, "avito")
|
||||
assert result == "http://u:p@clean.local:8080"
|
||||
|
||||
|
||||
def test_no_history_node_beats_node_with_ban_count_one() -> None:
|
||||
"""Узел БЕЗ ЕДИНОЙ строки истории (ban_count трактуется как 0) выигрывает у узла с
|
||||
ban_count=1, даже при равном consecutive_fails/last_ok_at."""
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[
|
||||
_proxy(1, consecutive_fails=0, last_ok_at=now, url="http://u:p@once-banned.local:8080"),
|
||||
_proxy(
|
||||
2, consecutive_fails=0, last_ok_at=now, url="http://u:p@never-banned.local:8080"
|
||||
),
|
||||
],
|
||||
bans=[
|
||||
{
|
||||
"proxy_id": 1,
|
||||
"source": "cian",
|
||||
"banned_until": now - timedelta(hours=2),
|
||||
"ban_count": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
result = resolve_proxy_url(db, "cian")
|
||||
assert result == "http://u:p@never-banned.local:8080"
|
||||
|
||||
|
||||
def test_equal_ban_history_falls_back_to_prior_tiebreak() -> None:
|
||||
"""При РАВНОМ ban_count у обоих узлов -- прежний порядок tie-break (consecutive_fails,
|
||||
затем last_ok_at) без изменений."""
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[
|
||||
_proxy(
|
||||
1,
|
||||
consecutive_fails=2,
|
||||
last_ok_at=now,
|
||||
url="http://u:p@flaky-history.local:8080",
|
||||
),
|
||||
_proxy(
|
||||
2,
|
||||
consecutive_fails=0,
|
||||
last_ok_at=now - timedelta(hours=1),
|
||||
url="http://u:p@solid-history.local:8080",
|
||||
),
|
||||
],
|
||||
bans=[
|
||||
{
|
||||
"proxy_id": 1,
|
||||
"source": "yandex",
|
||||
"banned_until": now - timedelta(hours=5),
|
||||
"ban_count": 2,
|
||||
},
|
||||
{
|
||||
"proxy_id": 2,
|
||||
"source": "yandex",
|
||||
"banned_until": now - timedelta(hours=5),
|
||||
"ban_count": 2,
|
||||
},
|
||||
],
|
||||
)
|
||||
result = resolve_proxy_url(db, "yandex")
|
||||
# Равный ban_count=2 у обоих -- решает consecutive_fails (0 < 2).
|
||||
assert result == "http://u:p@solid-history.local:8080"
|
||||
|
||||
|
||||
def test_ban_history_on_other_source_does_not_affect_ranking() -> None:
|
||||
"""Высокий ban_count по source=cian у узла НЕ влияет на его ранжирование для
|
||||
source=avito -- история строго per-source, ровно как активный бан (#2600 п.2)."""
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[
|
||||
_proxy(
|
||||
1,
|
||||
consecutive_fails=0,
|
||||
last_ok_at=now,
|
||||
url="http://u:p@cian-history-only.local:8080",
|
||||
),
|
||||
_proxy(
|
||||
2,
|
||||
consecutive_fails=0,
|
||||
last_ok_at=now - timedelta(hours=2),
|
||||
url="http://u:p@clean-everywhere.local:8080",
|
||||
),
|
||||
],
|
||||
bans=[
|
||||
{
|
||||
"proxy_id": 1,
|
||||
"source": "cian", # ДРУГОЙ source, не avito
|
||||
"banned_until": now - timedelta(hours=1),
|
||||
"ban_count": 9,
|
||||
}
|
||||
],
|
||||
)
|
||||
result = resolve_proxy_url(db, "avito")
|
||||
# Для avito у узла 1 ban_count=0 (истории по avito нет) -- выигрывает по last_ok_at.
|
||||
assert result == "http://u:p@cian-history-only.local:8080"
|
||||
|
||||
|
||||
def test_active_ban_still_excludes_regardless_of_ban_count() -> None:
|
||||
"""Активный бан по-прежнему полный фильтр -- ban_count=1 (низкий) не спасает узел
|
||||
с АКТИВНЫМ баном от исключения."""
|
||||
now = datetime.now(UTC)
|
||||
db = FakeSession(
|
||||
[_proxy(1, url="http://u:p@actively-banned.local:8080")],
|
||||
bans=[
|
||||
{
|
||||
"proxy_id": 1,
|
||||
"source": "avito",
|
||||
"banned_until": now + timedelta(hours=6),
|
||||
"ban_count": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
with pytest.raises(ProxyPoolExhaustedError):
|
||||
resolve_proxy_url(db, "avito")
|
||||
|
||||
|
||||
def test_empty_pool_falls_back_to_env_with_warning(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -79,3 +79,17 @@ tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
|
|||
tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
|
||||
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
|
||||
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
|
||||
|
||||
# MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `live_session` fixture
|
||||
# (self-skip через `_live_db_available()`, живёт только при реальном Postgres DSN).
|
||||
# Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка
|
||||
# физически не попадают в когорту (не только SQL-текст, который проверяется отдельным
|
||||
# статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах).
|
||||
tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live
|
||||
|
||||
# MAJOR-2 поведенческий пин (повторная проверка #2894) — та же `live_session` fixture.
|
||||
# Ловит мутацию «убрать FILTER у percentile_cont, оставив у count(*)», которую
|
||||
# текстовый тест test_max_age_outlier_days_passed_to_sql пропускал (подстрока
|
||||
# `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне
|
||||
# (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip.
|
||||
tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live
|
||||
|
|
|
|||
|
|
@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None:
|
|||
assert "DROP COLUMN IF EXISTS is_outlier" in sql
|
||||
|
||||
|
||||
_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
|
||||
|
||||
|
||||
def _latest_v_data_quality() -> tuple[str, str]:
|
||||
"""(текст последней миграции, создающей v_data_quality; тело её SELECT).
|
||||
|
||||
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
|
||||
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую
|
||||
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
|
||||
деплой применяет файлы отсортированными, последний по имени — последний в проде.
|
||||
"""
|
||||
creators = sorted(
|
||||
p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8"))
|
||||
)
|
||||
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
|
||||
sql = creators[-1].read_text(encoding="utf-8")
|
||||
hit = _VIEW_MARKER.search(sql)
|
||||
assert hit is not None
|
||||
return sql, sql[hit.end() :].split(";")[0]
|
||||
|
||||
|
||||
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
|
||||
"""Действующее определение v_data_quality (последняя миграция, которая его
|
||||
создаёт) не упоминает is_outlier.
|
||||
"""Действующее определение v_data_quality не упоминает is_outlier.
|
||||
|
||||
Red на origin/main: там последним был 095_dead_schema.sql со строкой
|
||||
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
|
||||
— показатель, который не мог быть ненулевым, потому что колонку не писал никто.
|
||||
|
||||
Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
|
||||
DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту
|
||||
миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
|
||||
деплой применяет файлы отсортированными, последний по имени — последний в проде.
|
||||
"""
|
||||
marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
|
||||
creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8")))
|
||||
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
|
||||
latest = creators[-1].read_text(encoding="utf-8")
|
||||
hit = marker.search(latest)
|
||||
assert hit is not None
|
||||
body = latest[hit.end() :].split(";")[0]
|
||||
_, body = _latest_v_data_quality()
|
||||
assert "outliers_flagged" not in body
|
||||
assert "is_outlier" not in body
|
||||
|
||||
|
||||
def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None:
|
||||
"""Тот же класс, третий случай: pct_cadastr (мигр. 259).
|
||||
|
||||
Считался по listings.cadastral_number — кадастру КВАРТИРЫ, которого не отдаёт ни
|
||||
одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому
|
||||
показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61%
|
||||
читался как измеренное качество данных.
|
||||
|
||||
Red на origin/main: последний DDL там — 222_db_audit_cleanup.sql, в нём строка
|
||||
`... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте.
|
||||
|
||||
Замена источника на listings.building_cadastral_number — НЕ починка: та колонка
|
||||
про ЗДАНИЕ и целиком производная нашего ночного KNN ≤50 м, который #2674
|
||||
замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление
|
||||
в этой витрине.
|
||||
"""
|
||||
sql, body = _latest_v_data_quality()
|
||||
assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality"
|
||||
assert "cadastral_number" not in body, (
|
||||
"в витрину подставили другой кадастр — под подписью «доля объявлений с "
|
||||
"кадастром» это новая ложь вместо старой (см. шапку 259)"
|
||||
)
|
||||
# DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана
|
||||
# выставить его заново, иначе объяснение «почему показателя нет» молча теряется.
|
||||
if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql):
|
||||
assert "COMMENT ON VIEW v_data_quality" in sql
|
||||
|
|
|
|||
226
tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
Normal file
226
tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры.
|
||||
|
||||
Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient`
|
||||
(«требует ремонта» −6%, «евро» +10%), а домовой якорь из `house_imv_evaluations`
|
||||
запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15`
|
||||
и сам blend клали два разных базиса на одну шкалу — и, поскольку blend
|
||||
однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало
|
||||
половину его −6% обратно наверх.
|
||||
|
||||
Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни
|
||||
разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог
|
||||
пересекали 19 — 9 из них «требует ремонта», и на них старый код давал медиану
|
||||
на 3.3–9.8 % выше базис-согласованной (суммарно +4.13 млн ₽).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
from app.services.estimator import _fetch_house_imv_anchor
|
||||
|
||||
# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки
|
||||
# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и
|
||||
# упали на ЦИФРАХ (красный по существу), а не на collection error.
|
||||
|
||||
# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽.
|
||||
_PPM2 = 150_000.0
|
||||
_AREA = 40.0
|
||||
_BASE_MEDIAN = int(_PPM2 * _AREA)
|
||||
|
||||
|
||||
def _make_listing(price_per_m2: float) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(price_per_m2),
|
||||
"source": "avito",
|
||||
"address": "ЕКБ, ул. Учителей, 18",
|
||||
"price_rub": int(price_per_m2 * _AREA),
|
||||
"price_per_m2": price_per_m2,
|
||||
"area_m2": _AREA,
|
||||
"rooms": 1,
|
||||
"floor": 4,
|
||||
"total_floors": 16,
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"listing_date": None,
|
||||
"days_on_market": None,
|
||||
"source_url": f"https://example.test/{int(price_per_m2)}",
|
||||
"photo_urls": [],
|
||||
"distance_m": 10.0,
|
||||
}
|
||||
|
||||
|
||||
_ANALOGS = [
|
||||
_make_listing(135_000.0),
|
||||
_make_listing(140_000.0),
|
||||
_make_listing(145_000.0),
|
||||
_make_listing(_PPM2),
|
||||
_make_listing(155_000.0),
|
||||
_make_listing(160_000.0),
|
||||
_make_listing(165_000.0),
|
||||
]
|
||||
|
||||
|
||||
def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any:
|
||||
"""estimate_quality со всеми I/O застабленными; домовой якорь форсирован."""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.estimator import estimate_quality
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Учителей, 18",
|
||||
area_m2=_AREA,
|
||||
rooms=1,
|
||||
floor=4,
|
||||
total_floors=16,
|
||||
repair_state=repair_state,
|
||||
)
|
||||
geo = GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(_ANALOGS), False, "S"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(0.8, "per_rooms")),
|
||||
patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
|
||||
):
|
||||
return await estimate_quality(payload, MagicMock())
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"recommended_price": recommended,
|
||||
"lower_price": int(recommended * 0.97),
|
||||
"higher_price": int(recommended * 1.05),
|
||||
"market_count": 500,
|
||||
"rooms": 1,
|
||||
"area_m2": _AREA,
|
||||
"renovation_type": renovation,
|
||||
}
|
||||
|
||||
|
||||
# ── чистая функция ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_repair_factor_is_ratio_of_the_same_coefficients() -> None:
|
||||
"""Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое."""
|
||||
from app.services.estimator import _anchor_repair_factor
|
||||
|
||||
# строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94)
|
||||
assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94
|
||||
# строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00)
|
||||
assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05
|
||||
# строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10)
|
||||
assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94
|
||||
|
||||
|
||||
def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None:
|
||||
"""Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать».
|
||||
|
||||
Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном
|
||||
ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в
|
||||
1.0 при дрейфе вокабуляра тихо вернул бы дефект — поэтому базис, а не no-op.
|
||||
"""
|
||||
from app.services.estimator import _anchor_repair_factor
|
||||
|
||||
assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94
|
||||
assert _anchor_repair_factor(None, "needs_repair") == 0.94
|
||||
# Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента.
|
||||
assert _anchor_repair_factor(None, None) == 1.0
|
||||
assert _anchor_repair_factor("cosmetic", None) == 1.0
|
||||
assert _anchor_repair_factor(None, "standard") == 1.0
|
||||
|
||||
|
||||
# ── SQL-граница: без колонки правка молча выродится в no-op ──────────────────
|
||||
|
||||
|
||||
def test_anchor_query_selects_renovation_type() -> None:
|
||||
"""`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0.
|
||||
|
||||
Проверка строковая намеренно — это единственная точка, где видно, доедет ли
|
||||
колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону
|
||||
границы получает dict уже от вызывающего и такую регрессию не увидит.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = None
|
||||
_fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA)
|
||||
sql = str(db.execute.call_args[0][0])
|
||||
assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0]
|
||||
|
||||
|
||||
# ── сквозь estimate_quality: цена, которую видит пользователь ────────────────
|
||||
|
||||
|
||||
def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
|
||||
"""«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт.
|
||||
|
||||
median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000
|
||||
— якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000.
|
||||
Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000
|
||||
< 6 486 000 → медиана остаётся 5 640 000 (−8.6 % к старому поведению).
|
||||
"""
|
||||
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
|
||||
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
|
||||
assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
|
||||
"""Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен.
|
||||
|
||||
median = 6 000 000 (coef 1.0). Старый порог 6 900 000 — якорь 7 000 000 его
|
||||
перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro'
|
||||
стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 → медиана не двигается.
|
||||
"""
|
||||
est = _run_estimate(_anchor(7_000_000, "euro"), None)
|
||||
assert est.median_price_rub == _BASE_MEDIAN
|
||||
|
||||
|
||||
def test_blend_still_fires_and_reports_the_rebased_anchor() -> None:
|
||||
"""Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится.
|
||||
|
||||
median = 5 640 000, якорь 'cosmetic' 10 000 000 → в базисе клиента 9 400 000
|
||||
> 6 486 000 → blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении —
|
||||
ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы
|
||||
с карточкой, где показана сырая оценка Avito).
|
||||
"""
|
||||
est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair")
|
||||
assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2
|
||||
explanation = est.confidence_explanation or ""
|
||||
assert "9.4 млн ₽" in explanation
|
||||
assert "приведённой к состоянию ремонта квартиры" in explanation
|
||||
# Карточка Avito остаётся сырой — чужое число мы не правим.
|
||||
assert est.avito_imv is not None
|
||||
assert est.avito_imv.recommended_price == 10_000_000
|
||||
86
tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
Normal file
86
tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""#2687: распознанный QRATOR-блок домкликового свипа — это 'platform', не 'unknown'.
|
||||
|
||||
Ветка `if counters.blocked:` в `run_domclick_city_sweep` входится ТОЛЬКО когда
|
||||
скрейпер поднял `DomClickBlockedError` (площадка отдала QRATOR block-страницу). Это
|
||||
ровно определение `BAN_KIND_PLATFORM`. До правки свип звал `mark_banned` без
|
||||
`ban_kind`, и все его баны падали в дефолт 'unknown' (#2764) — диагноз был
|
||||
УСТАНОВЛЕН (блок распознан), но не передавался, и мониторинг не мог отличить
|
||||
«площадка отбила» (platform) от «тракт/прокси» (infra).
|
||||
|
||||
Прод 2026-08-12: domclick_city_sweep 3751/3675/3594 все `banned` с `blocked=1`,
|
||||
`ban_kind='unknown'`. На старом коде тест ниже падает (ban_kind='unknown').
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
|
||||
|
||||
PFX = "scraper_kit.orchestration.pipeline"
|
||||
|
||||
|
||||
class _BanKindRecorder:
|
||||
"""Ловит ban_kind, переданный в mark_banned (остальное — no-op)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.ban_kind: str | None = None
|
||||
self.marked: list[str] = []
|
||||
|
||||
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||
self.marked.append("done")
|
||||
|
||||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||||
self.marked.append("failed")
|
||||
|
||||
def mark_banned(
|
||||
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
|
||||
) -> None:
|
||||
self.marked.append("banned")
|
||||
self.ban_kind = kw.get("ban_kind")
|
||||
|
||||
|
||||
async def _drive_blocked() -> _BanKindRecorder:
|
||||
recorder = _BanKindRecorder()
|
||||
scraper = MagicMock()
|
||||
scraper.__aenter__ = AsyncMock(return_value=scraper)
|
||||
scraper.__aexit__ = AsyncMock(return_value=None)
|
||||
scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)])
|
||||
scraper.blocked = True # распознанный QRATOR-блок
|
||||
scraper.geo_filtered = 0
|
||||
scraper.fetch_errors = 1
|
||||
scraper.buckets_completed = 2
|
||||
scraper.buckets_total = 6
|
||||
with (
|
||||
patch(f"{PFX}.DomClickScraper", return_value=scraper),
|
||||
patch(f"{PFX}.save_listings", MagicMock(return_value=(39, 0))),
|
||||
patch(f"{PFX}.runs", recorder),
|
||||
):
|
||||
await run_domclick_city_sweep(
|
||||
MagicMock(),
|
||||
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
|
||||
matcher=MagicMock(),
|
||||
run_id=1,
|
||||
city_id=4,
|
||||
pages=1,
|
||||
request_delay_sec=0.0,
|
||||
)
|
||||
return recorder
|
||||
|
||||
|
||||
async def test_qrator_block_marks_platform_ban_kind() -> None:
|
||||
"""Блок распознан → ban_kind='platform'. На старом коде было 'unknown' (дефолт)."""
|
||||
recorder = await _drive_blocked()
|
||||
assert recorder.marked[-1] == "banned"
|
||||
assert recorder.ban_kind == "platform"
|
||||
|
|
@ -153,28 +153,38 @@ async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.Monke
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "must_contain"),
|
||||
("source", "must_contain", "path_suffix"),
|
||||
[
|
||||
("avito", "www.avito.ru"),
|
||||
("cian", "ekb.cian.ru"),
|
||||
("yandex", "realty.yandex.ru"),
|
||||
("avito", "www.avito.ru", "/robots.txt"),
|
||||
("cian", "ekb.cian.ru", "/robots.txt"),
|
||||
("yandex", "realty.yandex.ru", "/robots.txt"),
|
||||
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
|
||||
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
|
||||
("domclick", "bff-search-web.domclick.ru"),
|
||||
#
|
||||
# #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*,
|
||||
# robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4
|
||||
# в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по
|
||||
# нему зелёная ровно тогда, когда свип получает блок.
|
||||
("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"),
|
||||
],
|
||||
)
|
||||
async def test_probe_asks_the_working_host_of_each_source(
|
||||
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
|
||||
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str
|
||||
) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
# Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» —
|
||||
# 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа.
|
||||
_body = (
|
||||
'{"result":{"snippetsCount":678}}' if source == "domclick" else "<pre>User-agent: *</pre>"
|
||||
)
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = '{"html": "<pre>User-agent: *</pre>"}'
|
||||
text = "{}"
|
||||
|
||||
@staticmethod
|
||||
def json() -> dict[str, str]:
|
||||
return {"html": "<pre>User-agent: *</pre>"}
|
||||
return {"html": _body}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, **_kw: Any) -> None: ...
|
||||
|
|
@ -196,7 +206,10 @@ async def test_probe_asks_the_working_host_of_each_source(
|
|||
|
||||
assert ok is True
|
||||
assert must_contain in seen["payload"]["url"]
|
||||
assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
|
||||
# #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это
|
||||
# боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть
|
||||
# нагрузка на площадку остаётся минимальной, а защита — той же, что у работы.
|
||||
assert path_suffix in seen["payload"]["url"]
|
||||
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
|
||||
assert seen["payload"]["source"] == "generic"
|
||||
|
||||
|
|
@ -233,7 +246,10 @@ async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.Monkey
|
|||
assert ok is False
|
||||
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
|
||||
assert fail_kind == "page"
|
||||
assert "not robots.txt" in detail
|
||||
# #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от
|
||||
# площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика).
|
||||
# Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили.
|
||||
assert "no marker 'User-agent'" in detail
|
||||
|
||||
|
||||
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -454,3 +470,72 @@ async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPat
|
|||
lease = acquire(db, "domclick") # type: ignore[arg-type]
|
||||
assert lease is not None
|
||||
release(db, lease.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ──
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_body", "expect_ok"),
|
||||
[
|
||||
# Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом.
|
||||
# На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True,
|
||||
# то есть «пара здорова» ровно там, где свип получает блок. Красный на main.
|
||||
("<html><title>Ошибка</title>qrator captcha</html>", False),
|
||||
# Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность
|
||||
# ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай
|
||||
# и провалила бы этот, то есть тест не может позеленеть от глухого отказа.
|
||||
# На origin/main этот случай тоже красный, но по другой причине — там проба
|
||||
# спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА.
|
||||
('{"result":{"snippetsCount":678}}', True),
|
||||
],
|
||||
)
|
||||
async def test_domclick_probe_sees_the_block_that_robots_txt_hides(
|
||||
monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool
|
||||
) -> None:
|
||||
"""Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок.
|
||||
|
||||
Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип
|
||||
`QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и
|
||||
5 банов на все четыре площадки при ежедневном блоке Домклика.
|
||||
"""
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = "{}"
|
||||
|
||||
def __init__(self, body: str) -> None:
|
||||
self._body = body
|
||||
|
||||
def json(self) -> dict[str, str]:
|
||||
return {"html": self._body}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, **_kw: Any) -> None: ...
|
||||
|
||||
async def __aenter__(self) -> _Client:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
|
||||
seen["payload"] = json
|
||||
asked = str(json["url"])
|
||||
# Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт.
|
||||
if asked.endswith("/robots.txt"):
|
||||
return _Resp("<pre>User-agent: *</pre>")
|
||||
return _Resp(api_body)
|
||||
|
||||
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
|
||||
ok, fail_kind, _detail = await bf.probe_proxy_via_browser(
|
||||
"http://tradein-browser:3000", "http://u:p@node:8080", source="domclick"
|
||||
)
|
||||
|
||||
assert ok is expect_ok
|
||||
if not expect_ok:
|
||||
# Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy".
|
||||
assert fail_kind == "page"
|
||||
# Спрашивали именно боевой путь, а не robots.txt того же хоста.
|
||||
assert "/api/offers/" in seen["payload"]["url"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674).
|
||||
|
||||
`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо
|
||||
в определении: district, distance_to_metro_m, last_price_change, photos_count.
|
||||
Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию
|
||||
не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты)
|
||||
и они сняты миграцией 261; district оставлен намеренно — он объявлен в
|
||||
schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее
|
||||
изменение контракта (решение владельца, вынесено отдельно в #2857).
|
||||
|
||||
Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её
|
||||
актуального определения (самый старший NN среди файлов, создающих витрину) и
|
||||
смотрит на состав списка. Переформатирование SQL, перестановка строк или смена
|
||||
`NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит.
|
||||
|
||||
Без БД и сети: миграции читаются как текст, разбираются в структуру.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
|
||||
MV = "listings_search_mv"
|
||||
# Сняты 261: ноль читателей во всём репозитории на момент сноса.
|
||||
DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count")
|
||||
|
||||
|
||||
def _strip_sql_comments(sql: str) -> str:
|
||||
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
|
||||
return re.sub(r"--[^\n]*", "", sql)
|
||||
|
||||
|
||||
def _latest_definition() -> str:
|
||||
"""Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид."""
|
||||
creators = [
|
||||
p
|
||||
for p in SQL_DIR.glob("*.sql")
|
||||
if re.search(
|
||||
rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b",
|
||||
_strip_sql_comments(p.read_text("utf-8")),
|
||||
re.I,
|
||||
)
|
||||
]
|
||||
assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда"
|
||||
return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8")
|
||||
|
||||
|
||||
def mv_columns() -> list[str]:
|
||||
"""Имена колонок витрины в порядке объявления.
|
||||
|
||||
Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым
|
||||
той же глубины, имя колонки — последний идентификатор элемента (алиас после
|
||||
`AS` либо хвост `l.foo`).
|
||||
"""
|
||||
sql = _strip_sql_comments(_latest_definition())
|
||||
body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1]
|
||||
|
||||
depth, items, cur = 0, [], []
|
||||
for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I):
|
||||
t = token.group(0)
|
||||
if t == "(":
|
||||
depth += 1
|
||||
elif t == ")":
|
||||
depth -= 1
|
||||
elif depth == 0 and t == ",":
|
||||
items.append("".join(cur))
|
||||
cur = []
|
||||
continue
|
||||
elif depth == 0 and t.upper() == "FROM":
|
||||
break
|
||||
cur.append(t)
|
||||
items.append("".join(cur))
|
||||
|
||||
return [item.split()[-1].split(".")[-1] for item in items if item.split()]
|
||||
|
||||
|
||||
def test_placeholder_columns_are_gone_from_the_matview() -> None:
|
||||
"""Red => витрина снова обещает поля, которых не заполняет (#2857).
|
||||
|
||||
Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел
|
||||
после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот.
|
||||
"""
|
||||
cols = mv_columns()
|
||||
still_there = [c for c in DROPPED if c in cols]
|
||||
assert not still_there, (
|
||||
f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя "
|
||||
"читается снаружи как «данные есть, просто у этого объекта пусто» — это "
|
||||
"хуже мёртвого кода, потому что видно в контракте."
|
||||
)
|
||||
|
||||
|
||||
def test_district_is_deliberately_kept() -> None:
|
||||
"""Red => district снесли заодно, а он в схеме ответа API.
|
||||
|
||||
schemas/search_response.py объявляет `district: str | None`, services/search_query.py
|
||||
его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857),
|
||||
а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа.
|
||||
"""
|
||||
assert "district" in mv_columns(), (
|
||||
f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: "
|
||||
"ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой."
|
||||
)
|
||||
|
||||
|
||||
def test_search_api_selects_only_columns_the_matview_has() -> None:
|
||||
"""Настоящий инвариант: то, что просит API, витрина обязана иметь.
|
||||
|
||||
Именно эта проверка отличает «список колонок» от «поиска подстроки»: она
|
||||
краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх
|
||||
известных именах.
|
||||
"""
|
||||
from app.schemas.search import SearchParams
|
||||
from app.services.search_query import build_search_query
|
||||
|
||||
sql, _ = build_search_query(SearchParams())
|
||||
selected = [
|
||||
c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip()
|
||||
]
|
||||
missing = [c for c in selected if c not in mv_columns()]
|
||||
assert not missing, (
|
||||
f"services/search_query.py просит у {MV} колонки, которых в её определении нет: "
|
||||
f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из "
|
||||
"schemas/search_response.py."
|
||||
)
|
||||
|
||||
|
||||
def test_unique_index_for_concurrent_refresh_survives_recreation() -> None:
|
||||
"""Red => ночной REFRESH ... CONCURRENTLY упадёт.
|
||||
|
||||
app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание
|
||||
refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает
|
||||
«cannot refresh materialized view concurrently ... no unique index» — а витрина,
|
||||
которую пересоздали и забыли проиндексировать, молчит до самой ночи.
|
||||
"""
|
||||
sql = _strip_sql_comments(_latest_definition())
|
||||
assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), (
|
||||
f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — "
|
||||
"REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен."
|
||||
)
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона.
|
||||
|
||||
#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket
|
||||
heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), —
|
||||
но единственным входом оставил админку. У avito full-load админского эндпоинта нет
|
||||
вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799
|
||||
на origin/main). То есть боевой путь возобновления не существовал ни одного дня.
|
||||
|
||||
Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных
|
||||
прогонах с ЖИВОЙ незабранной точкой — avito_full_load 242, cian_full_load 134,
|
||||
avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из
|
||||
84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08.
|
||||
|
||||
Красный прогон на origin/main:
|
||||
1. `test_scheduler_hands_checkpoint_to_pipeline` — планировщик отдаёт в пайплайн
|
||||
resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках);
|
||||
2. `test_partial_bucket_is_not_complete` — бакет с выпавшей страницей приезжает в
|
||||
on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще);
|
||||
3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` — TypeError: `_on_bucket`
|
||||
на main принимает два аргумента, признаку полноты некуда приехать.
|
||||
Тесты ладдера (`_resume_decision`) на main падают с AttributeError — функции нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.orchestration import scheduler as sched
|
||||
from scraper_kit.orchestration.pipeline import run_avito_full_load
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
|
||||
PFX = "scraper_kit.orchestration.pipeline"
|
||||
|
||||
# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона
|
||||
# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание».
|
||||
_PARAMS = {
|
||||
"concurrency": 1,
|
||||
"interval_days": 7,
|
||||
"secondary_only": True,
|
||||
"request_delay_sec": 7.0,
|
||||
"price_cap_per_bucket": 1400,
|
||||
}
|
||||
|
||||
|
||||
def _candidate(**over: Any) -> SimpleNamespace:
|
||||
"""Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде."""
|
||||
base = {
|
||||
"prev_id": 3547,
|
||||
"prev_status": "cancelled",
|
||||
"prev_counters": {
|
||||
"unique_fetched": 5496,
|
||||
"done_buckets": [f"room_1_komn:{i}:0" for i in range(35)],
|
||||
},
|
||||
"same_params": True,
|
||||
"age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08
|
||||
"interval_days": "7",
|
||||
}
|
||||
base.update(over)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
"""Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE."""
|
||||
|
||||
def __init__(self, row: Any) -> None:
|
||||
self.row = row
|
||||
self.written: list[dict[str, Any]] = []
|
||||
|
||||
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||
if params and "counters" in params: # update_heartbeat пишет вердикт
|
||||
self.written.append(params)
|
||||
return MagicMock()
|
||||
return MagicMock(fetchone=lambda: self.row)
|
||||
|
||||
def commit(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── 1. Главное: планировщик обязан отдать точку в пайплайн ───────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job", "pipeline_fn"),
|
||||
[
|
||||
(sched._job_avito_full_load, "run_avito_full_load"),
|
||||
(sched._job_avito_full_load_exhaustive, "run_avito_full_load"),
|
||||
(sched._job_cian_full_load, "run_cian_full_load"),
|
||||
],
|
||||
)
|
||||
async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None:
|
||||
"""Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля.
|
||||
|
||||
Падает на origin/main: планировщик передаёт литеральный None — 433 корзины за 90
|
||||
суток перебирались заново, включая 35 корзин прогона 3547.
|
||||
"""
|
||||
db = _FakeDb(_candidate())
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _spy(*_a: Any, **kw: Any) -> None:
|
||||
captured.update(kw)
|
||||
|
||||
with patch.object(sched, pipeline_fn, _spy):
|
||||
await job(db, 4000, dict(_PARAMS), MagicMock())
|
||||
|
||||
assert captured["resume_run_id"] == 3547
|
||||
|
||||
|
||||
async def test_verdict_lands_in_counters_of_new_run() -> None:
|
||||
"""Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах.
|
||||
|
||||
Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому
|
||||
молчаливый отказ подхватить неотличим от отсутствия правки.
|
||||
"""
|
||||
db = _FakeDb(_candidate(prev_status="zombie"))
|
||||
with patch.object(sched, "run_avito_full_load", AsyncMock()):
|
||||
await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock())
|
||||
|
||||
assert db.written, "вердикт о подхвате не записан в counters нового прогона"
|
||||
written = db.written[-1]["counters"]
|
||||
assert '"resume_reason": "status_zombie"' in written
|
||||
assert '"resume_candidate": 3547' in written
|
||||
|
||||
|
||||
# ── 2. Ладдер отказов: у каждого нуля своя причина ───────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("row", "reason"),
|
||||
[
|
||||
(None, "no_prev_run"),
|
||||
(_candidate(prev_status="done"), "status_done"),
|
||||
(_candidate(prev_status="zombie"), "status_zombie"),
|
||||
(_candidate(same_params=False), "params_changed"),
|
||||
(_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"),
|
||||
(_candidate(age_h=200.0), "checkpoint_stale"),
|
||||
(_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"),
|
||||
],
|
||||
)
|
||||
def test_resume_refusals_are_named(row: Any, reason: str) -> None:
|
||||
"""«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы."""
|
||||
resume_id, verdict = sched._resume_decision(row)
|
||||
assert resume_id is None
|
||||
assert verdict["resume_reason"] == reason
|
||||
assert verdict["resume_from"] is None
|
||||
|
||||
|
||||
def test_resume_chain_is_bounded() -> None:
|
||||
"""Цепочка возобновлений считается и упирается в потолок, а не тянется вечно.
|
||||
|
||||
Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход
|
||||
обязан начаться раньше, чем сводка объявит источник просроченным.
|
||||
"""
|
||||
assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1
|
||||
_id, first = sched._resume_decision(_candidate())
|
||||
assert first["resume_chain"] == 1
|
||||
_id2, second = sched._resume_decision(
|
||||
_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1})
|
||||
)
|
||||
assert second["resume_chain"] == sched._MAX_RESUME_CHAIN
|
||||
third_id, third = sched._resume_decision(
|
||||
_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2})
|
||||
)
|
||||
assert third_id is None and third["resume_reason"] == "chain_limit"
|
||||
|
||||
|
||||
def test_stale_threshold_follows_the_source_tick() -> None:
|
||||
"""Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой.
|
||||
|
||||
cian ходит раз в 3 суток, avito — раз в 7; одна и та же точка возрастом 100 ч для
|
||||
первого просрочена, для второго свежая. Плюс сутки — сетка запуска (см.
|
||||
_resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе
|
||||
точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) — нет.
|
||||
"""
|
||||
assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None
|
||||
assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547
|
||||
assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547
|
||||
assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None
|
||||
|
||||
|
||||
# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ─────────────────
|
||||
|
||||
|
||||
def _serp_config() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
scraper_fetch_mode="curl_cffi",
|
||||
browser_http_endpoint="http://browser.test/fetch",
|
||||
scraper_proxy_url=None,
|
||||
avito_proxy_max_rotations=0,
|
||||
avito_serp_ok_not_banned=True,
|
||||
avito_proxy_rotate_settle_s=0.0,
|
||||
proxy_rotate_attempts=1,
|
||||
proxy_rotate_attempt_timeout_s=1.0,
|
||||
scraper_skip_seen_today=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("page2_html", "expected_complete"),
|
||||
[(None, False), ("<page2/>", True)],
|
||||
)
|
||||
async def test_partial_bucket_is_not_complete(
|
||||
page2_html: str | None, expected_complete: bool
|
||||
) -> None:
|
||||
"""Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан».
|
||||
|
||||
Контрольная половина обязательна: реализация «всегда False» тоже прошла бы
|
||||
одностороннюю проверку, но убила бы возобновление целиком.
|
||||
|
||||
Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты
|
||||
в протоколе нет — частичный бакет неотличим от целого и попадает в done_buckets.
|
||||
"""
|
||||
scraper = AvitoScraper(_serp_config())
|
||||
scraper.request_delay_sec = 0.0
|
||||
calls: list[tuple[str, bool]] = []
|
||||
|
||||
def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
|
||||
calls.append((key, complete))
|
||||
|
||||
async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None:
|
||||
return page2_html if page == 2 else f"<page{page}/>"
|
||||
|
||||
with (
|
||||
patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page),
|
||||
patch.object(
|
||||
AvitoScraper,
|
||||
"_parse_html",
|
||||
lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")],
|
||||
),
|
||||
):
|
||||
await scraper._paginate_leaf_bucket(
|
||||
room_slug="kvartiry_1_komnatnye",
|
||||
room_label="room_1_komn",
|
||||
lo=0,
|
||||
hi=3999999,
|
||||
html="<page1/>",
|
||||
max_pages=3,
|
||||
seen={},
|
||||
price_cap_per_bucket=1400,
|
||||
max_pages_per_bucket=100,
|
||||
concurrency=2,
|
||||
secondary_only=False,
|
||||
on_bucket=_on_bucket,
|
||||
skip_buckets=None,
|
||||
expected_total=3 * 50,
|
||||
)
|
||||
|
||||
assert [c[1] for c in calls] == [expected_complete]
|
||||
|
||||
|
||||
async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None:
|
||||
"""Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал.
|
||||
|
||||
Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить
|
||||
частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда.
|
||||
"""
|
||||
finals: list[dict[str, Any]] = []
|
||||
|
||||
class _Recorder:
|
||||
def is_cancelled(self, *_a: Any, **_k: Any) -> bool:
|
||||
return False
|
||||
|
||||
def update_heartbeat(self, *_a: Any, **_k: Any) -> None:
|
||||
pass
|
||||
|
||||
def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None:
|
||||
finals.append(dict(counters))
|
||||
|
||||
async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
|
||||
on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True)
|
||||
on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False)
|
||||
|
||||
scraper = MagicMock()
|
||||
scraper.__aenter__ = AsyncMock(return_value=scraper)
|
||||
scraper.__aexit__ = AsyncMock(return_value=None)
|
||||
scraper.fetch_all_secondary = _fetch
|
||||
|
||||
with (
|
||||
patch(f"{PFX}.AvitoScraper", return_value=scraper),
|
||||
patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
|
||||
patch(f"{PFX}.runs", _Recorder()),
|
||||
):
|
||||
counters = await run_avito_full_load(
|
||||
MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock()
|
||||
)
|
||||
|
||||
assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"]
|
||||
assert finals[0]["partial_buckets"] == 1
|
||||
assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены"
|
||||
|
|
@ -102,14 +102,32 @@ def test_harness_itself_drops_warnings() -> None:
|
|||
|
||||
|
||||
class _FakeMonitorDB:
|
||||
"""Session-мок мониторов свежести: один SELECT max(...)."""
|
||||
"""Session-мок мониторов свежести.
|
||||
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает
|
||||
ещё время последнего ПОЛНОГО прогона загрузки и её такт — именно они, а не
|
||||
календарный возраст периода, решают, быть ли тревоге.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
latest: date | None,
|
||||
last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC),
|
||||
interval_days: str = "7",
|
||||
) -> None:
|
||||
self._latest = latest
|
||||
self._last_pull = last_pull
|
||||
self._interval_days = interval_days
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||
sql = str(stmt)
|
||||
result = MagicMock()
|
||||
result.first.return_value = MagicMock(latest=self._latest)
|
||||
if "scrape_runs" in sql:
|
||||
result.first.return_value = MagicMock(last_pull=self._last_pull)
|
||||
elif "scrape_schedules" in sql:
|
||||
result.first.return_value = MagicMock(interval_days=self._interval_days)
|
||||
else:
|
||||
result.first.return_value = MagicMock(latest=self._latest)
|
||||
return result
|
||||
|
||||
def rollback(self) -> None:
|
||||
|
|
@ -122,10 +140,13 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch, module: Any) -> None:
|
|||
monkeypatch.setattr(module.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||
|
||||
|
||||
def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Прод-состояние (9 срабатываний, ноль событий): застой бенчмарка → событие."""
|
||||
def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Загрузка встала (полный прогон 20 суток назад при такте 7) → событие.
|
||||
|
||||
20 суток — реальный разрыв прод-истории между полными прогонами 07-17 и 08-06.
|
||||
"""
|
||||
_patch_runs(monkeypatch, sber_mon)
|
||||
db = _FakeMonitorDB(date(2026, 6, 1))
|
||||
db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC))
|
||||
with glitchtip_events() as events:
|
||||
out = sber_mon.check_sber_freshness(
|
||||
db, # type: ignore[arg-type]
|
||||
|
|
@ -136,19 +157,24 @@ def test_sber_staleness_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
assert out["alert"] == 1
|
||||
assert any(
|
||||
"sber freshness" in t for t in event_texts(events)
|
||||
), "устаревание СберИндекса не стало событием — WARNING до GlitchTip не долетает"
|
||||
), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает"
|
||||
|
||||
|
||||
def test_sber_fresh_index_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Свежие данные — ни одного события (иначе алерт-усталость)."""
|
||||
def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет.
|
||||
|
||||
Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR:
|
||||
возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость
|
||||
от таких событий и делает настоящий отказ незаметным.
|
||||
"""
|
||||
_patch_runs(monkeypatch, sber_mon)
|
||||
db = _FakeMonitorDB(date(2026, 6, 1))
|
||||
db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон)
|
||||
with glitchtip_events() as events:
|
||||
out = sber_mon.check_sber_freshness(
|
||||
db, # type: ignore[arg-type]
|
||||
run_id=2,
|
||||
params={},
|
||||
now=datetime(2026, 6, 20, tzinfo=UTC),
|
||||
now=datetime(2026, 8, 12, 19, 6, tzinfo=UTC),
|
||||
)
|
||||
assert out["alert"] == 0
|
||||
assert event_texts(events) == []
|
||||
|
|
@ -165,7 +191,7 @@ def test_sber_empty_index_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None
|
|||
params={},
|
||||
now=datetime(2026, 8, 6, tzinfo=UTC),
|
||||
)
|
||||
assert any("sber_price_index пуст" in t for t in event_texts(events))
|
||||
assert any("у оценщика нет серии" in t for t in event_texts(events))
|
||||
|
||||
|
||||
def test_deals_empty_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,17 @@
|
|||
1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30
|
||||
(494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done').
|
||||
|
||||
Проверяем ровно ветвление mark_backfill_finished — БД замокана.
|
||||
Проверяем ровно ветвление mark_backfill_finished — БД замокана (mark_done/mark_failed/
|
||||
mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы
|
||||
ниже с высокой долей отказов (attempted=50, failed=38 или 36 — 76%/72%), ожидающие
|
||||
'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674:
|
||||
"обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С
|
||||
honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed'
|
||||
через _failed_ratio_too_high (доля отказов >= 0.5) — реальный терминальный статус
|
||||
для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется
|
||||
отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py
|
||||
(test_prod_fact_avito_15_08_no_longer_done и соседние) — не читай эти два кейса как
|
||||
"76%/72% отказов = 'done' в проде".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -59,9 +69,15 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st
|
|||
({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"),
|
||||
# Кандидатов не было — честная пустота, это успех.
|
||||
({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"),
|
||||
# Частичный прогон: обогатили хоть что-то → успех.
|
||||
# Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ
|
||||
# mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done
|
||||
# замокан, поэтому статус остаётся 'done'; в реальном mark_done с
|
||||
# honest-run-status (2026-08-15) это переквалифицируется в 'failed'
|
||||
# (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля.
|
||||
({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"),
|
||||
# Блоки были, но прогон доработал и обогатил — не бан.
|
||||
# Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает
|
||||
# НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done
|
||||
# переквалифицирует в 'failed', см. докстринг модуля выше.
|
||||
({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"),
|
||||
# Блок оборвал прогон, хотя часть успели обогатить — работа не доделана.
|
||||
({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"),
|
||||
|
|
|
|||
|
|
@ -31,20 +31,41 @@ def test_ekb_anchors_count() -> None:
|
|||
|
||||
|
||||
def test_resolve_city_name_known_oblast_slugs() -> None:
|
||||
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя."""
|
||||
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name
|
||||
"""Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.
|
||||
|
||||
expected = {
|
||||
#262 wave 2: CITY_LOCATIONS выросла с 5 (wave 1) до 45 (wave 1 + 40 wave-2
|
||||
городов) — вместо хардкода полного списка (дублировал бы CITY_DISPLAY_NAMES и
|
||||
ломался при каждом новом городе) проверяем структурный инвариант: CITY_DISPLAY_
|
||||
NAMES обязан покрывать РОВНО те же slug'и, что CITY_LOCATIONS (иначе oblast-город
|
||||
бы тихо получил ЕКБ-дефолт вместо своего имени) + spot-check wave-1 (не тронуты
|
||||
этим PR) и по одному wave-2 city из каждой tier-группы (все три id / только
|
||||
cian+yandex / только cian).
|
||||
"""
|
||||
from scraper_kit.orchestration.pipeline import (
|
||||
CITY_DISPLAY_NAMES,
|
||||
CITY_LOCATIONS,
|
||||
resolve_city_name,
|
||||
)
|
||||
|
||||
wave1_expected = {
|
||||
"nizhniy_tagil": "Нижний Тагил",
|
||||
"kamensk_uralskiy": "Каменск-Уральский",
|
||||
"pervouralsk": "Первоуральск",
|
||||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
}
|
||||
# CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS
|
||||
# (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени).
|
||||
assert set(expected) == set(CITY_LOCATIONS)
|
||||
for slug, name in expected.items():
|
||||
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS)
|
||||
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
|
||||
for slug, name in wave1_expected.items():
|
||||
assert resolve_city_name(slug) == name
|
||||
# wave-2 spot-check: novouralsk (avito+cian+yandex), revda (cian+yandex, avito
|
||||
# НЕ подтверждён), mikhaylovsk (только cian, yandex отсутствует у источника).
|
||||
for slug, name in {
|
||||
"novouralsk": "Новоуральск",
|
||||
"revda": "Ревда",
|
||||
"mikhaylovsk": "Михайловск",
|
||||
}.items():
|
||||
assert slug in CITY_LOCATIONS
|
||||
assert resolve_city_name(slug) == name
|
||||
|
||||
|
||||
|
|
@ -62,6 +83,56 @@ def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
|
|||
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
|
||||
|
||||
|
||||
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный
|
||||
# ЕКБ-fallback) ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_run_avito_city_sweep_raises_on_known_city_without_avito_slug() -> None:
|
||||
"""revda — известный CITY_LOCATIONS город, но avito_slug=None (не подтверждён).
|
||||
|
||||
run_avito_city_sweep обязан упасть ДО любого сетевого/DB похода — иначе
|
||||
`_city_seg()` молча взяла бы 'ekaterinburg' и sweep собрал бы ЕКБ под меткой
|
||||
'revda'. Raise происходит в самом начале функции (до await) — MagicMock() для
|
||||
config/matcher/enrichment безопасен, до них не доходит."""
|
||||
from scraper_kit.orchestration.pipeline import run_avito_city_sweep
|
||||
|
||||
with pytest.raises(ValueError, match="revda"):
|
||||
await run_avito_city_sweep(
|
||||
MagicMock(),
|
||||
run_id=1,
|
||||
config=MagicMock(),
|
||||
matcher=MagicMock(),
|
||||
enrichment=MagicMock(),
|
||||
city_slug="revda",
|
||||
)
|
||||
|
||||
|
||||
async def test_run_yandex_city_sweep_raises_on_known_city_without_yandex_rgid() -> None:
|
||||
"""mikhaylovsk — известный CITY_LOCATIONS город, но yandex_rgid=None (город
|
||||
отсутствует в гео-базе Яндекса вообще). run_yandex_city_sweep обязан упасть ДО
|
||||
любого сетевого/DB похода — иначе YandexRealtyScraper(city_rgid=None) молча
|
||||
взял бы ЕКБ rgid."""
|
||||
from scraper_kit.orchestration.pipeline import run_yandex_city_sweep
|
||||
|
||||
with pytest.raises(ValueError, match="mikhaylovsk"):
|
||||
await run_yandex_city_sweep(
|
||||
MagicMock(),
|
||||
run_id=1,
|
||||
config=MagicMock(),
|
||||
matcher=MagicMock(),
|
||||
enrichment=MagicMock(),
|
||||
city_slug="mikhaylovsk",
|
||||
)
|
||||
|
||||
|
||||
async def test_run_avito_city_sweep_does_not_raise_for_ekb_none_city_slug() -> None:
|
||||
"""city_slug=None (ЕКБ-путь, back-compat) НЕ должен затронуть новый guard — guard
|
||||
условие `_loc is not None`, а get_city_location(None) возвращает None."""
|
||||
from scraper_kit.orchestration.pipeline import get_city_location
|
||||
|
||||
assert get_city_location(None) is None
|
||||
|
||||
|
||||
# ── CitySweepCounters ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,19 @@ WHAT:
|
|||
LeadForm.tsx (regex, no JSX parser needed -- there is exactly one <span>
|
||||
in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for-
|
||||
byte after whitespace normalisation (JSX text nodes wrap across source
|
||||
lines; the DOM-rendered text collapses that to single spaces). If someone
|
||||
edits ONE side without the other, this test fails.
|
||||
lines; the DOM-rendered text collapses that to single spaces). The label
|
||||
now wraps a `<Link>` ("Политикой обработки персональных данных" is a
|
||||
clickable link to the actual policy document, RKN/owner requirement --
|
||||
the extractor strips JSX tags AND `{" "}` expression-spacers, keeping only
|
||||
the human-readable text, so the comparison stays a FLAT string on both
|
||||
sides). If someone edits ONE side without the other, this test fails.
|
||||
|
||||
A second test (`test_consent_policy_version_matches_privacy_approval_date`)
|
||||
guards the OTHER half of the same drift class found during triage: nothing
|
||||
was checking that _CONSENT_POLICY_VERSION actually points at the privacy
|
||||
policy edition it claims to (PRIVACY_APPROVAL in mera-public/content.ts).
|
||||
Bumping the policy text without bumping the version tag (or vice versa)
|
||||
would silently mislabel every lead's proof-of-consent snapshot.
|
||||
|
||||
NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT
|
||||
in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet
|
||||
|
|
@ -34,25 +45,71 @@ from pathlib import Path
|
|||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
_FRONTEND_LEAD_FORM = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "frontend"
|
||||
/ "src"
|
||||
/ "components"
|
||||
/ "trade-in"
|
||||
/ "v2"
|
||||
/ "LeadForm.tsx"
|
||||
_REPO_ROOT / "frontend" / "src" / "components" / "trade-in" / "v2" / "LeadForm.tsx"
|
||||
)
|
||||
|
||||
_FRONTEND_LEGAL_CONTENT = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts"
|
||||
|
||||
# Родительный падеж месяцев, как их пишет владелец в content.ts ("13 августа 2026 г.").
|
||||
_RU_MONTHS_GENITIVE = {
|
||||
"января": 1,
|
||||
"февраля": 2,
|
||||
"марта": 3,
|
||||
"апреля": 4,
|
||||
"мая": 5,
|
||||
"июня": 6,
|
||||
"июля": 7,
|
||||
"августа": 8,
|
||||
"сентября": 9,
|
||||
"октября": 10,
|
||||
"ноября": 11,
|
||||
"декабря": 12,
|
||||
}
|
||||
|
||||
|
||||
def _extract_span_text(tsx_source: str) -> str:
|
||||
"""Pull the text content of the (single) <span>...</span> in LeadForm.tsx,
|
||||
whitespace-normalised the same way a browser collapses JSX text-node
|
||||
whitespace when rendering (multiple lines/indentation -> single spaces).
|
||||
|
||||
The span may contain nested JSX markup (e.g. a <Link> wrapping part of the
|
||||
label, and a `{" "}` expression-spacer forcing a real space between a text
|
||||
node and the link on the next source line -- plain JSX whitespace between
|
||||
a text node and a tag on separate lines collapses to NOTHING, not a
|
||||
space, so LeadForm.tsx needs that explicit spacer for correct rendering).
|
||||
Both are stripped here so the comparison is against the flat, human-
|
||||
readable text a user actually sees -- not the markup.
|
||||
"""
|
||||
match = re.search(r"<span>\s*(.*?)\s*</span>", tsx_source, re.DOTALL)
|
||||
match = re.search(r"<span>(.*?)</span>", tsx_source, re.DOTALL)
|
||||
assert match is not None, "no <span> found in LeadForm.tsx -- consent label markup changed"
|
||||
return re.sub(r"\s+", " ", match.group(1)).strip()
|
||||
inner = match.group(1)
|
||||
inner = re.sub(r"\{\s*[\"']\s*[\"']\s*\}", " ", inner) # {" "} spacer -> real space
|
||||
inner = re.sub(r"\{/\*.*?\*/\}", " ", inner, flags=re.DOTALL) # JSX comments
|
||||
inner = re.sub(r"<[^>]+>", "", inner) # strip remaining JSX tags (e.g. <Link ...>, </Link>)
|
||||
return re.sub(r"\s+", " ", inner).strip()
|
||||
|
||||
|
||||
def _extract_privacy_approval_iso_date(content_ts_source: str) -> str:
|
||||
"""Pull the "DD <month genitive> YYYY" date out of PRIVACY_APPROVAL in
|
||||
mera-public/content.ts and return it as an ISO "YYYY-MM-DD" string.
|
||||
|
||||
PRIVACY_APPROVAL ("приказом директора № 1 от 13 августа 2026 г.") is the
|
||||
order that approves the actual privacy-policy EDITION the consent
|
||||
checkbox links to (/mera-public/privacy) -- it is the correct source of
|
||||
truth for _CONSENT_POLICY_VERSION, as opposed to LEGAL_DOCS_REVISION
|
||||
(which dates the offer + refund-policy documents, a different pair).
|
||||
"""
|
||||
match = re.search(r'PRIVACY_APPROVAL\s*=\s*"([^"]+)"', content_ts_source)
|
||||
assert match is not None, "PRIVACY_APPROVAL constant not found in mera-public/content.ts"
|
||||
date_match = re.search(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", match.group(1))
|
||||
assert date_match is not None, f"no RU date found in PRIVACY_APPROVAL: {match.group(1)!r}"
|
||||
day, month_name, year = date_match.groups()
|
||||
month = _RU_MONTHS_GENITIVE.get(month_name)
|
||||
assert month is not None, f"unknown RU month name in PRIVACY_APPROVAL: {month_name!r}"
|
||||
return f"{year}-{month:02d}-{int(day):02d}"
|
||||
|
||||
|
||||
def test_frontend_lead_form_exists() -> None:
|
||||
|
|
@ -78,6 +135,29 @@ def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_consent_policy_version_matches_privacy_approval_date() -> None:
|
||||
"""Guards the other half of the same drift class as the test above:
|
||||
_CONSENT_POLICY_VERSION must point at the privacy-policy EDITION it
|
||||
claims to (PRIVACY_APPROVAL in mera-public/content.ts), not just be some
|
||||
unrelated date bumped by hand. A silent mismatch here would mislabel
|
||||
every lead's proof-of-consent snapshot with the wrong policy edition."""
|
||||
from app.api.v1.lead import _CONSENT_POLICY_VERSION
|
||||
|
||||
assert _FRONTEND_LEGAL_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_LEGAL_CONTENT}"
|
||||
expected_version = _extract_privacy_approval_iso_date(
|
||||
_FRONTEND_LEGAL_CONTENT.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
assert _CONSENT_POLICY_VERSION == expected_version, (
|
||||
"app/api/v1/lead.py._CONSENT_POLICY_VERSION does not match the privacy-policy "
|
||||
"edition date derived from PRIVACY_APPROVAL in frontend/src/app/mera-public/"
|
||||
"content.ts. Bump _CONSENT_POLICY_VERSION to the new edition date whenever "
|
||||
"PRIVACY_APPROVAL changes (or vice versa).\n"
|
||||
f" _CONSENT_POLICY_VERSION: {_CONSENT_POLICY_VERSION!r}\n"
|
||||
f" PRIVACY_APPROVAL date: {expected_version!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
|
||||
"""Sanity check on the extraction helper itself, independent of the real file."""
|
||||
sample = """
|
||||
|
|
@ -87,3 +167,28 @@ def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
|
|||
</span>
|
||||
"""
|
||||
assert _extract_span_text(sample) == "Line one Line two"
|
||||
|
||||
|
||||
def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None:
|
||||
"""Sanity check: a <Link> wrapping part of the label (plus the {" "}
|
||||
spacer JSX needs to force a real space before it) must collapse to plain
|
||||
text, exactly like a browser renders it -- this is the shape LeadForm.tsx
|
||||
actually uses today for the policy-document link."""
|
||||
sample = """
|
||||
<span>
|
||||
Согласен(-на) на обработку персональных данных в соответствии с{" "}
|
||||
<Link href={PRIVACY_PATH} target="_blank" rel="noreferrer">
|
||||
Политикой обработки персональных данных
|
||||
</Link>
|
||||
</span>
|
||||
"""
|
||||
assert _extract_span_text(sample) == (
|
||||
"Согласен(-на) на обработку персональных данных в соответствии с "
|
||||
"Политикой обработки персональных данных"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_privacy_approval_iso_date_helper() -> None:
|
||||
"""Sanity check on the RU-date extraction helper, independent of the real file."""
|
||||
sample = 'export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";'
|
||||
assert _extract_privacy_approval_iso_date(sample) == "2026-08-13"
|
||||
|
|
|
|||
656
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal file
656
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
"""Tests for POST /api/v1/trade-in/coverage (issue #2894).
|
||||
|
||||
Бесплатная проба покрытия для публичного лэндинга «МЕРА» — до оплаты человек
|
||||
видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без
|
||||
единой рублёвой цифры в ответе. Covers:
|
||||
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
|
||||
- пустая когорта (n=0) → not_covered даже в поддерживаемом городе; threshold
|
||||
принудительно 0 в этом случае (nit-fix, повторная проверка #2894)
|
||||
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
|
||||
- MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
|
||||
estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city',
|
||||
price_rub > 0), не шире когорты платного эстиматора
|
||||
- MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке,
|
||||
выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану — запинено ЖИВЫМ SQL
|
||||
(см. test_max_age_outlier_excluded_from_median_live), не только подстрокой
|
||||
- Повторная проверка #2894 (2026-08): город резолвится ИСКЛЮЧИТЕЛЬНО по
|
||||
lat/lon (ближайший центроид), НЕ по моде `listings.city` (город
|
||||
свип-контекста скрейпера, не адреса объявления) и НЕ по `city_hint`
|
||||
(непроверенный клиентский вход) — см. app.api.v1.trade_in._resolve_coverage_city
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
|
||||
# (trade_in.py imports generate_trade_in_pdf at module load).
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def trade_in_app() -> FastAPI:
|
||||
"""Minimal FastAPI app mounting only the trade-in router with DB overridden."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield MagicMock()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def _row(
|
||||
n_listings: int,
|
||||
median_age_days: float | None,
|
||||
n_with_age: int | None = None,
|
||||
) -> dict:
|
||||
"""Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone().
|
||||
|
||||
n_with_age по умолчанию = n_listings, если не задан явно (большинство старых
|
||||
тестов не проверяют MAJOR-2 отдельно — сохраняем их поведение).
|
||||
|
||||
Повторная проверка #2894: строка больше не несёт cohort_city — город
|
||||
резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг).
|
||||
"""
|
||||
return {
|
||||
"n_listings": n_listings,
|
||||
"median_age_days": median_age_days,
|
||||
"n_with_age": n_with_age if n_with_age is not None else n_listings,
|
||||
}
|
||||
|
||||
|
||||
def _db_mock_returning(row: dict | None) -> MagicMock:
|
||||
"""DB session mock — coverage_probe reads db.execute(...).mappings().fetchone()."""
|
||||
db = MagicMock()
|
||||
mapping_result = MagicMock()
|
||||
mapping_result.fetchone.return_value = row
|
||||
execute_result = MagicMock()
|
||||
execute_result.mappings.return_value = mapping_result
|
||||
db.execute.return_value = execute_result
|
||||
return db
|
||||
|
||||
|
||||
def _override(app: FastAPI, db: MagicMock) -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: (yield db)
|
||||
|
||||
|
||||
# Екатеринбург — совпадает (с точностью до сотен метров) с центроидом
|
||||
# _CITY_CENTROIDS_DEG["Екатеринбург"], поэтому дефолтный payload детерминированно
|
||||
# резолвится в зелёный город без доп. настройки координат в каждом тесте.
|
||||
_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0}
|
||||
|
||||
# Координаты других городов из COVERAGE_GREEN/YELLOW_CITIES (те же значения, что
|
||||
# _CITY_CENTROIDS_DEG в trade_in.py) — используются, когда тесту нужен НЕ ЕКБ.
|
||||
_NIZHNY_TAGIL = {"lat": 57.9099, "lon": 59.9819}
|
||||
_REVDA = {"lat": 56.7986, "lon": 59.9298}
|
||||
_BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034}
|
||||
|
||||
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
|
||||
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
|
||||
# "город не определён", без совпадения ни с одним из 8 центроидов.
|
||||
_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970}
|
||||
|
||||
|
||||
# ── Response schema: NO price anywhere (issue #2894 hard rule) ────────────────
|
||||
|
||||
_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "₽", "cost")
|
||||
|
||||
|
||||
def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
|
||||
"""Regression guard: response schema must never grow a price-shaped field."""
|
||||
from app.schemas.trade_in import CoverageProbeResponse
|
||||
|
||||
field_names = set(CoverageProbeResponse.model_fields.keys())
|
||||
offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||||
assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}"
|
||||
|
||||
|
||||
def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
|
||||
"""Same guard but on a live serialized response (belt-and-suspenders)."""
|
||||
db = _db_mock_returning(_row(10, 21.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||||
assert not offending, f"response body must not carry price fields: {offending} in {data}"
|
||||
|
||||
|
||||
# ── Thresholds: green city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
|
||||
db = _db_mock_returning(_row(8, 15.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["n_listings"] == 8
|
||||
assert data["threshold"] == 8
|
||||
assert data["city"] == "Екатеринбург"
|
||||
assert data["radius_m"] == 1000
|
||||
assert data["median_listing_age_days"] == 15
|
||||
assert data["n_with_age"] == 8
|
||||
|
||||
|
||||
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
|
||||
db = _db_mock_returning(_row(7, 10.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["status"] == "thin"
|
||||
assert data["n_listings"] == 7
|
||||
assert data["threshold"] == 8
|
||||
|
||||
|
||||
# ── Thresholds: yellow city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok. Город резолвится из lat/lon."""
|
||||
db = _db_mock_returning(_row(12, 30.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL})
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["threshold"] == 12
|
||||
assert data["city"] == "Нижний Тагил"
|
||||
|
||||
|
||||
def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Ревда, n=11 (< порог 12) → thin."""
|
||||
db = _db_mock_returning(_row(11, 40.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA})
|
||||
data = resp.json()
|
||||
assert data["status"] == "thin"
|
||||
assert data["threshold"] == 12
|
||||
|
||||
|
||||
# ── City outside all centroids → always not_covered ─────────────────────────────
|
||||
|
||||
|
||||
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
|
||||
"""Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings
|
||||
(даже n=500)."""
|
||||
db = _db_mock_returning(_row(500, 5.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["threshold"] == 0
|
||||
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
|
||||
assert data["city"] == "" # город не определён — не эхуется сырой строкой
|
||||
|
||||
|
||||
# ── Empty cohort ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
|
||||
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.
|
||||
|
||||
Nit-fix (повторная проверка #2894): threshold обязан быть 0, а не реальным
|
||||
порогом города (8) — при not_covered threshold "неприменим" по докстрингу
|
||||
CoverageProbeResponse, независимо от ПРИЧИНЫ not_covered.
|
||||
"""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["n_listings"] == 0
|
||||
assert data["median_listing_age_days"] is None
|
||||
assert data["n_with_age"] == 0
|
||||
assert data["city"] == "Екатеринбург" # город резолвится по координатам всегда
|
||||
assert data["threshold"] == 0 # nit: не 8, хотя город поддерживаемый
|
||||
|
||||
|
||||
def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
|
||||
"""DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но
|
||||
coverage_probe обязан не падать, даже если mock/driver вернул пусто)."""
|
||||
db = _db_mock_returning(None)
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["n_listings"] == 0
|
||||
assert data["median_listing_age_days"] is None
|
||||
assert data["n_with_age"] == 0
|
||||
assert data["threshold"] == 0
|
||||
|
||||
|
||||
# ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ──
|
||||
|
||||
|
||||
def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) -> None:
|
||||
"""Точка в Берёзовском → city='Берёзовский' (а не 'Екатеринбург').
|
||||
|
||||
Регресс на прод-замер (повторная проверка #2894): в радиусе 1000м вокруг
|
||||
Берёзовского 90/90 строк listings имеют city='Екатеринбург' (город
|
||||
свип-контекста скрейпера, миграция 196) — старая логика (мода когорты)
|
||||
отдала бы 'Екатеринбург'. Ручка больше НЕ читает cohort city из SQL вовсе.
|
||||
"""
|
||||
db = _db_mock_returning(_row(8, 5.0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY})
|
||||
data = resp.json()
|
||||
assert data["city"] == "Берёзовский"
|
||||
assert data["status"] == "ok"
|
||||
assert data["threshold"] == 8
|
||||
|
||||
|
||||
def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
|
||||
"""Точка за пределами 25 км от всех 8 центроидов → not_covered, city=""."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["city"] == ""
|
||||
assert data["threshold"] == 0
|
||||
|
||||
|
||||
def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None:
|
||||
"""city_hint — чисто информационное поле (повторная проверка #2894): точка в
|
||||
Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский
|
||||
(threshold=8, зелёный порог — оба города зелёные, поэтому дополнительно
|
||||
проверяем n=8 → ok именно для Берёзовского, а не подмену клиентом города).
|
||||
"""
|
||||
db_with_hint = _db_mock_returning(_row(8, 5.0))
|
||||
_override(trade_in_app, db_with_hint)
|
||||
client = TestClient(trade_in_app)
|
||||
resp_with_hint = client.post(
|
||||
"/api/v1/trade-in/coverage",
|
||||
json={**_BASE_PAYLOAD, **_BEREZOVSKY, "city_hint": "Екатеринбург"},
|
||||
)
|
||||
|
||||
db_without_hint = _db_mock_returning(_row(8, 5.0))
|
||||
_override(trade_in_app, db_without_hint)
|
||||
resp_without_hint = client.post(
|
||||
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY}
|
||||
)
|
||||
|
||||
data_with, data_without = resp_with_hint.json(), resp_without_hint.json()
|
||||
assert data_with["city"] == data_without["city"] == "Берёзовский"
|
||||
assert data_with["threshold"] == data_without["threshold"] == 8
|
||||
assert data_with["status"] == data_without["status"] == "ok"
|
||||
|
||||
|
||||
# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ─────────────────
|
||||
|
||||
|
||||
def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
|
||||
"""n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null,
|
||||
даже если SQL посчитал percentile — "медиана" по 1-2 объявлениям не медиана."""
|
||||
from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES
|
||||
|
||||
assert COVERAGE_MIN_AGE_SAMPLES == 5
|
||||
db = _db_mock_returning(_row(20, 40.0, n_with_age=2))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром
|
||||
assert data["n_with_age"] == 2
|
||||
assert data["median_listing_age_days"] is None
|
||||
|
||||
|
||||
def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""n_with_age=5 (== порог) → median_listing_age_days отдаётся."""
|
||||
db = _db_mock_returning(_row(20, 40.0, n_with_age=5))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["n_with_age"] == 5
|
||||
assert data["median_listing_age_days"] == 40
|
||||
|
||||
|
||||
def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
|
||||
"""COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые
|
||||
объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python.
|
||||
|
||||
Слабая (текстовая) проверка — подстрока встречается в SQL ДВАЖДЫ (count и
|
||||
percentile_cont), поэтому `assert "..." in sql_text` одна ловит только
|
||||
"убрали оба FILTER", не "убрали один из двух". Реальный поведенческий пин —
|
||||
test_max_age_outlier_excluded_from_median_live ниже (живой Postgres).
|
||||
"""
|
||||
from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS
|
||||
|
||||
assert COVERAGE_MAX_AGE_DAYS == 365
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
call_args = db.execute.call_args
|
||||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
|
||||
assert params["max_age_days"] == 365
|
||||
|
||||
sql_text = str(call_args[0][0])
|
||||
# count==2: и в count(*) FILTER, и в percentile_cont(...) FILTER — обе нужны,
|
||||
# чтобы n_with_age и median_listing_age_days считались по ОДНОМУ и тому же
|
||||
# предикату (иначе честный n_with_age маскирует нечестный медианный расчёт).
|
||||
assert sql_text.count("days_on_market <= :max_age_days") == 2
|
||||
|
||||
|
||||
# ── DB dedup / cap params passed through ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
|
||||
"""SQL params: radius=1000 (строго), area ±15%, rooms exact."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
assert db.execute.called
|
||||
call_args = db.execute.call_args
|
||||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
|
||||
assert params["radius"] == 1000
|
||||
assert params["rooms"] == 2
|
||||
assert params["area_min"] == pytest.approx(50.0 * 0.85)
|
||||
assert params["area_max"] == pytest.approx(50.0 * 1.15)
|
||||
assert params["fresh_days"] == 14
|
||||
|
||||
|
||||
# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ────────
|
||||
#
|
||||
# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба
|
||||
# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м)
|
||||
# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid
|
||||
# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит
|
||||
# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL —
|
||||
# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py
|
||||
# для опционального real-Postgres-варианта аналогичной проверки в этом репо).
|
||||
|
||||
|
||||
def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
|
||||
"""Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932)."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text
|
||||
|
||||
|
||||
def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None:
|
||||
"""geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E)."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
|
||||
|
||||
|
||||
def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
|
||||
"""price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916)."""
|
||||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "price_rub > 0" in sql_text
|
||||
|
||||
|
||||
# ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ────────────────────
|
||||
#
|
||||
# Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что
|
||||
# test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/
|
||||
# DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test-
|
||||
# заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+
|
||||
# PostGIS-контейнер.
|
||||
#
|
||||
# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в
|
||||
# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и
|
||||
# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла),
|
||||
# И повторно внутри тела единственного live-теста. Теперь доступность БД
|
||||
# проверяется отдельной дешёвой функцией с явным закрытием соединения
|
||||
# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой
|
||||
# (`live_session`) с гарантированным close() в finally, а не ручным вызовом.
|
||||
|
||||
|
||||
def _live_db_available() -> bool:
|
||||
"""Дешёвая проверка доступности live-Postgres — соединение открывается и
|
||||
СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session.
|
||||
|
||||
Используется только в `pytest.mark.skipif(...)`, который вычисляется на
|
||||
этапе сбора тестов — до фикстур.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return False
|
||||
engine = create_engine(dsn, future=True)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(sa_text("SELECT 1"))
|
||||
return True
|
||||
finally:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_session(): # type: ignore[no-untyped-def]
|
||||
"""Session для live-Postgres тестов — гарантированно закрывается после теста
|
||||
(rollback + close + dispose в finally), в отличие от прежнего ручного вызова
|
||||
`_live_session()` внутри тела каждого теста."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
engine = create_engine(dsn, future=True)
|
||||
session_factory = sessionmaker(bind=engine, future=True)
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют
|
||||
# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх
|
||||
# чужой транзакции.
|
||||
_LIVE_LAT, _LIVE_LON = 1.111, 2.222
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
|
||||
def test_major1_cohort_excludes_novostroyki_and_city_precision_live(live_session) -> None: # type: ignore[no-untyped-def]
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.api.v1.trade_in import coverage_probe
|
||||
from app.schemas.trade_in import CoverageProbeInput
|
||||
|
||||
db = live_session
|
||||
rows = [
|
||||
# (source_url suffix, listing_segment, geo_precision, price_rub) — все
|
||||
# остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW().
|
||||
("ok-vtorichka", None, None, 5_000_000), # counted
|
||||
("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded
|
||||
("bad-city-precision", None, "city", 5_000_000), # excluded
|
||||
("bad-zero-price", None, None, 0), # excluded
|
||||
]
|
||||
for suffix, segment, geo_precision, price in rows:
|
||||
url = f"https://test.invalid/coverage-major1-{suffix}"
|
||||
db.execute(
|
||||
sa_text(
|
||||
"""
|
||||
INSERT INTO listings
|
||||
(source, source_url, source_id, dedup_hash, address, lat, lon,
|
||||
rooms, area_m2, price_rub, is_active, scraped_at,
|
||||
listing_segment, geo_precision)
|
||||
VALUES
|
||||
('test', :url, :url, :url, 'test addr', :lat, :lon,
|
||||
2, 50.0, :price, true, NOW(), :segment, :geo_precision)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"url": url,
|
||||
"lat": _LIVE_LAT,
|
||||
"lon": _LIVE_LON,
|
||||
"price": price,
|
||||
"segment": segment,
|
||||
"geo_precision": geo_precision,
|
||||
},
|
||||
)
|
||||
|
||||
result = coverage_probe(
|
||||
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
|
||||
)
|
||||
# Только первая (ok-vtorichka) строка должна попадать в когорту —
|
||||
# каждая следующая вставка не должна сдвигать счётчик.
|
||||
assert result.n_listings == 1, (
|
||||
f"predicate regression: n_listings={result.n_listings} after inserting "
|
||||
f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} "
|
||||
f"price={price}) — expected still 1 (only ok-vtorichka counted)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
|
||||
def test_max_age_outlier_excluded_from_median_live(live_session) -> None: # type: ignore[no-untyped-def]
|
||||
"""MAJOR-2 поведенческий пин (повторная проверка #2894).
|
||||
|
||||
Текстовый тест (test_max_age_outlier_days_passed_to_sql) проверял, что
|
||||
подстрока `days_on_market <= :max_age_days` встречается в SQL — но она там
|
||||
ДВАЖДЫ (count и percentile_cont), и мутация «убрать FILTER у
|
||||
percentile_cont, оставив у count» проходила зелёной: n_with_age (из count)
|
||||
оставался честным, а percentile_cont без FILTER считал медиану по ВСЕМ
|
||||
days_on_market, включая выбросы.
|
||||
|
||||
Вставляет когорту из 5 "нормальных" объявлений (days_on_market
|
||||
4/6/8/10/12, честная медиана — 8) и один выброс (days_on_market=4000,
|
||||
> COVERAGE_MAX_AGE_DAYS=365). Проверяет, что после вставки выброса
|
||||
n_with_age и median_listing_age_days НЕ меняются (выброс попадает только
|
||||
в n_listings) — с правильными двумя FILTER это так; без FILTER у
|
||||
percentile_cont медиана сдвинулась бы 8 → 9 (percentile_cont(0.5) по
|
||||
[4,6,8,10,12,4000] = среднее 3-го и 4-го отсортированных значений = 9).
|
||||
"""
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.api.v1.trade_in import coverage_probe
|
||||
from app.schemas.trade_in import CoverageProbeInput
|
||||
|
||||
db = live_session
|
||||
normal_ages = [4, 6, 8, 10, 12]
|
||||
for i, age in enumerate(normal_ages):
|
||||
url = f"https://test.invalid/coverage-major2-normal-{i}"
|
||||
db.execute(
|
||||
sa_text(
|
||||
"""
|
||||
INSERT INTO listings
|
||||
(source, source_url, source_id, dedup_hash, address, lat, lon,
|
||||
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
|
||||
VALUES
|
||||
('test', :url, :url, :url, :addr, :lat, :lon,
|
||||
2, 50.0, 5000000, true, NOW(), :age)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"url": url,
|
||||
"addr": f"test addr coverage-major2-{i}",
|
||||
"lat": _LIVE_LAT,
|
||||
"lon": _LIVE_LON,
|
||||
"age": age,
|
||||
},
|
||||
)
|
||||
|
||||
result = coverage_probe(
|
||||
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
|
||||
)
|
||||
assert result.n_listings == 5
|
||||
assert result.n_with_age == 5
|
||||
assert result.median_listing_age_days == 8
|
||||
|
||||
outlier_url = "https://test.invalid/coverage-major2-outlier"
|
||||
db.execute(
|
||||
sa_text(
|
||||
"""
|
||||
INSERT INTO listings
|
||||
(source, source_url, source_id, dedup_hash, address, lat, lon,
|
||||
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
|
||||
VALUES
|
||||
('test', :url, :url, :url, 'test addr coverage-major2-outlier', :lat, :lon,
|
||||
2, 50.0, 5000000, true, NOW(), 4000)
|
||||
"""
|
||||
),
|
||||
{"url": outlier_url, "lat": _LIVE_LAT, "lon": _LIVE_LON},
|
||||
)
|
||||
|
||||
result_with_outlier = coverage_probe(
|
||||
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
|
||||
)
|
||||
assert result_with_outlier.n_listings == 6 # выброс всё же попадает в n_listings
|
||||
assert result_with_outlier.n_with_age == 5, (
|
||||
f"MAJOR-2 regression: outlier (days_on_market=4000 > MAX=365) leaked into "
|
||||
f"n_with_age={result_with_outlier.n_with_age} — count(*) FILTER пропал/сломан"
|
||||
)
|
||||
assert result_with_outlier.median_listing_age_days == 8, (
|
||||
f"MAJOR-2 regression: median_listing_age_days="
|
||||
f"{result_with_outlier.median_listing_age_days} shifted by outlier — "
|
||||
f"percentile_cont(...) FILTER пропал (мутация «убрать FILTER у "
|
||||
f"percentile_cont, оставив у count»)"
|
||||
)
|
||||
|
|
@ -424,3 +424,184 @@ def test_migration_160_is_transactional() -> None:
|
|||
def test_migration_160_no_psycopg_trap() -> None:
|
||||
sql = _MIGRATION_160.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
# ── null_segment_only (пустой listing_segment yandex/cian, никогда не переобходится) ──
|
||||
|
||||
|
||||
def test_null_segment_sql_uses_is_null_not_any() -> None:
|
||||
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
|
||||
assert "listing_segment IS NULL" in sql
|
||||
assert "ANY(CAST(:segments AS text[]))" not in sql
|
||||
assert ":segments" not in sql
|
||||
|
||||
|
||||
def test_null_segment_sql_filters_is_active_and_source() -> None:
|
||||
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
|
||||
assert "is_active = true" in sql
|
||||
assert ":listing_source" in sql
|
||||
assert "SET is_active = false" in sql
|
||||
assert "DELETE" not in sql.upper()
|
||||
|
||||
|
||||
def test_null_segment_sql_no_psycopg_trap() -> None:
|
||||
sql = str(task_mod._build_null_segment_sql("last_seen_at").text)
|
||||
assert not re.search(r":\w+::", sql)
|
||||
assert "CAST(:ttl_days || ' days' AS interval)" in sql
|
||||
|
||||
|
||||
def test_null_segment_only_and_segments_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""null_segment_only=True + segments заданы -- неоднозначный запрос, ValueError."""
|
||||
failed: dict[str, Any] = {}
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
task_mod.runs_mod,
|
||||
"mark_failed",
|
||||
lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err),
|
||||
)
|
||||
db = _FakeDB(rowcount=0)
|
||||
with pytest.raises(ValueError, match="null_segment_only"):
|
||||
task_mod.deactivate_stale_listings(
|
||||
db,
|
||||
run_id=20,
|
||||
listing_source="cian",
|
||||
ttl_days=60,
|
||||
segments=["vtorichka"],
|
||||
null_segment_only=True,
|
||||
) # type: ignore[arg-type]
|
||||
assert db.executed == []
|
||||
assert failed["run_id"] == 20
|
||||
|
||||
|
||||
def test_null_segment_only_deactivates_via_is_null(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
marked: dict[str, Any] = {}
|
||||
monkeypatch.setattr(
|
||||
task_mod.runs_mod,
|
||||
"mark_done",
|
||||
lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)),
|
||||
)
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||
db = _FakeDB(rowcount=211)
|
||||
out = task_mod.deactivate_stale_listings(
|
||||
db,
|
||||
run_id=21,
|
||||
listing_source="cian",
|
||||
ttl_days=60,
|
||||
null_segment_only=True,
|
||||
) # type: ignore[arg-type]
|
||||
assert out == {"deactivated": 211}
|
||||
assert db.committed is True
|
||||
stmt, params = db.executed[0]
|
||||
sql = _sql_text(stmt)
|
||||
assert "listing_segment IS NULL" in sql
|
||||
assert params is not None
|
||||
assert "segments" not in params
|
||||
assert params["listing_source"] == "cian"
|
||||
assert params["ttl_days"] == 60
|
||||
assert marked["counters"] == {"deactivated": 211}
|
||||
|
||||
|
||||
def test_null_segment_only_confirmations_sql_uses_is_null() -> None:
|
||||
sql = str(
|
||||
task_mod._build_confirmations_sql(
|
||||
"last_seen_at", with_segments=False, null_segment_only=True
|
||||
).text
|
||||
)
|
||||
assert "listing_segment IS NULL" in sql
|
||||
assert ":segments" not in sql
|
||||
|
||||
|
||||
def test_null_segment_only_revisit_floor_sql_uses_is_null() -> None:
|
||||
sql = str(
|
||||
task_mod._build_revisit_floor_sql(
|
||||
"last_seen_at", with_segments=False, null_segment_only=True
|
||||
).text
|
||||
)
|
||||
assert "l.listing_segment IS NULL" in sql
|
||||
assert ":segments" not in sql
|
||||
|
||||
|
||||
def test_null_segment_only_default_is_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Обратная совместимость: старые вызовы без null_segment_only ведут себя как раньше."""
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||
db = _FakeDB(rowcount=3)
|
||||
task_mod.deactivate_stale_listings(
|
||||
db, run_id=22, listing_source="avito", ttl_days=10, segments=None
|
||||
) # type: ignore[arg-type]
|
||||
stmt, _params = db.executed[0]
|
||||
sql = _sql_text(stmt)
|
||||
assert "listing_segment IS NULL" not in sql
|
||||
|
||||
|
||||
# ── Migration 266 (deactivate_stale_yandex_null_segment / _cian_null_segment) ───────
|
||||
# Renumbered 264 -> 266 (collision with forgejo/main's 264_deactivate_stale_avito_cap_mult.sql
|
||||
# / 265_deactivate_stale_yandex_cap_mult.sql, merged после того как эта ветка забрала 264).
|
||||
|
||||
_MIGRATION_266 = _SQL_DIR / "266_seed_deactivate_stale_null_segment_yandex_cian.sql"
|
||||
|
||||
|
||||
def test_migration_266_exists() -> None:
|
||||
assert _MIGRATION_266.is_file(), f"missing migration: {_MIGRATION_266}"
|
||||
|
||||
|
||||
def test_migration_266_seeds_yandex_and_cian_null_segment() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "'deactivate_stale_yandex_null_segment'" in sql
|
||||
assert "'deactivate_stale_cian_null_segment'" in sql
|
||||
|
||||
|
||||
def test_migration_266_null_segment_only_true() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert '"null_segment_only":true' in sql
|
||||
|
||||
|
||||
def test_migration_266_ttl_60_days() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert '"ttl_days":60' in sql
|
||||
|
||||
|
||||
def test_migration_266_gates_disabled() -> None:
|
||||
"""min_confirmations/revisit_floor_quantile выключены явно -- население слишком
|
||||
мало для порогов, откалиброванных под полноценный vtorichka-свип (см. файл)."""
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert '"min_confirmations":0' in sql
|
||||
assert '"revisit_floor_quantile":0' in sql
|
||||
|
||||
|
||||
def test_migration_266_is_idempotent() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||
|
||||
|
||||
def test_migration_266_is_transactional() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_266_enabled_true() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert "true" in sql
|
||||
|
||||
|
||||
def test_migration_266_window_7_to_8_utc() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert re.search(r"\b7\b", sql), "window_start_hour 7 missing"
|
||||
assert re.search(r"\b8\b", sql), "window_end_hour 8 missing"
|
||||
|
||||
|
||||
def test_migration_266_no_psycopg_trap() -> None:
|
||||
sql = _MIGRATION_266.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
def test_handler_wires_null_segment_only_from_schedule_params() -> None:
|
||||
"""Читаем исходник файлом (как test_handler_wires_revisit_floor_from_schedule_params):
|
||||
product_handlers тянет scraper_kit, которого в юнит-окружении может не быть."""
|
||||
handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
|
||||
src = handlers.read_text("utf-8")
|
||||
job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
|
||||
flat = " ".join(job.split())
|
||||
assert 'params.get("null_segment_only", False)' in flat
|
||||
assert "null_segment_only=null_segment_only" in job
|
||||
|
|
|
|||
|
|
@ -126,13 +126,21 @@ def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[st
|
|||
|
||||
|
||||
def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ни одно из 127 доказанно ложных снятий не должно повториться.
|
||||
"""Ни одно из 127 доказанно ложных снятий (cian/yandex) не должно повториться.
|
||||
|
||||
Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть
|
||||
строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон
|
||||
снимет ту же строку снова.
|
||||
строго выше этого возраста на cian/yandex-срезах — иначе следующий прогон
|
||||
снимет ту же строку снова. avito из этого цикла исключён намеренно: 127
|
||||
доказанных ложных снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex,
|
||||
у avito другой сценарий и своя проверка ниже
|
||||
(test_avito_prod_floor_is_capped_by_calibrated_cap_mult) -- калибровка cap_mult=6
|
||||
для avito (миграция 264_deactivate_stale_avito_cap_mult.sql) пиннится ТАМ, а не
|
||||
здесь, чтобы не смешивать два разных замера под одним порогом
|
||||
_FALSE_KILL_AGE_MAX, который к avito не относится.
|
||||
"""
|
||||
for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items():
|
||||
if source == "avito":
|
||||
continue
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
|
|
@ -154,6 +162,108 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey
|
|||
), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
|
||||
|
||||
|
||||
def _read_cap_mult_from_migration(filename: str, *, source: str) -> int:
|
||||
"""Читает cap_mult из UPDATE default_params миграции -- НЕ хардкодит дубль в тесте.
|
||||
|
||||
Найдено ревью круга 3 2026-08-15: раньше тест ниже принимал cap_mult=6 как
|
||||
аргумент напрямую, захардкоженный прямо в теле теста. Мутация значения в
|
||||
264_deactivate_stale_avito_cap_mult.sql (6 -> 2) НЕ трогала вход теста вовсе --
|
||||
набор оставался зелёным при любом реальном значении в миграции, то есть
|
||||
калибровка нигде не была пином, только упоминанием в комментарии. Здесь
|
||||
значение читается ИЗ ФАЙЛА миграции regex'ом, а ожидаемый результат
|
||||
(ttl_days_effective, ttl_floor_capped) остаётся зафиксированным числом в самом
|
||||
тесте -- так дрейф калибровки в миграции ломает тест, как и задумано.
|
||||
"""
|
||||
migration = Path(__file__).resolve().parents[1] / "data" / "sql" / filename
|
||||
src = migration.read_text("utf-8")
|
||||
# Порядок в файле -- jsonb_build_object('cap_mult', N) в SET, ЗАТЕМ WHERE source
|
||||
# = '<source>' ниже (см. 264/265_*.sql). DOTALL матчит перевод строки между ними;
|
||||
# source в regex -- страховка от чтения не того UPDATE, если файл когда-нибудь
|
||||
# станет мульти-source (сейчас в каждом файле ровно один UPDATE).
|
||||
match = re.search(
|
||||
r"jsonb_build_object\('cap_mult',\s*(\d+)\).*?WHERE\s+source\s*=\s*'"
|
||||
+ re.escape(source)
|
||||
+ r"'",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match is not None, (
|
||||
f"{filename} сменил формат UPDATE default_params для source={source!r} -- "
|
||||
"обнови regex в _read_cap_mult_from_migration"
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пиннит калибровку cap_mult=6 для avito (миграция
|
||||
264_deactivate_stale_avito_cap_mult.sql) на измеренном прод-поле _PROD_FLOORS
|
||||
("avito/все сегменты" = 69.7, замер 2026-08-09).
|
||||
|
||||
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции (regex), не хардкодится
|
||||
здесь: дрейф калибровки в 264_*.sql (например 6 -> 2) меняет вход, но НЕ
|
||||
ожидаемый результат ниже (60/70) -- эти числа пинят калибровку саму по себе,
|
||||
поэтому дрейф ломает тест, как и задумано (см. _read_cap_mult_from_migration).
|
||||
|
||||
С дефолтным cap_mult=2 потолок avito (20 сут) РЕЖЕТ ниже собственного хвоста
|
||||
переобхода p99=42.1 (_REVISIT_TAIL) -- ровно тот false-kill, ради которого пол
|
||||
заведён. С калиброванным cap_mult=6 потолок 60 сут -- выше и p99=42.1, и живого
|
||||
прод-пика 52 (замер 08-10..08-12), и этого гипотетического замера 69.7 (капается
|
||||
ровно на 60, не пропускается как есть).
|
||||
"""
|
||||
calibrated_cap_mult = _read_cap_mult_from_migration(
|
||||
"264_deactivate_stale_avito_cap_mult.sql", source="deactivate_stale_avito"
|
||||
)
|
||||
source, segments, ttl_days, floor = _PROD_FLOORS["avito/все сегменты"]
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source=source,
|
||||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
cap_mult=calibrated_cap_mult,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 60, "cap_mult из миграции 264 обязан дать потолок 60"
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу"
|
||||
|
||||
|
||||
def test_yandex_prod_floor_is_not_capped_by_calibrated_cap_mult(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Пиннит калибровку cap_mult=3 для yandex (миграция
|
||||
265_deactivate_stale_yandex_cap_mult.sql, найдено ревью круга 3 2026-08-15) на
|
||||
измеренном прод-поле _PROD_FLOORS ("yandex/vtorichka" = 74.3).
|
||||
|
||||
cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции 265 (тот же приём, что и у
|
||||
avito выше): дрейф калибровки в 265_*.sql ломает тест.
|
||||
|
||||
С дефолтным cap_mult=2 потолок yandex (60 сут) РЕЖЕТ живой пол (75-79 сут,
|
||||
scrape_runs.counters 08-10..08-15 и live-замер 08-15) -- та же дыра, что у
|
||||
avito, найдена позже (первая версия 264 ошибочно считала yandex безопасным по
|
||||
устаревшему статическому p99=43.0). С калиброванным cap_mult=3 потолок 90 сут
|
||||
выше живого пика 79.2 -- пол 74.3 из этого теста НЕ капается, эффективный TTL
|
||||
равен сырому полу (75, ceil(74.3)).
|
||||
"""
|
||||
calibrated_cap_mult = _read_cap_mult_from_migration(
|
||||
"265_deactivate_stale_yandex_cap_mult.sql", source="deactivate_stale_yandex"
|
||||
)
|
||||
source, segments, ttl_days, floor = _PROD_FLOORS["yandex/vtorichka"]
|
||||
db = _FakeDB(floor_days=floor)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source=source,
|
||||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
cap_mult=calibrated_cap_mult,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 75, "ceil(74.3) == 75, потолок 90 не должен резать"
|
||||
assert "ttl_floor_capped" not in out, "потолок 90 выше живого пола 74.3 -- капать нечего"
|
||||
|
||||
|
||||
def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше."""
|
||||
assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX
|
||||
|
|
|
|||
443
tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py
Normal file
443
tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
"""Потолок эффективного TTL деактивации (найдено на проде 2026-08-15).
|
||||
|
||||
Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) поднимает
|
||||
эффективный TTL через max(ttl_days, пол) без верхней границы. На проде это оказалось
|
||||
петлёй с положительной обратной связью: медленный обход поднимает пол, высокий пол
|
||||
продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться свежий обход, пул
|
||||
«активных» раздувается протухшими строками. У yandex ttl_days_effective держали
|
||||
75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 -- это и есть разгон пола,
|
||||
ради которого потолок написан. Цифру «23 687 из 44 744» из исходного разбора сюда НЕ
|
||||
переносим: она про все источники сразу, две трети её -- новостройки вне выборки
|
||||
оценщика, а у самого avito просроченных строк ноль (уточнено 2026-08-15).
|
||||
|
||||
Этот файл проверяет CAP_MULT -- потолок, не пускающий эффективный TTL выше
|
||||
ttl_days * CAP_MULT, независимо от того, насколько высоко посчитанный пол.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.tasks import deactivate_stale_avito as task_mod
|
||||
|
||||
# ── Фейковая сессия (тот же контракт, что в test_deactivate_stale_revisit_floor.py) ──
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
|
||||
self.rowcount = rowcount
|
||||
self._scalar = scalar_value
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
floor_days: float | None,
|
||||
confirmations: int = 10_000,
|
||||
rowcount: int = 137,
|
||||
) -> None:
|
||||
self._floor = floor_days
|
||||
self._confirmations = confirmations
|
||||
self._rowcount = rowcount
|
||||
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||||
self.committed = False
|
||||
self.rolled_back = False
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
sql = str(stmt.text)
|
||||
self.executed.append((sql, params))
|
||||
if "percentile_disc" in sql:
|
||||
return _FakeResult(scalar_value=self._floor)
|
||||
if "SELECT count(*)" in sql:
|
||||
return _FakeResult(scalar_value=self._confirmations)
|
||||
return _FakeResult(rowcount=self._rowcount)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
@property
|
||||
def update_query(self) -> tuple[str, dict[str, Any] | None]:
|
||||
return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None))
|
||||
|
||||
|
||||
def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||
return task_mod.deactivate_stale_listings(
|
||||
db, # type: ignore[arg-type]
|
||||
1,
|
||||
listing_source=kwargs.pop("listing_source", "avito"),
|
||||
ttl_days=kwargs.pop("ttl_days", 10),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ── Контракт из задачи ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_high_floor_is_capped_at_double_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""revisit_floor=75, ttl_days=10 -> итог 20 (потолок 2x), НЕ 75."""
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 20
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 75
|
||||
_, update_params = db.update_query
|
||||
assert update_params is not None
|
||||
assert update_params["ttl_days"] == 20, "UPDATE обязан получить капнутый TTL, не сырой пол"
|
||||
|
||||
|
||||
def test_low_floor_leaves_ttl_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""revisit_floor=5, ttl_days=10 -> итог 10 (пол ниже заданного TTL, max() его не поднимает)."""
|
||||
db = _FakeDB(floor_days=5.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10
|
||||
assert "ttl_floor_capped" not in out
|
||||
assert "ttl_days_floor_raw" not in out
|
||||
_, update_params = db.update_query
|
||||
assert update_params is not None
|
||||
assert update_params["ttl_days"] == 10
|
||||
|
||||
|
||||
# ── Контракт потолка ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cap_mult_is_named_module_constant_equal_two() -> None:
|
||||
assert task_mod.CAP_MULT == 2
|
||||
|
||||
|
||||
def test_floor_between_ttl_and_cap_is_not_flagged_capped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пол поднял TTL, но не дотянулся до потолка -- capped-флаг НЕ выставляется."""
|
||||
db = _FakeDB(floor_days=15.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 15
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_floor_exactly_at_cap_boundary_is_not_flagged_capped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Пол ровно на потолке (2x ttl) -- это ещё "поднят до потолка", не "срезан выше него".
|
||||
|
||||
Формула -- min(raw, cap): при raw == cap срезания не происходит (raw > cap ложно),
|
||||
капнутый флаг предназначен сигналить именно "потолок реально что-то отрезал".
|
||||
"""
|
||||
db = _FakeDB(floor_days=20.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 20
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_cap_logs_warning_containing_both_numbers(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""WARNING при срезании содержит и сырой пол, и капнутый результат -- не только counters."""
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with caplog.at_level("WARNING", logger=task_mod.logger.name):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
messages = " ".join(r.getMessage() for r in caplog.records)
|
||||
assert "75" in messages, "лог обязан называть сырой пол"
|
||||
assert "20" in messages, "лог обязан называть итоговый (капнутый) TTL"
|
||||
|
||||
|
||||
def test_cap_never_lowers_ttl_below_configured_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Потолок -- верхняя граница, не альтернативный источник истины: заданный TTL
|
||||
(10) остаётся нижней границей независимо от того, насколько низко ушёл пол."""
|
||||
db = _FakeDB(floor_days=1.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10
|
||||
|
||||
|
||||
# ── avito self-descend (52 -> ... -> 10) не должен ломаться потолком ────────────
|
||||
|
||||
|
||||
def test_avito_high_transient_floor_is_capped_not_left_unbounded(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Наблюдённый на проде транзиентный пик avito (счётчики видели ttl_days_effective=52)
|
||||
теперь капается на 2x ttl=20, а не пропускается в UPDATE как есть."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 20
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 52
|
||||
|
||||
|
||||
def test_avito_recovered_low_floor_still_reaches_configured_ttl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""После восстановления обхода (пол опустился ниже ttl_days=10, как на проде 52->10)
|
||||
потолок не мешает нормальному пути -- эффективный TTL просто равен заданному."""
|
||||
db = _FakeDB(floor_days=9.0)
|
||||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Промежуточная точка того же самопонижения (пол между ttl и потолком, например 18)
|
||||
поднимает TTL как раньше -- потолок не мешает нормальному постепенному пути."""
|
||||
db = _FakeDB(floor_days=18.0)
|
||||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 18
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
# ── cap_mult конфигурируем per-source (найдено ревью 2026-08-15) ────────────────
|
||||
# Дефолтный CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных источниках
|
||||
# (cian/yandex 60 сут, avito 20 сут), а хвост переобхода не пропорционален
|
||||
# ttl_days: avito p99=42.1 -- выше его же дефолтного потолка 20. cap_mult -- ручка
|
||||
# для конкретно такого источника, без изменения дефолта для остальных.
|
||||
|
||||
|
||||
def test_cap_mult_defaults_to_module_constant_when_not_overridden(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Без явного cap_mult поведение не меняется: потолок = ttl_days * CAP_MULT (2)."""
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||||
assert out["ttl_days_effective"] == 10 * task_mod.CAP_MULT
|
||||
|
||||
|
||||
def test_cap_mult_override_raises_the_ceiling_for_a_long_tailed_source(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""avito p99=42.1: cap_mult=6 (потолок 60) больше не режет пол ниже хвоста обхода,
|
||||
в отличие от дефолтного cap_mult=2 (потолок 20)."""
|
||||
db = _FakeDB(floor_days=45.0)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=6,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 45
|
||||
assert "ttl_floor_capped" not in out
|
||||
|
||||
|
||||
def test_cap_mult_override_still_caps_when_floor_exceeds_the_wider_ceiling(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""cap_mult поднимает потолок, но не убирает его -- пол выше 60 всё равно срезается."""
|
||||
db = _FakeDB(floor_days=90.0)
|
||||
out = _run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=6,
|
||||
)
|
||||
assert out["ttl_days_effective"] == 60
|
||||
assert out["ttl_floor_capped"] == 1
|
||||
assert out["ttl_days_floor_raw"] == 90
|
||||
|
||||
|
||||
def test_cap_mult_is_threaded_into_update_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Капнутый по override'нутому потолку TTL реально уходит в UPDATE, не только считается."""
|
||||
db = _FakeDB(floor_days=90.0)
|
||||
_run(
|
||||
db,
|
||||
monkeypatch,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=6,
|
||||
)
|
||||
_, update_params = db.update_query
|
||||
assert update_params is not None
|
||||
assert update_params["ttl_days"] == 60
|
||||
|
||||
|
||||
# ── ttl_days <= 0 (LOW из ревью 2026-08-15) ──────────────────────────────────────
|
||||
# До потолка max(ttl_days, floor) прикрывал ttl_days<=0, если пол посчитан и
|
||||
# положителен. С потолком min(raw, ttl_days * cap_mult) при ttl_days<=0 капнутый
|
||||
# потолок тоже <= 0 и побеждает в min() -- защита пола пропадает молча. Явный guard
|
||||
# ловит это ДО любого SQL, тем же путём, что и невалидный staleness_column.
|
||||
|
||||
|
||||
def test_ttl_days_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=0, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=-5, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Тот же контракт, что и невалидный staleness_column: run помечается failed,
|
||||
а не остаётся 'running'."""
|
||||
marked_failed: list[Any] = []
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
task_mod.runs_mod,
|
||||
"mark_failed",
|
||||
lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)),
|
||||
)
|
||||
db = _FakeDB(floor_days=75.0)
|
||||
with pytest.raises(ValueError):
|
||||
task_mod.deactivate_stale_listings(
|
||||
db, # type: ignore[arg-type]
|
||||
7,
|
||||
listing_source="avito",
|
||||
ttl_days=0,
|
||||
)
|
||||
assert len(marked_failed) == 1
|
||||
assert marked_failed[0][0] == 7
|
||||
|
||||
|
||||
# ── cap_mult < 1 (HIGH из ревью круга 2, 2026-08-15) ─────────────────────────────
|
||||
# Тот же класс дыры, что и ttl_days<=0 выше, но со стороны потолка: cap_mult -- ЕДИНСТВЕННЫЙ
|
||||
# запланированный способ его задать -- руками вписать в jsonb default_params расписания
|
||||
# (см. миграцию для avito), т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода.
|
||||
# cap_mult=0 -> capped=0 -> effective_ttl_days=0 -> UPDATE снимает весь активный пул
|
||||
# источника молча. cap_mult<1 (например 0.5) опускает потолок НИЖЕ заданного оператором
|
||||
# ttl_days -- прямое нарушение инварианта, который проверяет
|
||||
# test_cap_never_lowers_ttl_below_configured_value для пола, но не было проверено для
|
||||
# потолка при некорректном cap_mult.
|
||||
|
||||
|
||||
def test_cap_mult_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=-2)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_below_one_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cap_mult=0.5 опустил бы потолок НИЖЕ заданного ttl_days -- та самая инверсия,
|
||||
которую тест test_cap_never_lowers_ttl_below_configured_value гарантирует для пола."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0.5)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Опечатка в jsonb default_params (строка вместо числа) не должна молча пройти
|
||||
в SQL -- TypeError из сравнения `cap_mult < 1` ловится тем же except Exception,
|
||||
что и ValueError-гварды, и маршрутизируется через mark_failed. Никакого SQL не
|
||||
исполняется, ни один active-лот не тронут."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(TypeError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult="6")
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
# ── cap_mult / ttl_days -- bool (найдено ревью круга 3, 2026-08-15) ─────────────
|
||||
# bool -- подкласс int в Python: `True < 1` ложно, `True <= 0` ложно. Числовые
|
||||
# guard'ы выше (`cap_mult < 1`, `ttl_days <= 0`) поэтому НЕ ловят jsonb `true` в
|
||||
# default_params расписания -- ровно тот класс опечатки, ради которого guard'ы
|
||||
# вообще написаны. `cap_mult=True` даёт потолок == ttl_days (ttl_days * True ==
|
||||
# ttl_days) -- пол молча отключается без единого ValueError. `ttl_days=True` даёт
|
||||
# ttl_days == 1 -- TTL молча меняется на 1 сутки. Явная type-проверка ловит оба
|
||||
# ДО числового сравнения и ДО любого SQL.
|
||||
|
||||
|
||||
def test_cap_mult_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cap_mult=True: `True < 1` ложно -- без явной type-проверки потолок = ttl_days
|
||||
(пол молча отключается) вместо ValueError. Воспроизведено на HEAD ветки."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=True)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cap_mult=False уже ловится `cap_mult < 1` (False == 0), но type-guard идёт
|
||||
первым -- проверяем, что путь всё равно ValueError, а не иной exception."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=False)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ttl_days=True: `True <= 0` ложно -- без явной type-проверки TTL молча
|
||||
становится 1 сутки (True ведёт себя как int 1) вместо ValueError."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=True, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_ttl_days_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ttl_days=False уже ловится `ttl_days <= 0` (False == 0), но type-guard идёт
|
||||
первым -- проверяем, что путь всё равно ValueError."""
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
_run(db, monkeypatch, ttl_days=False, revisit_floor_quantile=0.99)
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
def test_cap_mult_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Тот же контракт, что и ttl_days<=0: run помечается failed, а не остаётся 'running',
|
||||
и НИ ОДНА строка не деактивируется (в отличие от воспроизведённого на HEAD дефекта, где
|
||||
cap_mult=0 давало effective_ttl_days=0 и снимало весь активный пул источника)."""
|
||||
marked_failed: list[Any] = []
|
||||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
task_mod.runs_mod,
|
||||
"mark_failed",
|
||||
lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)),
|
||||
)
|
||||
db = _FakeDB(floor_days=52.0)
|
||||
with pytest.raises(ValueError):
|
||||
task_mod.deactivate_stale_listings(
|
||||
db, # type: ignore[arg-type]
|
||||
9,
|
||||
listing_source="avito",
|
||||
ttl_days=10,
|
||||
revisit_floor_quantile=0.99,
|
||||
cap_mult=0,
|
||||
)
|
||||
assert len(marked_failed) == 1
|
||||
assert marked_failed[0][0] == 9
|
||||
assert db.executed == []
|
||||
|
||||
|
||||
# ── проводка cap_mult в product_handlers ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_handler_wires_cap_mult_from_schedule_params() -> None:
|
||||
"""Тот же приём, что test_handler_wires_revisit_floor_from_schedule_params:
|
||||
читаем исходник файлом (product_handlers тянет scraper_kit, которого в юнит-
|
||||
окружении может не быть) и проверяем именно проводку default_params -> вызов."""
|
||||
handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
|
||||
src = handlers.read_text("utf-8")
|
||||
job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
|
||||
flat = " ".join(job.split())
|
||||
assert 'params.get("cap_mult", CAP_MULT)' in flat
|
||||
assert "cap_mult=cap_mult" in job
|
||||
|
|
@ -3,8 +3,6 @@
|
|||
Каждая правка эпика — тест, который краснеет без неё:
|
||||
|
||||
подключено:
|
||||
- houses.has_panorama пишется из yandex_valuation (и НЕ пишется, когда страница
|
||||
не подтверждена — иначе false «не смотрели» выдаётся за false «посмотрели»);
|
||||
- domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules —
|
||||
именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным;
|
||||
- filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash.
|
||||
|
|
@ -15,7 +13,11 @@
|
|||
читателя и писателя;
|
||||
- listings.merged_into, house_sources.raw_payload — колонки без писателя;
|
||||
- v_data_quality.price_disagreements_count — показатель, который не мог быть
|
||||
ненулевым.
|
||||
ненулевым;
|
||||
- houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке.
|
||||
Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя;
|
||||
подключённый писатель за 7 дней записал 12 значений, все false. Разница между
|
||||
«проводка оборвана» и «мерить нечего» видна не по коду, а по площадке.
|
||||
|
||||
задокументировано:
|
||||
- BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт —
|
||||
|
|
@ -29,19 +31,13 @@ from __future__ import annotations
|
|||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.providers.cian.valuation import _parse_valuation_state
|
||||
from scraper_kit.providers.yandex.valuation import (
|
||||
ValuationHistoryItem,
|
||||
ValuationHouseMeta,
|
||||
YandexValuationResult,
|
||||
)
|
||||
|
||||
from app.services import estimator
|
||||
from app.services.estimator import _save_yandex_history_items
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
TRADEIN = REPO_ROOT / "tradein-mvp"
|
||||
|
|
@ -50,118 +46,7 @@ MIGRATION = SQL_DIR / "216_dead_code_sweep.sql"
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Подключено 1/3: houses.has_panorama
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _result_with_meta(meta: ValuationHouseMeta) -> YandexValuationResult:
|
||||
return YandexValuationResult(
|
||||
address="Екатеринбург, ул. Куйбышева, 106",
|
||||
offer_category="APARTMENT",
|
||||
offer_type="SELL",
|
||||
page=1,
|
||||
source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test",
|
||||
house=meta,
|
||||
history_items=[ValuationHistoryItem(area_m2=50.0, rooms=2, floor=5, start_price=9_000_000)],
|
||||
)
|
||||
|
||||
|
||||
def _panorama_updates(db: MagicMock) -> list[dict]:
|
||||
"""Параметры всех db.execute, которые обновляли houses.has_panorama."""
|
||||
found = []
|
||||
for call in db.execute.call_args_list:
|
||||
sql = str(call.args[0])
|
||||
if "has_panorama" in sql and "UPDATE houses" in sql:
|
||||
found.append(call.args[1])
|
||||
return found
|
||||
|
||||
|
||||
def test_has_panorama_written_when_page_rendered() -> None:
|
||||
"""Разобранный флаг доезжает до houses — до #2674 он не доезжал ни одной строкой."""
|
||||
db = MagicMock()
|
||||
result = _result_with_meta(
|
||||
ValuationHouseMeta(year_built=2010, total_floors=16, has_panorama=True)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
return_value=(99, 0.9, "fp"),
|
||||
):
|
||||
_save_yandex_history_items(db, result)
|
||||
|
||||
updates = _panorama_updates(db)
|
||||
assert updates, "houses.has_panorama не записан — вернулась исходная болячка #2674"
|
||||
assert updates[0] == {"hid": 99, "panorama": True}
|
||||
|
||||
|
||||
def test_has_panorama_false_written_when_page_rendered() -> None:
|
||||
"""Отсутствие метки на ОТРИСОВАННОЙ странице — тоже наблюдение, пишем false."""
|
||||
db = MagicMock()
|
||||
result = _result_with_meta(
|
||||
ValuationHouseMeta(year_built=1998, total_floors=9, has_panorama=False)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
return_value=(7, 0.9, "fp"),
|
||||
):
|
||||
_save_yandex_history_items(db, result)
|
||||
|
||||
assert _panorama_updates(db) == [{"hid": 7, "panorama": False}]
|
||||
|
||||
|
||||
def test_has_panorama_written_when_page_has_no_history() -> None:
|
||||
"""Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде.
|
||||
|
||||
Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие
|
||||
страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение
|
||||
о доме к наличию объявлений отношения не имеет.
|
||||
"""
|
||||
db = MagicMock()
|
||||
result = _result_with_meta(
|
||||
ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True)
|
||||
)
|
||||
result.history_items = []
|
||||
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
return_value=(42, 0.9, "fp"),
|
||||
):
|
||||
assert _save_yandex_history_items(db, result) == 0
|
||||
|
||||
assert _panorama_updates(db) == [{"hid": 42, "panorama": True}]
|
||||
|
||||
|
||||
def test_has_panorama_not_written_when_page_unconfirmed() -> None:
|
||||
"""Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false."""
|
||||
db = MagicMock()
|
||||
result = _result_with_meta(ValuationHouseMeta(has_panorama=False))
|
||||
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
return_value=(5, 0.9, "fp"),
|
||||
):
|
||||
_save_yandex_history_items(db, result)
|
||||
|
||||
assert _panorama_updates(db) == [], "false записан там, где мы ничего не наблюдали"
|
||||
|
||||
|
||||
def test_has_panorama_not_written_without_house_id() -> None:
|
||||
"""Дом не сматчился → писать некуда, но и падать нельзя."""
|
||||
db = MagicMock()
|
||||
result = _result_with_meta(ValuationHouseMeta(year_built=2010, total_floors=16))
|
||||
|
||||
with patch(
|
||||
"app.services.estimator.match_or_create_house",
|
||||
side_effect=RuntimeError("no house"),
|
||||
):
|
||||
_save_yandex_history_items(db, result)
|
||||
|
||||
assert _panorama_updates(db) == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Подключено 2/3: загрузчик ДОМ.РФ — оборванная проводка
|
||||
# Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -217,7 +102,7 @@ def test_domrf_handler_reuses_loader_functions() -> None:
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Подключено 3/3: filters_hash лежит на уровень выше, чем его читали
|
||||
# Подключено 2/2: filters_hash лежит на уровень выше, чем его читали
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -285,6 +170,10 @@ def test_dead_names_absent_from_live_code() -> None:
|
|||
"asking_to_sold_ratios_tiered",
|
||||
"asking_to_sold_tier_bounds",
|
||||
"price_disagreements_count",
|
||||
# Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета
|
||||
# источников. Гейт краснеет на любом из трёх, а не только на одном (первая
|
||||
# редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет).
|
||||
"has_panorama",
|
||||
]
|
||||
offenders: list[str] = []
|
||||
for path in _live_python_sources():
|
||||
|
|
@ -332,6 +221,29 @@ def test_migration_drops_exactly_what_was_declared_dead() -> None:
|
|||
assert "COMMENT ON VIEW v_price_divergence" in sql
|
||||
|
||||
|
||||
def test_has_panorama_dropped_together_with_the_contract_column() -> None:
|
||||
"""Снос колонки обязан пересобрать market.v_houses и вернуть ему грант.
|
||||
|
||||
has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку
|
||||
не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три
|
||||
части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен.
|
||||
"""
|
||||
sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8")
|
||||
assert "DROP VIEW IF EXISTS market.v_houses" in sql
|
||||
assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql
|
||||
assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока"
|
||||
|
||||
view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0]
|
||||
assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой"
|
||||
# Контракт — обещание стабильности: пересборка не должна заодно потерять соседей.
|
||||
for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"):
|
||||
assert kept in view_ddl, f"{kept} пропала из контракта при пересборке"
|
||||
assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, (
|
||||
"DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит "
|
||||
"permission denied на следующем прогоне"
|
||||
)
|
||||
|
||||
|
||||
def test_price_divergence_is_documented_as_structurally_empty() -> None:
|
||||
"""Оставленный задел обязан говорить, чем он НЕ является сегодня."""
|
||||
sql = MIGRATION.read_text(encoding="utf-8")
|
||||
|
|
|
|||
208
tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
Normal file
208
tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа.
|
||||
|
||||
Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и
|
||||
period_months — ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и
|
||||
слово «ФАКТИЧЕСКИЕ» читалось как «недавние».
|
||||
|
||||
Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein):
|
||||
|
||||
deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01
|
||||
(223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк,
|
||||
месяцы ровно {01, 04, 07, 10} → deal_date это метка КВАРТАЛЬНОЙ пачки.
|
||||
|
||||
Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15%
|
||||
+окно 12 мес + ppm²-банды, включая city-wide widen):
|
||||
свежайшая I кв. 2026 — 654 (74.2%)
|
||||
коридора нет вовсе — 165 (18.7%)
|
||||
свежайшая IV кв. 2025 — 62 ( 7.0%)
|
||||
→ у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего
|
||||
максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу.
|
||||
|
||||
Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в
|
||||
dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.schemas.trade_in import AnalogLot, DkpCorridor
|
||||
from app.services.estimator import _fetch_dkp_corridor
|
||||
|
||||
_ADDRESS = "Екатеринбург, ул. Гоголя, 18"
|
||||
|
||||
|
||||
def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]:
|
||||
return {"price_per_m2": ppm2, "deal_date": deal_date}
|
||||
|
||||
|
||||
def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any:
|
||||
"""Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen.
|
||||
|
||||
Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем
|
||||
_load_sber_index_series, затем (опционально) city-wide widen.
|
||||
"""
|
||||
db = MagicMock()
|
||||
calls: list[Any] = []
|
||||
|
||||
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
result = MagicMock()
|
||||
calls.append(query)
|
||||
sql = str(query)
|
||||
if "sber" in sql.lower():
|
||||
result.mappings.return_value.all.return_value = []
|
||||
elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql:
|
||||
result.mappings.return_value.all.return_value = street_rows
|
||||
elif "deal_city_price_bands" in sql:
|
||||
result.mappings.return_value.all.return_value = city_rows or []
|
||||
else:
|
||||
result.mappings.return_value.all.return_value = []
|
||||
return result
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
return db
|
||||
|
||||
|
||||
def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def]
|
||||
return _fetch_dkp_corridor(
|
||||
_db(street_rows, city_rows),
|
||||
address=_ADDRESS,
|
||||
rooms=2,
|
||||
area=60.0,
|
||||
city="екатеринбург" if city_rows is None else "нижний тагил",
|
||||
)
|
||||
|
||||
|
||||
# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ─────────────────────────────────
|
||||
|
||||
|
||||
def test_latest_deal_date_reaches_the_response_schema() -> None:
|
||||
"""Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки.
|
||||
|
||||
На origin/main падает AttributeError — поля в схеме нет, а Pydantic v2
|
||||
молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» — нет.
|
||||
"""
|
||||
raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))])
|
||||
assert raw is not None
|
||||
corridor = DkpCorridor(**raw)
|
||||
assert corridor.latest_deal_date == date(2026, 1, 1)
|
||||
|
||||
|
||||
def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None:
|
||||
"""Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025.
|
||||
|
||||
Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы
|
||||
(2026-01-01) для них — чужая, более свежая дата.
|
||||
"""
|
||||
raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))])
|
||||
assert raw is not None
|
||||
assert raw["latest_deal_date"] == date(2025, 10, 1)
|
||||
|
||||
|
||||
def test_deal_without_price_does_not_donate_its_freshness() -> None:
|
||||
"""Строка без ppm² не входит в границы коридора — не входит и в его возраст.
|
||||
|
||||
Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не
|
||||
участвуя ни в одном из чисел под ней.
|
||||
"""
|
||||
raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))])
|
||||
assert raw is not None
|
||||
assert raw["count"] == 1
|
||||
assert raw["latest_deal_date"] == date(2025, 10, 1)
|
||||
|
||||
|
||||
def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None:
|
||||
"""#oblast-D widen: числа переехали на city-выборку — дата обязана тоже.
|
||||
|
||||
Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для
|
||||
глаза; но на экране после widen'а стоят city-числа, которые кончаются
|
||||
III кв. 2025. Подпись описывает то, что показано.
|
||||
"""
|
||||
street = [_deal(200_000, date(2026, 1, 1))]
|
||||
city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))]
|
||||
raw = _fetch(street, city)
|
||||
assert raw is not None
|
||||
assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)"
|
||||
assert raw["latest_deal_date"] == date(2025, 7, 1)
|
||||
|
||||
|
||||
def test_no_deals_means_no_corridor_and_nothing_to_date() -> None:
|
||||
"""count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать."""
|
||||
assert _fetch([]) is None
|
||||
|
||||
|
||||
def test_period_months_is_not_a_freshness_claim() -> None:
|
||||
"""Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа.
|
||||
|
||||
Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев»
|
||||
(mappers.ts) — а v1 печатал.
|
||||
"""
|
||||
raw = _fetch([_deal(200_000, date(2025, 10, 1))])
|
||||
assert raw is not None
|
||||
assert raw["period_months"] == 12
|
||||
assert raw["latest_deal_date"] == date(2025, 10, 1)
|
||||
|
||||
|
||||
# ── PDF: то же обещание, та же подпись ──────────────────────────────────────
|
||||
|
||||
|
||||
def _estimate_with_deals(dates: list[date]) -> Any:
|
||||
est = MagicMock()
|
||||
est.actual_deals = [
|
||||
AnalogLot(
|
||||
address="Екатеринбург, ул. Гоголя",
|
||||
area_m2=60.0,
|
||||
rooms=2,
|
||||
floor=None,
|
||||
total_floors=None,
|
||||
price_rub=12_000_000,
|
||||
price_per_m2=200_000,
|
||||
listing_date=d,
|
||||
days_on_market=None,
|
||||
date_precision="quarter",
|
||||
source="rosreestr",
|
||||
)
|
||||
for d in dates
|
||||
]
|
||||
return est
|
||||
|
||||
|
||||
def test_pdf_deals_page_dates_the_deals_it_shows() -> None:
|
||||
"""PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка.
|
||||
|
||||
До правки страница печатала «Период сделок: 08.2025 – 08.2026», где правый
|
||||
конец = сегодня: окно поиска, выданное за период данных.
|
||||
"""
|
||||
from app.services.exporters.trade_in_pdf import deals_as_of_label
|
||||
|
||||
assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == (
|
||||
"по I кв. 2026"
|
||||
)
|
||||
assert deals_as_of_label(_estimate_with_deals([])) is None
|
||||
|
||||
|
||||
def test_quarter_label_matches_every_deal_date_present_on_prod() -> None:
|
||||
"""Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка.
|
||||
|
||||
Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`.
|
||||
"""
|
||||
from app.services.exporters.trade_in_pdf import deals_as_of_label
|
||||
|
||||
expected = {
|
||||
date(2026, 1, 1): "по I кв. 2026",
|
||||
date(2025, 10, 1): "по IV кв. 2025",
|
||||
date(2025, 7, 1): "по III кв. 2025",
|
||||
date(2025, 4, 1): "по II кв. 2025",
|
||||
date(2025, 1, 1): "по I кв. 2025",
|
||||
date(2024, 10, 1): "по IV кв. 2024",
|
||||
date(2024, 7, 1): "по III кв. 2024",
|
||||
date(2024, 4, 1): "по II кв. 2024",
|
||||
date(2024, 1, 1): "по I кв. 2024",
|
||||
}
|
||||
for d, label in expected.items():
|
||||
assert deals_as_of_label(_estimate_with_deals([d])) == label
|
||||
|
|
@ -331,18 +331,24 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 5a — sber staleness warning
|
||||
# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846)
|
||||
#
|
||||
# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой
|
||||
# возраст недостижим по построению (period_month — метка первого числа + лаг
|
||||
# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки
|
||||
# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим»
|
||||
# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место —
|
||||
# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""_load_sber_index_series логирует warning при stale серии."""
|
||||
def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Серия отдаётся как есть; календарного warning'а в горячем пути больше нет."""
|
||||
import logging
|
||||
|
||||
from app.services.estimator import _load_sber_index_series
|
||||
|
||||
# Серия с единственным месяцем 2 года назад
|
||||
stale_month = date(2024, 1, 1)
|
||||
stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал
|
||||
mock_db = MagicMock()
|
||||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"period_month": stale_month, "index_value_rub_m2": 100_000.0}
|
||||
|
|
@ -351,33 +357,9 @@ def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None
|
|||
with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
|
||||
series = _load_sber_index_series(mock_db, region="Свердловская область")
|
||||
|
||||
assert len(series) == 1
|
||||
assert stale_month in series
|
||||
# Warning о staleness должен быть залогирован
|
||||
assert series == {stale_month: 100_000.0}
|
||||
stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
|
||||
assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}"
|
||||
|
||||
|
||||
def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""_load_sber_index_series НЕ логирует warning при свежей серии."""
|
||||
import logging
|
||||
|
||||
from app.services.estimator import _load_sber_index_series
|
||||
|
||||
# Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога).
|
||||
today = datetime.now(tz=UTC).date()
|
||||
fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца
|
||||
mock_db = MagicMock()
|
||||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"period_month": fresh_month, "index_value_rub_m2": 128_000.0}
|
||||
]
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
|
||||
series = _load_sber_index_series(mock_db, region="Свердловская область")
|
||||
|
||||
assert len(series) == 1
|
||||
stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()]
|
||||
assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}"
|
||||
assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ def test_corridor_clamp_above_corridor_tier_c_clamps() -> None:
|
|||
|
||||
def test_corridor_clamp_inside_corridor_is_noop() -> None:
|
||||
# Эконом/комфорт: headline в коридоре (с учётом slack) → ничего не меняется.
|
||||
new_ppm2, new_price, new_low, new_high, clamped = _clamp(
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
median_ppm2=140_000, corridor_high=130_000, count=20, tier="C"
|
||||
)
|
||||
assert clamped is False
|
||||
|
|
|
|||
|
|
@ -178,21 +178,22 @@ def test_save_history_items_inserts_each():
|
|||
):
|
||||
saved = _save_yandex_history_items(db, result)
|
||||
assert saved == 2
|
||||
# 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE
|
||||
# houses.has_panorama — считаем именно вставки истории, а не все execute.
|
||||
# 1 batch INSERT (executemany). Фильтруем по SQL, а не по позиции вызова:
|
||||
# #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой.
|
||||
rows = _history_rows(db)
|
||||
assert isinstance(rows, list) and len(rows) == 2
|
||||
# Два коммита: панорама (до истории) + батч истории. Раньше был один.
|
||||
assert db.commit.call_count == 2
|
||||
# Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе
|
||||
# с колонкой, хвост #2674, мигр. 259.
|
||||
assert db.commit.call_count == 1
|
||||
|
||||
|
||||
def test_save_history_items_empty_no_commit():
|
||||
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
|
||||
|
||||
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
|
||||
отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%).
|
||||
Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается —
|
||||
а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает.
|
||||
отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь
|
||||
match_or_create_house вызывается до возврата — а записей по-прежнему ноль:
|
||||
истории нет, вставлять нечего.
|
||||
"""
|
||||
db = MagicMock()
|
||||
result = YandexValuationResult(
|
||||
|
|
@ -246,9 +247,9 @@ def test_save_history_items_ext_id_stable_across_calls():
|
|||
def test_save_history_items_db_error_rolls_back_batch():
|
||||
"""Any item failing rolls back the whole batch — batch semantics (finding #5).
|
||||
|
||||
#2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение
|
||||
доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает),
|
||||
а батч истории проходил бы успешно, и тест молча проверял бы не тот путь.
|
||||
#2674: side_effect адресуем по SQL, а не по позиции вызова. Урок остаётся в силе
|
||||
и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы
|
||||
не тот путь, стоит появиться любому новому execute перед вставкой истории.
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
|
|
@ -266,5 +267,4 @@ def test_save_history_items_db_error_rolls_back_batch():
|
|||
saved = _save_yandex_history_items(db, result)
|
||||
assert saved == 0 # whole batch rolled back
|
||||
db.rollback.assert_called_once()
|
||||
# Панорама коммитится отдельно и раньше — её успех не отменяет отката истории.
|
||||
assert db.commit.call_count == 1
|
||||
assert db.commit.call_count == 0
|
||||
|
|
|
|||
510
tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py
Normal file
510
tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
"""Unit tests for the `houses` fallback tier of `geocode()` (#2626).
|
||||
|
||||
Covers:
|
||||
- `_norm_local_house`: normalization of corpus/slash house-number forms
|
||||
(«49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»).
|
||||
- `_extract_local_house_token`: pulling the house-number token out of a raw
|
||||
user address, WITH the corpus/slash suffix that `_parse_street_house`'s
|
||||
`_HOUSE_NUM` drops.
|
||||
- `_clean_local_house_street` / `_row_local_house`: extracting a comparable
|
||||
(street, house) pair out of the free-text `houses.address` column (multiple
|
||||
scraper source formats — avito/cian/derived/yandex_valuation).
|
||||
- `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical
|
||||
name), regardless of leading district/city noise.
|
||||
- `_local_houses_match`: full tier with a mocked DB session —
|
||||
exact number match, corpus-1 fallback guess («49» → «49к1»), and the
|
||||
defensive "ambiguous → None" invariant (no guessing on >1 distinct match).
|
||||
- `geocode()` wiring: local-houses tier is the LAST step, only reached when
|
||||
cache/geoportal/cadastral/Nominatim all miss, and marks
|
||||
`GeocodeResult.address_refined=True`.
|
||||
|
||||
Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as
|
||||
regression fixtures: «ул Онуфриева, д 24» → «Начдива Онуфриева, 24к1»,
|
||||
«ул. Хрустальногорская, д. 88/2» → exact match, «ул Крестинского, д 49» →
|
||||
genuinely ambiguous in prod data (two DIFFERENT buildings both stored as
|
||||
«Крестинского, 49к1» — must NOT resolve, per the defensive "no guessing" rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
from app.services.geocoder import ( # noqa: E402
|
||||
GeocodeSuggestion,
|
||||
_clean_local_house_street,
|
||||
_extract_local_house_token,
|
||||
_local_houses_match,
|
||||
_norm_local_house,
|
||||
_row_local_house,
|
||||
_street_tail_matches,
|
||||
geocode,
|
||||
)
|
||||
|
||||
# ── _norm_local_house ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("49 к 1", "49к1"),
|
||||
("49-к1", "49к1"),
|
||||
("49 корпус 1", "49к1"),
|
||||
("49 корп. 1", "49к1"),
|
||||
("88 / 2", "88/2"),
|
||||
("88/2", "88/2"),
|
||||
("35А", "35а"),
|
||||
("13Б", "13б"),
|
||||
("13-б", "13б"),
|
||||
("44", "44"),
|
||||
],
|
||||
)
|
||||
def test_norm_local_house(raw: str, expected: str) -> None:
|
||||
assert _norm_local_house(raw) == expected
|
||||
|
||||
|
||||
# ── _extract_local_house_token ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("address", "expected"),
|
||||
[
|
||||
("ул Крестинского, д 49", "49"),
|
||||
("ул. Хрустальногорская, д. 88/2", "88/2"),
|
||||
("ул Онуфриева, д 24", "24"),
|
||||
("Крестинского 49к1", "49к1"),
|
||||
("8 Марта 204", "204"), # digit-leading street name doesn't confuse it
|
||||
("Малышева 30", "30"),
|
||||
# #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11»
|
||||
# → дом «11», чужое здание). Реальные строки из trade_in_estimates:
|
||||
(
|
||||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||||
"ул Педагогическая, д 15, кв 11",
|
||||
"15",
|
||||
),
|
||||
(
|
||||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||||
"ул Педагогическая, д 15, кв 48",
|
||||
"15",
|
||||
),
|
||||
# корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома,
|
||||
# «кв 41» — уйти:
|
||||
(
|
||||
"620149, Свердловская обл, г Екатеринбург, Ленинский р-н, "
|
||||
"ул Начдива Онуфриева, д 26 к 1, кв 41",
|
||||
"26к1",
|
||||
),
|
||||
# подъезд/этаж — тот же класс бага, что и квартира (последнее число в
|
||||
# строке — не дом):
|
||||
(
|
||||
"Россия, Свердловская область, Екатеринбург, Трамвайный переулок, "
|
||||
"2к2, подъезд 1, этаж 25, кв. 205",
|
||||
"2к2",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extract_local_house_token(address: str, expected: str) -> None:
|
||||
assert _extract_local_house_token(address) == expected
|
||||
|
||||
|
||||
def test_extract_local_house_token_none_for_garbage() -> None:
|
||||
assert _extract_local_house_token("") is None
|
||||
assert _extract_local_house_token("Екатеринбург") is None
|
||||
|
||||
|
||||
# ── _clean_local_house_street / _street_tail_matches ────────────────────────
|
||||
|
||||
|
||||
def test_clean_local_house_street_strips_type_regardless_of_position() -> None:
|
||||
"""Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются."""
|
||||
assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева"
|
||||
assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская"
|
||||
|
||||
|
||||
def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None:
|
||||
"""Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива
|
||||
Онуфриева» (каноничное имя ГАР, как в houses.address)."""
|
||||
assert _street_tail_matches("начдива онуфриева", "онуфриева") is True
|
||||
|
||||
|
||||
def test_street_tail_matches_exact_equality() -> None:
|
||||
assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True
|
||||
|
||||
|
||||
def test_street_tail_matches_rejects_non_suffix_substring() -> None:
|
||||
"""«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово."""
|
||||
assert _street_tail_matches("онуфриева южная", "онуфриева") is False
|
||||
|
||||
|
||||
# ── _row_local_house: разбор houses.address разных форматов источников ──────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("row_address", "expected"),
|
||||
[
|
||||
(
|
||||
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
|
||||
("р-н чкаловский мкр. ботанический крестинского", "49к1"),
|
||||
),
|
||||
("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")),
|
||||
("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")),
|
||||
(
|
||||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"),
|
||||
),
|
||||
("Крестинского, 44", ("крестинского", "44")),
|
||||
# house-then-district order («·» separator, no comma before house) —
|
||||
# match-from-start of the LAST comma-segment still finds the leading token.
|
||||
("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")),
|
||||
],
|
||||
)
|
||||
def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None:
|
||||
assert _row_local_house(row_address) == expected
|
||||
|
||||
|
||||
def test_row_local_house_none_without_house_segment() -> None:
|
||||
"""Нет запятой (номер дома не отделён сегментом) → None, не гадаем."""
|
||||
assert _row_local_house("Крестинского") is None
|
||||
assert _row_local_house("") is None
|
||||
|
||||
|
||||
# ── _local_houses_match: full tier, mocked db ────────────────────────────────
|
||||
|
||||
|
||||
def _make_row(address: str, lat: float, lon: float) -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.address = address
|
||||
row.lat = lat
|
||||
row.lon = lon
|
||||
return row
|
||||
|
||||
|
||||
def _db_with_rows(rows: list[MagicMock]) -> MagicMock:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.fetchall.return_value = rows
|
||||
return db
|
||||
|
||||
|
||||
def test_local_houses_match_exact_house_number() -> None:
|
||||
"""«88/2» точно совпадает с единственной строкой houses — возвращает её координаты."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("Хрустальногорская ул.,88", 56.79412, 60.498687),
|
||||
_make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106),
|
||||
]
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "хрустальногорская", "88/2")
|
||||
|
||||
assert hit is not None
|
||||
assert isinstance(hit, GeocodeSuggestion)
|
||||
assert hit.lat == pytest.approx(56.793218)
|
||||
assert hit.lon == pytest.approx(60.497106)
|
||||
assert hit.kind == "house"
|
||||
|
||||
|
||||
def test_local_houses_match_street_tail_and_corpus1_guess() -> None:
|
||||
"""«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ
|
||||
корпус «24к1» → уверенная догадка (нет sibling-корпусов — не угадайка)."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row(
|
||||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
56.802928,
|
||||
60.551696,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "онуфриева", "24")
|
||||
|
||||
assert hit is not None
|
||||
assert hit.lat == pytest.approx(56.802928)
|
||||
assert hit.lon == pytest.approx(60.551696)
|
||||
|
||||
|
||||
def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None:
|
||||
"""#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ
|
||||
разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «→24к1»
|
||||
не угадывает конкретное здание среди known-siblings — честный None, не
|
||||
«уверенный» результат с confidence='exact' на случайно выбранном доме."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row(
|
||||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
56.802928,
|
||||
60.551696,
|
||||
),
|
||||
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
|
||||
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||||
|
||||
|
||||
def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None:
|
||||
"""Sibling-guard ловит не только «кN», но и «/N» вариант того же номера."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("улица X, 24к1", 56.80, 60.60),
|
||||
_make_row("улица X, 24/2", 56.81, 60.61),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "x", "24") is None
|
||||
|
||||
|
||||
def test_local_houses_match_no_corpus1_candidate_returns_none() -> None:
|
||||
"""Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
|
||||
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||||
|
||||
|
||||
def test_local_houses_match_ambiguous_exact_number_returns_none() -> None:
|
||||
"""Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами
|
||||
(две разные строки houses) — неоднозначность, фолбэк не угадывает, None."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row(
|
||||
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
|
||||
56.789895,
|
||||
60.632464,
|
||||
),
|
||||
_make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "крестинского", "49к1") is None
|
||||
|
||||
|
||||
def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None:
|
||||
"""«49» → «49к1»-кандидатов больше одного (разные координаты) → None."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("улица X, 49к1", 56.80, 60.60),
|
||||
_make_row("улица X, 49к1", 56.81, 60.61),
|
||||
]
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "x", "49") is None
|
||||
|
||||
|
||||
def test_local_houses_match_deduplicates_same_building_different_sources() -> None:
|
||||
"""Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными
|
||||
координатами — НЕ считается неоднозначностью (дедуп по округлённым coords)."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("улица X, 49к1", 56.800001, 60.600001),
|
||||
_make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source
|
||||
]
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "x", "49к1")
|
||||
|
||||
assert hit is not None
|
||||
assert hit.lat == pytest.approx(56.800001)
|
||||
|
||||
|
||||
def test_local_houses_match_no_guess_for_non_digit_house() -> None:
|
||||
"""Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1
|
||||
ДОГАДКА не пробуется (не «35к3к1»), результат None."""
|
||||
db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)])
|
||||
|
||||
assert _local_houses_match(db, "x", "35к3") is None
|
||||
|
||||
|
||||
def test_local_houses_match_returns_none_on_db_error() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("connection lost")
|
||||
|
||||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||||
|
||||
|
||||
# ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ───────────────
|
||||
|
||||
|
||||
def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None:
|
||||
"""Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 —
|
||||
~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только
|
||||
ЗАПРОС пользователя, не координаты строки-источника — bbox-фильтр внутри
|
||||
`_local_houses_match` обязан отбросить такую строку, а не вернуть её как
|
||||
confidence='exact' совпадение чужого города."""
|
||||
db = _db_with_rows(
|
||||
[_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ
|
||||
)
|
||||
|
||||
assert _local_houses_match(db, "маяковского", "7") is None
|
||||
|
||||
|
||||
def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None:
|
||||
"""Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT)
|
||||
по-прежнему проходит — bbox-фильтр не режет реальные ЕКБ-дома."""
|
||||
db = _db_with_rows(
|
||||
[_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)],
|
||||
)
|
||||
|
||||
hit = _local_houses_match(db, "маяковского", "8")
|
||||
|
||||
assert hit is not None
|
||||
assert hit.lat == pytest.approx(56.862701)
|
||||
|
||||
|
||||
# ── deterministic ORDER BY (#2626 review R2 #5) ──────────────────────────────
|
||||
|
||||
|
||||
def test_local_houses_match_query_has_deterministic_order_by() -> None:
|
||||
"""Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку
|
||||
в порядке сканирования — недетерминированно между вызовами. SQL обязан
|
||||
сортировать явно."""
|
||||
db = _db_with_rows([])
|
||||
|
||||
_local_houses_match(db, "x", "1")
|
||||
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "ORDER BY" in sql_text.upper()
|
||||
|
||||
|
||||
# ── geocode() wiring — last-resort tier, sets address_refined ───────────────
|
||||
|
||||
|
||||
async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None:
|
||||
"""Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир
|
||||
вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||||
lat=56.802928,
|
||||
lon=60.551696,
|
||||
kind="house",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder._cache_put") as mock_cache_put,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._local_houses_match",
|
||||
return_value=hit,
|
||||
) as mock_local,
|
||||
):
|
||||
result = await geocode("ул Онуфриева, д 24", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.802928)
|
||||
assert result.confidence == "exact"
|
||||
assert result.address_refined is True
|
||||
mock_local.assert_called_once()
|
||||
# #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник
|
||||
# координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует.
|
||||
mock_cache_put.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None:
|
||||
"""geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` —
|
||||
флаг честно относится ТОЛЬКО к houses-фолбэку."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="ул. Серова, д. 27, Екатеринбург",
|
||||
full_address="ул. Серова, д. 27, Екатеринбург",
|
||||
lat=56.81188,
|
||||
lon=60.59739,
|
||||
kind="house",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=hit),
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch(
|
||||
"app.services.geocoder._local_houses_match",
|
||||
) as mock_local,
|
||||
):
|
||||
result = await geocode("Серова 27", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.address_refined is False
|
||||
mock_local.assert_not_called()
|
||||
|
||||
|
||||
async def test_geocode_returns_none_when_local_houses_also_misses() -> None:
|
||||
"""Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем)."""
|
||||
db = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local,
|
||||
):
|
||||
result = await geocode("ул Онуфриева, д 24", db)
|
||||
|
||||
assert result is None
|
||||
mock_local.assert_called_once()
|
||||
|
||||
|
||||
async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None:
|
||||
"""End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом
|
||||
«кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11
|
||||
(`Педагогическая ул.,11` — чужое здание) — `_local_houses_match` не
|
||||
замокан, проверяем полную цепочку `geocode()` → `_extract_local_house_token`
|
||||
→ SQL-lookup."""
|
||||
db = _db_with_rows(
|
||||
[
|
||||
_make_row("Педагогическая ул.,11", 56.835387, 60.654104),
|
||||
_make_row("Педагогическая ул.,15", 56.835284, 60.655829),
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder._cache_put") as mock_cache_put,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = await geocode(
|
||||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||||
"ул Педагогическая, д 15, кв 11",
|
||||
db,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.835284)
|
||||
assert result.lon == pytest.approx(60.655829)
|
||||
assert result.address_refined is True
|
||||
mock_cache_put.assert_not_called()
|
||||
35
tradein-mvp/backend/tests/test_health_endpoint.py
Normal file
35
tradein-mvp/backend/tests/test_health_endpoint.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""GET/HEAD /health — uptime-monitor honesty (#uptime-honest-green).
|
||||
|
||||
GlitchTip PING-мониторы шлют HEAD (или GET без чтения тела). Голый
|
||||
`@app.get("/health")` без явного HEAD-хендлера отдаёт 405 на HEAD — Starlette
|
||||
НЕ добавляет HEAD автоматически к FastAPI `@app.get()` роуту (в отличие от
|
||||
низкоуровневого `Route(methods=["GET"])`). Прод-симптом: `HEAD /health` → 405,
|
||||
монитор либо красный по конструкции, либо (при PING без сверки статуса)
|
||||
зелёный вне зависимости от факта. Тест фиксирует оба метода.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health_get_ok() -> None:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_head_ok_no_body() -> None:
|
||||
"""HEAD /health — то, что реально шлёт uptime-monitor. Должен быть 200, без тела."""
|
||||
client = TestClient(app)
|
||||
resp = client.head("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b""
|
||||
# RFC 9110 §9.3.2 — HEAD должен вернуть те же заголовки представления
|
||||
# (Content-Type), что и GET; Content-Length допустимо не совпадать (payload
|
||||
# header field, MAY быть опущен для HEAD).
|
||||
assert resp.headers["content-type"] == "application/json"
|
||||
336
tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py
Normal file
336
tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""honest-run-status (2026-08-15): статус прогона не должен рапортовать 'done' поверх
|
||||
провала или нуля. Три прод-факта закрыты этой правкой:
|
||||
|
||||
(a) avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1}
|
||||
-> status='done' — 89% отказов, статус зелёный. mark_backfill_finished звал
|
||||
mark_done, потому что produced=6 (>0); ни _sweep_run_did_nothing (нет
|
||||
anchors_total/errors_count у backfill'ов), ни _phase_totally_failed (ключи
|
||||
"attempted"/"failed" без фазового префикса) эту форму counters не ловили.
|
||||
Фикс: _failed_ratio_too_high внутри mark_done.
|
||||
|
||||
(b) yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при
|
||||
processed=5, succeeded=0, rows_inserted=0, failed_resolve=4-5 — сторож нулевого
|
||||
результата (_alert_if_consecutive_zero_results) слеп, т.к. _RESULT_COUNTER_KEYS
|
||||
не знал ни одного ключа этого sweep'а (total_seen/lots_fetched/unique_fetched).
|
||||
Фикс: _RESULT_COUNTER_KEYS дополнен 'succeeded'. Первая версия правки добавляла
|
||||
голые 'rows_inserted'/'processed' — ревью нашло, что 'rows_inserted' пишет ЕЩЁ
|
||||
rosreestr_dkp_import (66/67 прод-прогонов, здоровый ноль догнавшего импорта, а не
|
||||
отказ) и завёл бы непрерываемый ложный zero-стрик, а 'processed' — счётчик
|
||||
попыток (==limit даже при частичном провале у newbuilding_enrich) и маскирует
|
||||
реальные отказы. 'succeeded' пишут только yandex_newbuilding_sweep и
|
||||
newbuilding_enrich, численно совпадает с прежним 'rows_inserted' на всех
|
||||
прод-прогонах sweep'а — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured
|
||||
и test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже.
|
||||
|
||||
(c) admin-витрина показывала new_count=0 у трёх подряд cian_full_load, хотя реально
|
||||
сохранено saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count', ни
|
||||
'lots_inserted'. Фикс: _column_counts дополнен saved_inserted/rows_inserted.
|
||||
|
||||
Проверяем на обоих модулях (kit-копия и app-копия — байт-эквивалентны по докстрингу
|
||||
runs.py), тем же паттерном, что test_2625_run_that_did_nothing.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
|
||||
from app.services import scrape_runs as app_runs
|
||||
|
||||
_MODULES = {"kit": kit_runs, "app": app_runs}
|
||||
|
||||
|
||||
def _capture_status(mod: Any, counters: dict[str, Any]) -> list[str]:
|
||||
"""Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов.
|
||||
|
||||
Тот же helper, что в test_2625_run_that_did_nothing.py — читаем СТАТУС В SQL, а не
|
||||
имя вызванной функции.
|
||||
"""
|
||||
statuses: list[str] = []
|
||||
|
||||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||
sql = str(stmt)
|
||||
for status in ("done", "failed", "banned"):
|
||||
if f"status = '{status}'" in sql:
|
||||
statuses.append(status)
|
||||
return MagicMock()
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
with patch.object(mod, "sentry_sdk", MagicMock()):
|
||||
mod.mark_done(db, 1, dict(counters))
|
||||
return statuses
|
||||
|
||||
|
||||
def _capture_backfill_status(
|
||||
counters: dict[str, Any], *, source: str = "avito_detail_backfill", aborted: bool = False
|
||||
) -> list[str]:
|
||||
"""Прогнать app_runs.mark_backfill_finished на фейковой сессии (mark_done НЕ мокан —
|
||||
в отличие от test_backfill_honest_status.py, здесь важно именно его РЕАЛЬНОЕ
|
||||
поведение: mark_backfill_finished решает вызвать mark_done, а решает ли mark_done
|
||||
остаться 'done' или сам себя переквалифицировать в 'failed' — предмет этого теста).
|
||||
|
||||
mark_backfill_finished есть только в app_runs (kit-копия его не держит — см.
|
||||
docstring модуля runs.py, "mark_skipped есть только здесь" — тот же принцип
|
||||
относится к продуктовым финализаторам detail-backfill'ов).
|
||||
"""
|
||||
statuses: list[str] = []
|
||||
|
||||
def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock:
|
||||
sql = str(stmt)
|
||||
for status in ("done", "failed", "banned"):
|
||||
if f"status = '{status}'" in sql:
|
||||
statuses.append(status)
|
||||
return MagicMock()
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = _execute
|
||||
with patch.object(app_runs, "sentry_sdk", MagicMock()):
|
||||
app_runs.mark_backfill_finished(
|
||||
db, 1, dict(counters), source=source, aborted_by_blocks=aborted
|
||||
)
|
||||
return statuses
|
||||
|
||||
|
||||
# ── (a) failed_ratio: прод-факт avito_detail_backfill 15.08 ─────────────────────────
|
||||
|
||||
|
||||
def test_prod_fact_avito_15_08_no_longer_done() -> None:
|
||||
"""{"attempted":64,"failed":57,"enriched":6,"blocked":1} — 89% отказов — 'failed',
|
||||
НЕ 'done'. Красный на старом коде (produced=6 != 0 -> mark_done -> 'done')."""
|
||||
counters = {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1}
|
||||
assert _capture_backfill_status(counters) == ["failed"]
|
||||
|
||||
|
||||
def test_prod_fact_avito_reason_names_the_ratio() -> None:
|
||||
reason = app_runs._failed_ratio_too_high(
|
||||
{"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1}
|
||||
)
|
||||
assert reason is not None
|
||||
assert "failed-ratio-honest-status" in reason
|
||||
assert "57 из 64" in reason
|
||||
assert "89%" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
@pytest.mark.parametrize(
|
||||
("counters", "flagged", "why"),
|
||||
[
|
||||
({"attempted": 64, "failed": 57}, True, "прод-факт: 89% отказов"),
|
||||
({"attempted": 10, "failed": 5}, True, "ровно порог failed (0.5)"),
|
||||
({"attempted": 20, "failed": 3}, True, "ровно порог degraded (0.15)"),
|
||||
({"attempted": 20, "failed": 2}, False, "ниже порога degraded (0.10)"),
|
||||
({"attempted": 2, "failed": 2}, False, "ratio=1.0, но < _FAILED_RATIO_MIN_ATTEMPTS"),
|
||||
({"attempted": 0, "failed": 0}, False, "нет попыток вовсе"),
|
||||
({"failed": 5}, False, "нет attempted — чужой словарь"),
|
||||
({"attempted": 50}, False, "нет failed — чужой словарь"),
|
||||
({}, False, "пустые counters"),
|
||||
(
|
||||
{"anchors_total": 5, "errors_count": 5, "lots_fetched": 0},
|
||||
False,
|
||||
"sweep-словарь (anchors_total), не detail-backfill",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_failed_ratio_classifier_boundaries(
|
||||
name: str, counters: dict[str, Any], flagged: bool, why: str
|
||||
) -> None:
|
||||
reason = _MODULES[name]._failed_ratio_too_high(counters)
|
||||
assert (reason is not None) is flagged, why
|
||||
|
||||
|
||||
# ── (5) не должен палить прогоны с малой/умеренной долей отказов ────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_low_failure_ratio_stays_done(name: str) -> None:
|
||||
"""Штатный шум (10% отказов) не становится 'failed' — не каждый отказ диагноз."""
|
||||
counters = {"attempted": 50, "enriched": 45, "failed": 5}
|
||||
assert _capture_status(_MODULES[name], counters) == ["done"]
|
||||
|
||||
|
||||
def test_tiny_batch_zero_produced_fails_via_old_rule_not_ratio() -> None:
|
||||
"""2 попытки, обе отказали, produced=0 — доля тут не при чём (attempted < floor
|
||||
_FAILED_RATIO_MIN_ATTEMPTS, _failed_ratio_too_high вернул бы None); статус всё
|
||||
равно 'failed', но по СТАРОМУ правилу #2674 (produced==0), внутри
|
||||
mark_backfill_finished — mark_done/_failed_ratio_too_high тут не вызываются вовсе.
|
||||
Показывает, что новая проверка не дублирует и не подменяет старую."""
|
||||
counters = {"attempted": 2, "enriched": 0, "failed": 2}
|
||||
assert _capture_backfill_status(counters) == ["failed"]
|
||||
|
||||
|
||||
def test_tiny_batch_with_partial_success_stays_done() -> None:
|
||||
"""2 попытки, 1 успех, 1 отказ (ratio=0.5, но attempted < floor=3) — стрик слишком
|
||||
короткий, чтобы доля что-то значила -> остаётся 'done'."""
|
||||
counters = {"attempted": 2, "enriched": 1, "failed": 1}
|
||||
assert _capture_backfill_status(counters) == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_honest_empty_sweep_unaffected_by_failed_ratio(name: str) -> None:
|
||||
"""Сознательно спящее расписание (город без новостроек): sweep-словарь без
|
||||
attempted/failed вовсе -> failed_ratio не о чем судить, честная пустота остаётся
|
||||
'done' (см. также test_2625_run_that_did_nothing.py::test_honest_empty_stays_done)."""
|
||||
counters = {"anchors_total": 1, "errors_count": 0, "lots_fetched": 0}
|
||||
assert _capture_status(_MODULES[name], counters) == ["done"]
|
||||
|
||||
|
||||
# ── (b) _RESULT_COUNTER_KEYS: прод-факт yandex_newbuilding_sweep 26.07-10.08 ─────────
|
||||
|
||||
|
||||
def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None:
|
||||
"""processed=5, succeeded=0, rows_inserted=0, failed_resolve=4 — раньше
|
||||
_run_result_count возвращал None ("не измерено"); теперь — измеренный 0 (через
|
||||
'succeeded', не 'rows_inserted' — см. ниже, почему ключ переигран ревью)."""
|
||||
counters = {
|
||||
"total": 309,
|
||||
"fetchable": 200,
|
||||
"pending": 50,
|
||||
"processed": 5,
|
||||
"skipped_already_enriched": 0,
|
||||
"succeeded": 0,
|
||||
"resolved_slug": 1,
|
||||
"failed_resolve": 4,
|
||||
"failed_fetch": 0,
|
||||
"rows_inserted": 0,
|
||||
"duration_sec": 42.0,
|
||||
}
|
||||
assert app_runs._run_result_count(counters) == 0
|
||||
assert kit_runs._run_result_count(counters) == 0
|
||||
|
||||
|
||||
def test_succeeded_is_the_measured_key_not_rows_inserted_or_processed() -> None:
|
||||
"""'succeeded' читается как результат; голые 'rows_inserted'/'processed' в
|
||||
_RESULT_COUNTER_KEYS больше не участвуют (были в первой версии правки, снято
|
||||
ревью — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured и
|
||||
test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже)."""
|
||||
counters = {"processed": 5, "rows_inserted": 0}
|
||||
assert app_runs._run_result_count(counters) is None
|
||||
assert kit_runs._run_result_count(counters) is None
|
||||
|
||||
|
||||
def test_rosreestr_dkp_import_healthy_zero_stays_unmeasured() -> None:
|
||||
"""Прод-факт rosreestr_dkp_import (2026-08-15, 66 из 67 прогонов за 90д): инкрементальный
|
||||
импорт догнал источник — rows_fetched==rows_skipped, rows_inserted=0. Это ЗДОРОВЫЙ
|
||||
ответ (нечего вставлять), а не отказ; словарь не содержит 'succeeded' вовсе.
|
||||
|
||||
Первая версия правки добавляла голый 'rows_inserted' в _RESULT_COUNTER_KEYS — тогда
|
||||
этот прод-факт читался бы как "измеренный провал" и копил бы практически
|
||||
непрерываемый zero-стрик (rosreestr_dkp_import не прерывается другим статусом:
|
||||
он либо 'done' с этим же нулём, либо не бежал). Ревью поймало это до деплоя —
|
||||
правильный ответ: "не измерено" (None), стрик не копится."""
|
||||
counters = {
|
||||
"last_id": 6829903,
|
||||
"batches_done": 49,
|
||||
"rows_errored": 0,
|
||||
"rows_fetched": 96974,
|
||||
"rows_skipped": 96974,
|
||||
"rows_updated": 0,
|
||||
"rows_inserted": 0,
|
||||
}
|
||||
assert app_runs._run_result_count(counters) is None
|
||||
assert kit_runs._run_result_count(counters) is None
|
||||
|
||||
|
||||
def test_newbuilding_enrich_partial_failure_not_masked_by_processed() -> None:
|
||||
"""Прод-факт newbuilding_enrich (09.08): processed=25 (счётчик ПОПЫТОК, ==limit),
|
||||
succeeded=14 — 44% отказов. Если бы сторож читал 'processed' как результат, партиальный
|
||||
провал замаскировался бы под measured-25 (сторож нулевого результата промолчал бы
|
||||
ровно там, где должен был сработать при полном провале). 'succeeded' даёт честные 14."""
|
||||
counters = {
|
||||
"failed": 11,
|
||||
"enriched": 14,
|
||||
"attempted": 25,
|
||||
"processed": 25,
|
||||
"succeeded": 14,
|
||||
"failed_fetch": 11,
|
||||
}
|
||||
assert app_runs._run_result_count(counters) == 14
|
||||
assert kit_runs._run_result_count(counters) == 14
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) -> None:
|
||||
"""(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с succeeded=0
|
||||
-> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал результат "не
|
||||
измеренным" и молчал бы вечно (см. #2703 в docstring модуля)."""
|
||||
mod = _MODULES[name]
|
||||
row = MagicMock()
|
||||
row.status = "done"
|
||||
row.counters = {"processed": 5, "succeeded": 0, "rows_inserted": 0, "failed_resolve": 4}
|
||||
db = MagicMock()
|
||||
result = MagicMock()
|
||||
result.fetchall.return_value = [row, row, row]
|
||||
db.execute.return_value = result
|
||||
with patch.object(mod, "sentry_sdk") as mock_sentry:
|
||||
mod._alert_if_consecutive_zero_results(db, "yandex_newbuilding_sweep")
|
||||
mock_sentry.capture_message.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_zero_result_watchdog_silent_on_rosreestr_dkp_import_streak(name: str) -> None:
|
||||
"""Негативный аналог теста выше: та же лестница из 3 подряд 'done', но словарь
|
||||
rosreestr_dkp_import (нет 'succeeded') -> сторож не считает результат измеренным
|
||||
и НЕ шлёт алерт — регрессионный тест на замечание ревью (HIGH #1)."""
|
||||
mod = _MODULES[name]
|
||||
row = MagicMock()
|
||||
row.status = "done"
|
||||
row.counters = {
|
||||
"last_id": 6829903,
|
||||
"rows_fetched": 96974,
|
||||
"rows_skipped": 96974,
|
||||
"rows_inserted": 0,
|
||||
}
|
||||
db = MagicMock()
|
||||
result = MagicMock()
|
||||
result.fetchall.return_value = [row, row, row]
|
||||
db.execute.return_value = result
|
||||
with patch.object(mod, "sentry_sdk") as mock_sentry:
|
||||
mod._alert_if_consecutive_zero_results(db, "rosreestr_dkp_import")
|
||||
mock_sentry.capture_message.assert_not_called()
|
||||
|
||||
|
||||
# ── (c) _column_counts: прод-факт cian_full_load new_count=0 при saved_inserted>0 ───
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_prod_fact_cian_full_load_saved_inserted_surfaces_as_new_count(name: str) -> None:
|
||||
"""saved_inserted=482 (прод-факт: три подряд прогона 482/214/239) — new_count
|
||||
больше не 0, хотя ключей 'new_count'/'lots_inserted' в counters нет вовсе."""
|
||||
counters = {"unique_fetched": 1200, "saved_inserted": 482, "saved_updated": 30}
|
||||
total_seen, new_count = _MODULES[name]._column_counts(counters)
|
||||
assert total_seen == 1200
|
||||
assert new_count == 482
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_yandex_newbuilding_rows_inserted_surfaces_as_new_count(name: str) -> None:
|
||||
counters = {"rows_inserted": 7}
|
||||
_, new_count = _MODULES[name]._column_counts(counters)
|
||||
assert new_count == 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_new_count_priority_unchanged_by_new_keys(name: str) -> None:
|
||||
"""'new_count' явный ключ всё ещё побеждает 'lots_inserted'/'saved_inserted' —
|
||||
расширение списка не меняет приоритет уже существующих ключей."""
|
||||
counters = {"new_count": 5, "lots_inserted": 99, "saved_inserted": 1}
|
||||
_, new_count = _MODULES[name]._column_counts(counters)
|
||||
assert new_count == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_MODULES))
|
||||
def test_lots_inserted_still_beats_saved_inserted(name: str) -> None:
|
||||
"""Порядок пикулярно НЕ переставлен для уже существующей пары — 'lots_inserted'
|
||||
(city/newbuilding-sweep'ы) проверяется раньше 'saved_inserted' (full-load'ы),
|
||||
т.к. это разные, непересекающиеся семейства источников."""
|
||||
counters = {"lots_inserted": 12, "saved_inserted": 999}
|
||||
_, new_count = _MODULES[name]._column_counts(counters)
|
||||
assert new_count == 12
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"""listing_segment upsert self-heal: строка не должна вечно застревать с NULL-сегментом.
|
||||
|
||||
Баг: `scraper_kit.base.save_listings` писал `listing_segment` ТОЛЬКО в INSERT-ветке
|
||||
upsert'а — колонки не было ни в `ON CONFLICT (dedup_hash) DO UPDATE SET`, ни в
|
||||
reconcile-UPDATE (dedup_hash-drift fallback, срабатывает при UniqueViolation по
|
||||
(source, source_id)). Итог: если первый скрейп объявления не смог определить сегмент
|
||||
(классификатор вернул None), строка рождалась с `listing_segment IS NULL` и
|
||||
НИКОГДА не самочинялась на последующих пересборах, даже когда сегмент становился
|
||||
определим. Симптом лечили отдельной джобой деактивации
|
||||
(`deactivate_stale_{cian,yandex}_null_segment`, миграция 266, PR #2908) — чистит
|
||||
мусор в `is_active`, но не лечит саму запись сегмента.
|
||||
|
||||
Fix: `listing_segment = COALESCE(EXCLUDED.listing_segment, listings.listing_segment)`
|
||||
в ON CONFLICT DO UPDATE + `listing_segment = COALESCE(:listing_segment, listing_segment)`
|
||||
в reconcile UPDATE — тот же идиом, что уже применён для `city` (#2594,
|
||||
test_listings_city_from_sweep.py) и `kitchen_area_m2`/`ceiling_height_m` (#2007):
|
||||
новое значение обновляет строку, но НЕ затирает уже известное пустым.
|
||||
|
||||
Тесты здесь, как и соседний test_listings_city_from_sweep.py, мокают db.execute и
|
||||
проверяют SQL-текст + bind-параметры (unit-уровень, без реальной Postgres) —
|
||||
COALESCE-семантику "новое непустое побеждает / пустое не затирает старое" исполняет
|
||||
сама база при выполнении запроса.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.base import ScrapedLot as KitLot
|
||||
from scraper_kit.base import save_listings as kit_save_listings
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _nested_ctx() -> Any:
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _mock_db_insert_path(listing_id: int = 42) -> MagicMock:
|
||||
"""Session mock для fresh INSERT path (xmax = 0 → inserted)."""
|
||||
insert_row = MagicMock()
|
||||
insert_row.id = listing_id
|
||||
insert_row.inserted = True
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
res.fetchone.return_value = insert_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
db.begin_nested.side_effect = _nested_ctx
|
||||
return db
|
||||
|
||||
|
||||
def _find_call(db: MagicMock, needle: str) -> tuple[str, dict[str, Any]]:
|
||||
for call in db.execute.call_args_list:
|
||||
sql = str(call.args[0])
|
||||
if needle in sql:
|
||||
params = call.args[1] if len(call.args) > 1 else {}
|
||||
return sql, params
|
||||
raise AssertionError(f"SQL containing {needle!r} not found")
|
||||
|
||||
|
||||
def _kit_matcher() -> MagicMock:
|
||||
matcher = MagicMock()
|
||||
matcher.match_or_create_house.return_value = (101, 1.0, "new")
|
||||
matcher.upsert_listing_source.return_value = None
|
||||
return matcher
|
||||
|
||||
|
||||
def _lot(
|
||||
source: str = "avito",
|
||||
source_id: str = "1",
|
||||
listing_segment: str | None = None,
|
||||
) -> KitLot:
|
||||
return KitLot(
|
||||
source=source,
|
||||
source_url=f"https://www.{source}.ru/item/{source_id}",
|
||||
source_id=source_id,
|
||||
address="ул. Победы, 30",
|
||||
listing_segment=listing_segment,
|
||||
price_rub=3_000_000,
|
||||
)
|
||||
|
||||
|
||||
# ── save_listings(...) — INSERT path ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_listings_writes_listing_segment_into_insert_sql() -> None:
|
||||
"""listing_segment передаётся в SQL params И колонка есть в INSERT-списке."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment="vtorichka")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "listing_segment" in sql, "listing_segment column must be in INSERT column list"
|
||||
assert params["listing_segment"] == "vtorichka"
|
||||
|
||||
|
||||
def test_save_listings_listing_segment_none_backward_compat() -> None:
|
||||
"""Классификатор не определил сегмент (None) — INSERT всё равно проходит, NULL."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment=None)
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
_sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert params["listing_segment"] is None
|
||||
|
||||
|
||||
# ── ON CONFLICT DO UPDATE — COALESCE self-heal (главный фикс) ──────────────
|
||||
|
||||
|
||||
def test_save_listings_on_conflict_coalesces_listing_segment() -> None:
|
||||
"""ON CONFLICT DO UPDATE — listing_segment = COALESCE(EXCLUDED.listing_segment,
|
||||
listings.listing_segment), не blind overwrite и не "никогда не обновляется"."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment="vtorichka")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "listing_segment = COALESCE(" in sql
|
||||
assert "EXCLUDED.listing_segment, listings.listing_segment" in sql
|
||||
# Повторный скрейп с ОПРЕДЕЛЁННЫМ сегментом — новое значение уходит в EXCLUDED,
|
||||
# COALESCE на стороне Postgres применит его к прежде-NULL строке (self-heal).
|
||||
assert params["listing_segment"] == "vtorichka"
|
||||
|
||||
|
||||
def test_save_listings_on_conflict_listing_segment_none_does_not_blind_overwrite() -> None:
|
||||
"""Повторный скрейп БЕЗ сегмента (classifier снова None) — SQL всё равно
|
||||
несёт COALESCE (не голый EXCLUDED), значит уже известный сегмент строки
|
||||
в БД НЕ будет затёрт пустым при выполнении запроса."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment=None)
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "listing_segment = COALESCE(" in sql
|
||||
assert "EXCLUDED.listing_segment, listings.listing_segment" in sql
|
||||
assert params["listing_segment"] is None
|
||||
|
||||
|
||||
# ── Reconcile UPDATE (dedup_hash drift) — тот же self-heal ─────────────────
|
||||
|
||||
|
||||
def test_save_listings_reconcile_update_coalesces_listing_segment() -> None:
|
||||
"""dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:listing_segment,
|
||||
listing_segment), не blind overwrite. Без этого фикса строки, дошедшие до
|
||||
reconcile (content дрейфит, старый dedup_hash не находится, INSERT ловит
|
||||
UniqueViolation по (source, source_id)), остались бы незалеченными."""
|
||||
import psycopg.errors
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
uv_orig = psycopg.errors.UniqueViolation()
|
||||
integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig)
|
||||
rec_row = MagicMock()
|
||||
rec_row.id = 88
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
raise integrity_err
|
||||
elif "UPDATE listings" in s and "SET dedup_hash" in s:
|
||||
res.fetchone.return_value = rec_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
|
||||
@contextmanager
|
||||
def _nested() -> Any:
|
||||
try:
|
||||
yield MagicMock()
|
||||
except IntegrityError:
|
||||
raise
|
||||
|
||||
db.begin_nested.side_effect = _nested
|
||||
|
||||
lot = _lot(source="avito", source_id="7960764619", listing_segment="novostroyki")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "SET dedup_hash")
|
||||
assert "listing_segment = COALESCE(:listing_segment, listing_segment)" in sql
|
||||
assert params["listing_segment"] == "novostroyki"
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
"""Static guards for migration 262 (wave 2 — оставшиеся 40 городов Свердловской обл.).
|
||||
|
||||
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой
|
||||
БД; здесь фиксируем структурные инварианты миграции (транзакционность, отсутствие DDL,
|
||||
отсутствие psycopg CAST-ловушки, все enabled=false) и, ГЛАВНОЕ, parity-guard между
|
||||
data/sql (что реально сеется в scrape_schedules) и `scraper_kit.orchestration.pipeline`
|
||||
(`CITY_ANCHORS`/`CITY_LOCATIONS`, откуда sweep берёт координаты и provider-id по
|
||||
`default_params->>'city'`):
|
||||
|
||||
- каждый slug из миграций 179_/262_ ОБЯЗАН существовать в CITY_ANCHORS — иначе
|
||||
`get_city_anchors(slug)` вернёт None и `_job_*_city_sweep` молча упадёт на
|
||||
EKB_ANCHORS (город "включат", а сборка физически уйдёт в Екатеринбург);
|
||||
- и наоборот: КАЖДЫЙ ключ CITY_ANCHORS (кроме None-пути ЕКБ) обязан иметь schedule-
|
||||
строки где-то (179_ ИЛИ 262_);
|
||||
- #262 ревью (после первой версии файла): строка для (provider, city) существует
|
||||
ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий provider-идентификатор в CITY_LOCATIONS
|
||||
подтверждён (не None) — НЕ "каждый город получает все 3 источника". Первая версия
|
||||
заводила все 123 (41×3) строки, планируя добыть идентификаторы ПОСЛЕ — это был бы
|
||||
ровно тот силентный ЕКБ-fallback баг, о котором она сама предупреждала. Этот файл
|
||||
проверяет обратное соответствие в обе стороны для avito_slug/yandex_rgid/
|
||||
cian_region_id.
|
||||
|
||||
Это ловит ИМЕННО тот класс регрессии, которого просил избежать заказчик: опечатка в
|
||||
slug/рассинхрон идентификатора при заведении миграции, из-за которой sweep молча
|
||||
резолвится на EKB_ANCHORS или ЕКБ region_id/rgid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from scraper_kit.orchestration.pipeline import CITY_ANCHORS, CITY_LOCATIONS
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_179 = _SQL_DIR / "179_scrape_schedules_seed_oblast_city_sweeps.sql"
|
||||
_MIGRATION_262 = _SQL_DIR / "262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql"
|
||||
|
||||
_PROVIDERS = ("avito", "cian", "yandex")
|
||||
|
||||
# source-строка внутри VALUES (), напр. " 'avito_city_sweep_nizhniy_tagil',".
|
||||
# Ограничено 4-пробельным отступом + запятой в конце — не матчит примеры в header-
|
||||
# комментариях (см. "UPDATE scrape_schedules SET enabled = true WHERE source = '...';").
|
||||
_ROW_SOURCE_RE = re.compile(r"^ '(avito|cian|yandex)_city_sweep_([a-z_]+)',$", re.MULTILINE)
|
||||
|
||||
_WAVE1_SLUGS = {
|
||||
"nizhniy_tagil",
|
||||
"kamensk_uralskiy",
|
||||
"pervouralsk",
|
||||
"verkhnyaya_pyshma",
|
||||
"serov",
|
||||
}
|
||||
_WAVE2_SLUGS = {
|
||||
"novouralsk",
|
||||
"revda",
|
||||
"polevskoy",
|
||||
"asbest",
|
||||
"bogdanovich",
|
||||
"irbit",
|
||||
"krasnoufimsk",
|
||||
"berezovskiy",
|
||||
"zarechny",
|
||||
"kachkanar",
|
||||
"krasnoturinsk",
|
||||
"severouralsk",
|
||||
"ivdel",
|
||||
"tavda",
|
||||
"turinsk",
|
||||
"sysert",
|
||||
"sredneuralsk",
|
||||
"degtyarsk",
|
||||
"verkhnyaya_salda",
|
||||
"nizhnyaya_salda",
|
||||
"nevyansk",
|
||||
"artemovskiy",
|
||||
"kamyshlov",
|
||||
"alapaevsk",
|
||||
"sukhoy_log",
|
||||
"kushva",
|
||||
"krasnouralsk",
|
||||
"karpinsk",
|
||||
"nizhnyaya_tura",
|
||||
"verkhniy_tagil",
|
||||
"nizhnie_sergi",
|
||||
"lesnoy",
|
||||
"rezh",
|
||||
"aramil",
|
||||
"volchansk",
|
||||
"verkhnyaya_tura",
|
||||
"mikhaylovsk",
|
||||
"verkhoturye",
|
||||
"talitsa",
|
||||
"novaya_lyalya",
|
||||
}
|
||||
assert len(_WAVE2_SLUGS) == 40
|
||||
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
|
||||
|
||||
|
||||
def _sql(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _executable_sql(path: Path) -> str:
|
||||
"""SQL без построчных `--`-комментариев — только исполняемый код."""
|
||||
lines = []
|
||||
for raw in _sql(path).splitlines():
|
||||
code = raw.split("--", 1)[0]
|
||||
if code.strip():
|
||||
lines.append(code)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _row_sources(path: Path) -> list[tuple[str, str]]:
|
||||
"""(provider, slug) пар из исполняемых INSERT-строк VALUES (не из header-комментов)."""
|
||||
return _ROW_SOURCE_RE.findall(_executable_sql(path))
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip().lower()
|
||||
|
||||
|
||||
# ── existence / basic shape ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_migration_262_exists() -> None:
|
||||
assert _MIGRATION_262.exists(), f"missing migration: {_MIGRATION_262}"
|
||||
|
||||
|
||||
def test_migration_262_is_transactional() -> None:
|
||||
sql = _sql(_MIGRATION_262)
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_262_no_ddl() -> None:
|
||||
flat = _flat(_executable_sql(_MIGRATION_262))
|
||||
assert "alter table" not in flat
|
||||
assert "create table" not in flat
|
||||
assert "drop table" not in flat
|
||||
assert "truncate" not in flat
|
||||
|
||||
|
||||
def test_migration_262_no_psycopg_cast_trap() -> None:
|
||||
assert not re.search(r":\w+::", _sql(_MIGRATION_262))
|
||||
|
||||
|
||||
def test_migration_262_idempotent_on_conflict_do_nothing() -> None:
|
||||
flat = _flat(_executable_sql(_MIGRATION_262))
|
||||
assert "on conflict (source) do nothing" in flat
|
||||
# Одна INSERT-инструкция на весь файл (не 102 отдельных INSERT) — ON CONFLICT
|
||||
# покрывает весь батч разом; повторный прогон всего файла — no-op.
|
||||
assert flat.count("insert into scrape_schedules") == 1
|
||||
|
||||
|
||||
def test_migration_262_excludes_bisert_entirely() -> None:
|
||||
"""Бисерть — пгт, не город; у Циана её нет вообще (любой запрос отдаёт Сысерть).
|
||||
|
||||
Regression-guard: ни одной строки source LIKE '%bisert%' в исполняемом SQL."""
|
||||
flat = _flat(_executable_sql(_MIGRATION_262))
|
||||
assert "bisert" not in flat
|
||||
|
||||
|
||||
# ── row counts (102 = 23 avito + 40 cian + 39 yandex) ───────────────────────
|
||||
|
||||
|
||||
def test_migration_262_has_exactly_102_rows() -> None:
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
assert len(rows) == 102, f"expected 23 avito + 40 cian + 39 yandex = 102 rows, got {len(rows)}"
|
||||
|
||||
|
||||
def test_migration_262_row_counts_per_provider() -> None:
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS}
|
||||
for provider, slug in rows:
|
||||
by_provider[provider].add(slug)
|
||||
assert len(by_provider["avito"]) == 23
|
||||
assert len(by_provider["cian"]) == 40
|
||||
assert len(by_provider["yandex"]) == 39
|
||||
# cian — ровно все 40 wave-2 slug'ов (cian_region_id подтверждён у всех).
|
||||
assert by_provider["cian"] == _WAVE2_SLUGS
|
||||
# yandex — все, КРОМЕ mikhaylovsk.
|
||||
assert by_provider["yandex"] == _WAVE2_SLUGS - {"mikhaylovsk"}
|
||||
# avito — подмножество (только подтверждённые avito_slug), не пересекается с
|
||||
# городами, у которых avito_slug=None в CITY_LOCATIONS.
|
||||
assert by_provider["avito"] <= _WAVE2_SLUGS
|
||||
|
||||
|
||||
def test_migration_262_no_duplicate_sources() -> None:
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
sources = [f"{p}_city_sweep_{s}" for p, s in rows]
|
||||
assert len(sources) == len(set(sources)), "duplicate source в 262_"
|
||||
|
||||
|
||||
def test_migration_262_does_not_touch_wave1_cities() -> None:
|
||||
"""Regression-guard: 262_ не должен переопределять/дублировать wave-1 5 городов —
|
||||
они уже сидированы 179_ и живут (enabled управляется отдельно от них)."""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
slugs = {slug for _provider, slug in rows}
|
||||
overlap = slugs & _WAVE1_SLUGS
|
||||
assert not overlap, f"262_ пересекается с wave-1 городами: {overlap}"
|
||||
|
||||
|
||||
def test_migration_262_all_rows_enabled_false() -> None:
|
||||
"""Все 102 строки должны быть enabled=false (dormant by design — оператор включает
|
||||
волнами вручную). Считаем по числу `false,` сразу после source-литерала."""
|
||||
executable = _executable_sql(_MIGRATION_262)
|
||||
tuples = re.findall(
|
||||
r"'(?:avito|cian|yandex)_city_sweep_[a-z_]+',\s*\n\s*(true|false),",
|
||||
executable,
|
||||
)
|
||||
assert len(tuples) == 102
|
||||
assert set(tuples) == {"false"}, "найдена строка с enabled=true — нарушение dormant-инварианта"
|
||||
|
||||
|
||||
# ── CITY_LOCATIONS parity (ГЛАВНЫЙ regression-guard после ревью) ───────────
|
||||
|
||||
|
||||
def test_migration_262_row_exists_iff_identifier_confirmed() -> None:
|
||||
"""Строка (provider, city) есть в 262_ ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий
|
||||
provider-идентификатор в CITY_LOCATIONS подтверждён (не None).
|
||||
|
||||
Это единственный источник правды после ревью: НЕ "каждый wave-2 город получает 3
|
||||
строки" (так было в отклонённой первой версии — риск silent EKB-fallback), а
|
||||
"строка существует ровно там, где есть подтверждённый id".
|
||||
"""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS}
|
||||
for provider, slug in rows:
|
||||
by_provider[provider].add(slug)
|
||||
|
||||
for slug in _WAVE2_SLUGS:
|
||||
loc = CITY_LOCATIONS[slug]
|
||||
|
||||
has_avito_row = slug in by_provider["avito"]
|
||||
has_avito_id = loc.avito_slug is not None
|
||||
assert has_avito_row == has_avito_id, (
|
||||
f"{slug}: avito_slug={loc.avito_slug!r} (confirmed={has_avito_id}) но "
|
||||
f"avito-row-exists={has_avito_row} — рассинхрон CITY_LOCATIONS vs 262_"
|
||||
)
|
||||
|
||||
has_cian_row = slug in by_provider["cian"]
|
||||
has_cian_id = loc.cian_region_id is not None
|
||||
assert has_cian_row == has_cian_id, (
|
||||
f"{slug}: cian_region_id={loc.cian_region_id!r} (confirmed={has_cian_id}) но "
|
||||
f"cian-row-exists={has_cian_row} — рассинхрон CITY_LOCATIONS vs 262_"
|
||||
)
|
||||
|
||||
has_yandex_row = slug in by_provider["yandex"]
|
||||
has_yandex_id = loc.yandex_rgid is not None
|
||||
assert has_yandex_row == has_yandex_id, (
|
||||
f"{slug}: yandex_rgid={loc.yandex_rgid!r} (confirmed={has_yandex_id}) но "
|
||||
f"yandex-row-exists={has_yandex_row} — рассинхрон CITY_LOCATIONS vs 262_"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None:
|
||||
"""Каждый slug из CITY_LOCATIONS с непустым avito_slug имеет avito_city_sweep_<slug>
|
||||
строку в 262_ (wave-2) или 179_ (wave-1) — и наоборот, каждая avito-строка
|
||||
принадлежит городу с непустым avito_slug."""
|
||||
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "avito"}
|
||||
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "avito"}
|
||||
scheduled_avito = rows_262 | rows_179
|
||||
|
||||
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
|
||||
assert scheduled_avito == confirmed_avito, (
|
||||
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
|
||||
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None:
|
||||
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "yandex"}
|
||||
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "yandex"}
|
||||
scheduled_yandex = rows_262 | rows_179
|
||||
|
||||
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
|
||||
assert scheduled_yandex == confirmed_yandex, (
|
||||
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
|
||||
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> None:
|
||||
rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "cian"}
|
||||
rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "cian"}
|
||||
scheduled_cian = rows_262 | rows_179
|
||||
|
||||
confirmed_cian = {
|
||||
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
|
||||
}
|
||||
assert scheduled_cian == confirmed_cian, (
|
||||
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
|
||||
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
|
||||
)
|
||||
|
||||
|
||||
def test_mikhaylovsk_has_only_cian_row() -> None:
|
||||
"""Regression-guard для самого узкого случая: mikhaylovsk — единственный город без
|
||||
yandex_rgid (город отсутствует в гео-базе Яндекса) и без avito_slug — должен иметь
|
||||
РОВНО одну строку (cian), НЕ три."""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"}
|
||||
assert mikhaylovsk_providers == {
|
||||
"cian"
|
||||
}, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}"
|
||||
|
||||
|
||||
# ── CITY_ANCHORS parity ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_migration_262_slugs_all_exist_in_city_anchors() -> None:
|
||||
"""Каждый slug из 262_ ОБЯЗАН быть в CITY_ANCHORS — иначе get_city_anchors(slug)
|
||||
вернёт None и sweep молча резолвится на EKB_ANCHORS (собирает ЕКБ вместо города)."""
|
||||
rows = _row_sources(_MIGRATION_262)
|
||||
slugs = {slug for _provider, slug in rows}
|
||||
missing = slugs - set(CITY_ANCHORS)
|
||||
assert not missing, f"slugs из 262_ отсутствуют в CITY_ANCHORS: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_migration_179_slugs_all_exist_in_city_anchors() -> None:
|
||||
"""Тот же guard для wave-1 (179_) — sanity, что baseline не сломан этим PR."""
|
||||
rows = _row_sources(_MIGRATION_179)
|
||||
slugs = {slug for _provider, slug in rows}
|
||||
missing = slugs - set(CITY_ANCHORS)
|
||||
assert not missing, f"slugs из 179_ отсутствуют в CITY_ANCHORS: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
|
||||
"""Обратное направление: каждый ключ CITY_ANCHORS обязан иметь schedule-строки в
|
||||
179_ ИЛИ 262_ — мёртвая запись без сидов сигнализирует рассинхрон/опечатку."""
|
||||
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
|
||||
slug for _p, slug in _row_sources(_MIGRATION_262)
|
||||
}
|
||||
orphaned = set(CITY_ANCHORS) - seeded_slugs
|
||||
assert (
|
||||
not orphaned
|
||||
), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
|
||||
|
||||
|
||||
def test_city_anchors_wave2_count_and_content() -> None:
|
||||
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
|
||||
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
|
||||
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS
|
||||
assert len(CITY_ANCHORS) == 45
|
||||
|
||||
|
||||
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:
|
||||
"""Каждая wave-2 запись — ровно 1 anchor (город компактнее ЕКБ, см. коммент в
|
||||
pipeline.py) с непустой человекочитаемой подписью, заканчивающейся на 'центр'."""
|
||||
for slug in _WAVE2_SLUGS:
|
||||
anchors = CITY_ANCHORS[slug]
|
||||
assert len(anchors) == 1, f"{slug}: ожидался 1 anchor, получено {len(anchors)}"
|
||||
lat, lon, label = anchors[0]
|
||||
assert 55.0 < lat < 62.0, f"{slug}: lat={lat} вне разумного диапазона Свердловской обл."
|
||||
assert 56.0 < lon < 67.0, f"{slug}: lon={lon} вне разумного диапазона Свердловской обл."
|
||||
assert label.strip().lower().endswith("центр"), f"{slug}: подпись '{label}' без 'центр'"
|
||||
|
||||
|
||||
def test_city_locations_wave2_count_and_content() -> None:
|
||||
"""CITY_LOCATIONS покрывает ровно те же 45 slug'ов, что CITY_ANCHORS (иначе
|
||||
get_city_location(slug) вернёт None для известного anchor'ами города и сборка
|
||||
молча уйдёт на ЕКБ region_id/rgid)."""
|
||||
assert set(CITY_LOCATIONS) == set(CITY_ANCHORS)
|
||||
|
||||
|
||||
def test_city_locations_cian_region_id_never_none() -> None:
|
||||
"""cian_region_id подтверждён у ВСЕХ 45 городов (wave 1 + wave 2) — единственное
|
||||
поле CityLocation, у которого нет Optional-пути."""
|
||||
for slug, loc in CITY_LOCATIONS.items():
|
||||
assert loc.cian_region_id is not None, f"{slug}: cian_region_id не должен быть None"
|
||||
|
||||
|
||||
# ── window scheduling ───────────────────────────────────────────────────────
|
||||
|
||||
_EKB_OCCUPIED = {"avito": {6}, "cian": {2, 3, 4}, "yandex": {16}}
|
||||
_WAVE1_OCCUPIED = {
|
||||
"avito": {0, 1, 5, 7, 8},
|
||||
"cian": {9, 10, 11, 12, 13},
|
||||
"yandex": {14, 15, 17, 18, 19},
|
||||
}
|
||||
# Математический минимум коллизий одного источника в одном часе (round-robin по
|
||||
# 23/40/39 городам на 18/16/18 свободных часов соответственно).
|
||||
_MAX_COLLISIONS = {"avito": 2, "cian": 3, "yandex": 3}
|
||||
|
||||
|
||||
def _row_windows(path: Path) -> list[tuple[str, str, int, int]]:
|
||||
"""(provider, slug, window_start_hour, window_end_hour) для каждой VALUES-строки."""
|
||||
executable = _executable_sql(path)
|
||||
pattern = re.compile(
|
||||
r"'(avito|cian|yandex)_city_sweep_([a-z_]+)',\s*\n\s*(?:true|false),\s*\n\s*(\d+),\s*\n\s*(\d+),",
|
||||
)
|
||||
return [(p, s, int(a), int(b)) for p, s, a, b in pattern.findall(executable)]
|
||||
|
||||
|
||||
def test_migration_262_windows_are_one_hour() -> None:
|
||||
"""Окно ровно на час. Час 23 заворачивается в 0 (cross-midnight) — scheduler
|
||||
это поддерживает явно (`window_end_hour <= window_start_hour` → окно через
|
||||
полночь, см. scraper_kit.orchestration.scheduler)."""
|
||||
for provider, slug, start, end in _row_windows(_MIGRATION_262):
|
||||
expected = (start + 1) % 24
|
||||
assert (
|
||||
end == expected
|
||||
), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})"
|
||||
|
||||
|
||||
def test_migration_262_window_hours_satisfy_db_check_constraint() -> None:
|
||||
"""Оба часа обязаны попадать в 0..23 — иначе миграция не применится вовсе.
|
||||
|
||||
Регресс: первая версия давала start=23, end=24 у пяти строк. Проверка
|
||||
«end == start + 1» это пропускала, а прод-констрейнт `window_end_range`
|
||||
(052_scrape_schedules.sql: CHECK (window_end_hour BETWEEN 0 AND 23)) —
|
||||
нет, и весь INSERT падал в CI на реальном postgres.
|
||||
"""
|
||||
for provider, slug, start, end in _row_windows(_MIGRATION_262):
|
||||
assert 0 <= start <= 23, f"{provider}_{slug}: window_start_hour={start} вне 0..23"
|
||||
assert 0 <= end <= 23, f"{provider}_{slug}: window_end_hour={end} вне 0..23"
|
||||
|
||||
|
||||
def test_migration_262_windows_avoid_ekb_and_wave1_occupied_hours() -> None:
|
||||
"""Ни одна wave-2 строка не наезжает на занятые ЕКБ-окна (avito 6-7, cian 2-5,
|
||||
yandex 16-17) или на окна wave-1 (179_) — тот же provider."""
|
||||
for provider, slug, start, _end in _row_windows(_MIGRATION_262):
|
||||
occupied = _EKB_OCCUPIED[provider] | _WAVE1_OCCUPIED[provider]
|
||||
assert start not in occupied, (
|
||||
f"{provider}_city_sweep_{slug}: окно start={start} пересекает занятый "
|
||||
f"ЕКБ/wave-1 час ({sorted(occupied)})"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_262_same_provider_collisions_within_math_minimum() -> None:
|
||||
"""Коллизии ОДНОГО источника в одном окне не превышают математический минимум
|
||||
(round-robin по подтверждённым городам на свободные часы), и распределены
|
||||
равномерно (не сконцентрированы в 1-2 часах)."""
|
||||
from collections import Counter
|
||||
|
||||
rows = _row_windows(_MIGRATION_262)
|
||||
for provider in _PROVIDERS:
|
||||
hours = [start for p, _slug, start, _end in rows if p == provider]
|
||||
counts = Counter(hours)
|
||||
assert max(counts.values()) <= _MAX_COLLISIONS[provider], (
|
||||
f"{provider}: час {counts.most_common(1)} превышает математический минимум "
|
||||
f"коллизий ({_MAX_COLLISIONS[provider]})"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_262_cross_provider_overlap_allowed() -> None:
|
||||
"""Разные провайдеры МОГУТ делить окно в рамках этой миграции (не ограничивается) —
|
||||
документируем это явно, чтобы будущий рефактор не поломал предположение по ошибке."""
|
||||
rows = _row_windows(_MIGRATION_262)
|
||||
avito_hours = {start for p, _s, start, _e in rows if p == "avito"}
|
||||
cian_hours = {start for p, _s, start, _e in rows if p == "cian"}
|
||||
assert avito_hours and cian_hours
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
|
||||
"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846).
|
||||
|
||||
Покрывает:
|
||||
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
|
||||
- fresh: age <= max_age_days (алерта нет);
|
||||
- stale: age > max_age_days (алерт);
|
||||
- граница порога (== max_age_days → нет алерта; +1 день → алерт).
|
||||
2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag).
|
||||
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
|
||||
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
|
||||
1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте /
|
||||
загрузка встала / полных прогонов не было вовсе / граница порога.
|
||||
2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность:
|
||||
при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой.
|
||||
3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы
|
||||
отставшее табло).
|
||||
4. Свойства миграций 180/212 + регистрацию в kit product_handlers.
|
||||
|
||||
Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index):
|
||||
latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток),
|
||||
dinamika-tsen-obyavlenii = 2026-05-01 (103);
|
||||
последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639);
|
||||
такт загрузки interval_days = 7;
|
||||
прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,7 +29,6 @@ import pytest
|
|||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.product_handlers import build_product_handlers
|
||||
from app.tasks import sber_freshness_monitor as mon
|
||||
|
||||
|
|
@ -30,69 +36,152 @@ _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
|||
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
|
||||
_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql"
|
||||
|
||||
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
|
||||
_MAY_2026 = date(2026, 5, 1)
|
||||
|
||||
|
||||
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
|
||||
# Прод-состояние 2026-08-12.
|
||||
_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток
|
||||
_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103
|
||||
_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639
|
||||
_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days
|
||||
_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера
|
||||
|
||||
|
||||
def _now(y: int, m: int, d: int) -> datetime:
|
||||
return datetime(y, m, d, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_fresh_within_max_age() -> None:
|
||||
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
|
||||
assert v.stale is False
|
||||
assert v.age_days == 30
|
||||
assert v.latest_period == _MAY_2026
|
||||
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
|
||||
|
||||
|
||||
def test_stale_beyond_max_age() -> None:
|
||||
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
|
||||
assert v.stale is True
|
||||
def test_loader_in_cadence_no_alert_even_at_age_72() -> None:
|
||||
"""Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим.
|
||||
|
||||
После полного прогона наш max(period_month) равен максимуму источника ПО
|
||||
ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера,
|
||||
а не наше отставание.
|
||||
"""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=_LAST_FULL_PULL,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.age_days == 72
|
||||
assert v.pull_lag_days == 6
|
||||
assert v.stale is False
|
||||
|
||||
|
||||
def test_loader_stalled_alerts_at_the_same_age() -> None:
|
||||
"""Тот же возраст периода, но полный прогон 20 суток назад → тревога.
|
||||
|
||||
20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14.
|
||||
"""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=_now(2026, 7, 23),
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше
|
||||
assert v.pull_lag_days == 20
|
||||
assert v.max_pull_lag_days == 14
|
||||
assert v.stale is True
|
||||
|
||||
|
||||
def test_no_complete_pull_ever_alerts() -> None:
|
||||
"""Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=None,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.stale is True
|
||||
assert v.pull_lag_days == -1
|
||||
|
||||
|
||||
def test_threshold_boundary_exact_no_alert() -> None:
|
||||
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
|
||||
assert v.age_days == 60
|
||||
"""Ровно на пороге (2 такта) алерта ещё нет — строгое >."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL),
|
||||
last_complete_pull_at=_LAST_FULL_PULL,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.pull_lag_days == 14
|
||||
assert v.stale is False
|
||||
|
||||
|
||||
def test_threshold_boundary_next_day_alert() -> None:
|
||||
"""Порог + 1 день (age=61) — первый алерт."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
|
||||
assert v.age_days == 61
|
||||
def test_threshold_boundary_next_day_alerts() -> None:
|
||||
"""Порог + 1 сутки — первый алерт (два такта подряд пропущены)."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1),
|
||||
last_complete_pull_at=_LAST_FULL_PULL,
|
||||
pull_interval_days=_PULL_INTERVAL,
|
||||
)
|
||||
assert v.pull_lag_days == 15
|
||||
assert v.stale is True
|
||||
|
||||
|
||||
def test_threshold_follows_pull_cadence() -> None:
|
||||
"""Порог — производная такта загрузки, а не константа: такт 28 → порог 56."""
|
||||
v = mon.evaluate_sber_freshness(
|
||||
_JUN_2026,
|
||||
_PROD_NOW,
|
||||
last_complete_pull_at=_now(2026, 7, 23), # 20 суток
|
||||
pull_interval_days=28,
|
||||
)
|
||||
assert v.max_pull_lag_days == 56
|
||||
assert v.stale is False # при месячном такте 20 суток — норма
|
||||
|
||||
|
||||
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Row:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self.latest = latest
|
||||
def __init__(self, **kw: Any) -> None:
|
||||
self.__dict__.update(kw)
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self._latest = latest
|
||||
def __init__(self, row: _Row | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def first(self) -> _Row:
|
||||
return _Row(self._latest)
|
||||
def first(self) -> _Row | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self._latest = latest
|
||||
"""Отвечает на три запроса монитора; пригоден и для старой версии кода.
|
||||
|
||||
Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой
|
||||
запрос отдаём максимум по всем табло, ровно как это делал бы Postgres.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
latest_by_dash: dict[str, date],
|
||||
last_pull: datetime | None = _LAST_FULL_PULL,
|
||||
interval_days: str | None = str(_PULL_INTERVAL),
|
||||
) -> None:
|
||||
self._latest_by_dash = latest_by_dash
|
||||
self._last_pull = last_pull
|
||||
self._interval_days = interval_days
|
||||
self.rolled_back = False
|
||||
self.asked_dashboards: list[str] = []
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
return _FakeResult(self._latest)
|
||||
sql = str(stmt)
|
||||
params = params or {}
|
||||
if "scrape_runs" in sql:
|
||||
return _FakeResult(_Row(last_pull=self._last_pull))
|
||||
if "scrape_schedules" in sql:
|
||||
return _FakeResult(_Row(interval_days=self._interval_days))
|
||||
if "dash" in params:
|
||||
self.asked_dashboards.append(params["dash"])
|
||||
return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"])))
|
||||
# Старый монитор: max(period_month) по всей таблице.
|
||||
latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None
|
||||
return _FakeResult(_Row(latest=latest))
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
|
@ -118,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|||
return calls
|
||||
|
||||
|
||||
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
|
||||
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
|
||||
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
|
||||
assert calls["done"] == out
|
||||
assert calls["failed"] is None
|
||||
def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB:
|
||||
"""Прод-состояние 2026-08-12 (оба табло вторички)."""
|
||||
return _FakeDB(
|
||||
{"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026},
|
||||
last_pull=last_pull,
|
||||
)
|
||||
|
||||
|
||||
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_prod_replay_healthy_loader_silent_source_no_alert(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина.
|
||||
|
||||
На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки
|
||||
подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации
|
||||
Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же.
|
||||
"""
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# @2026-07-12 (прод): age=72 > 60 → алерт.
|
||||
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||
assert out["alert"] == 1
|
||||
db = _prod_db()
|
||||
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["age_days"] == 72
|
||||
assert out["latest_month"] == 5
|
||||
# Монитор НЕ падает при алерте — прогон done, а не failed.
|
||||
assert out["alert"] == 0
|
||||
assert calls["done"] == out
|
||||
assert calls["failed"] is None
|
||||
|
||||
|
||||
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой.
|
||||
|
||||
На main оба состояния дают alert=1 (вердикт зависит только от календаря) —
|
||||
сторож не умеет зеленеть, что и было исходным дефектом.
|
||||
"""
|
||||
_patch_runs(monkeypatch)
|
||||
healthy = mon.check_sber_freshness(
|
||||
_prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type]
|
||||
run_id=2,
|
||||
params={},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
stalled = mon.check_sber_freshness(
|
||||
_prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type]
|
||||
run_id=3,
|
||||
params={},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
assert healthy["age_days"] == stalled["age_days"] == 72
|
||||
assert (healthy["alert"], stalled["alert"]) == (0, 1)
|
||||
|
||||
|
||||
def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит.
|
||||
|
||||
Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт —
|
||||
SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь
|
||||
ровно то состояние, что дал бы прод с одним лишь id=37.
|
||||
"""
|
||||
_patch_runs(monkeypatch)
|
||||
out = mon.check_sber_freshness(
|
||||
_prod_db(last_pull=None), # type: ignore[arg-type]
|
||||
run_id=4,
|
||||
params={},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
assert out["alert"] == 1
|
||||
assert out["pull_lag_days"] == -1
|
||||
|
||||
|
||||
def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице)."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _prod_db()
|
||||
out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert db.asked_dashboards[0] == "real_estate_deals"
|
||||
assert (out["latest_year"], out["latest_month"]) == (2026, 6)
|
||||
|
||||
|
||||
def test_falls_back_to_next_dashboard_when_first_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Первое табло пусто → берём следующее, как и оценщик."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026})
|
||||
out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["latest_month"] == 5
|
||||
assert out["age_days"] == 103
|
||||
|
||||
|
||||
def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ни одного табло с данными — оценивать нечего, это сбой монитора."""
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(None)
|
||||
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||
out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["alert"] == 0
|
||||
assert calls["done"] is None
|
||||
assert calls["failed"] is not None
|
||||
|
||||
|
||||
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь.
|
||||
|
||||
Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180).
|
||||
С любым её значением вердикт один и тот же — порог берётся из такта загрузки.
|
||||
"""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
|
||||
out = mon.check_sber_freshness(
|
||||
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
|
||||
) # type: ignore[arg-type]
|
||||
assert out["alert"] == 1
|
||||
base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
tweaked = mon.check_sber_freshness(
|
||||
_prod_db(), # type: ignore[arg-type]
|
||||
run_id=9,
|
||||
params={"lag_allowance_days": 0},
|
||||
now=_PROD_NOW,
|
||||
)
|
||||
assert base["alert"] == tweaked["alert"] == 0
|
||||
|
||||
|
||||
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
|
||||
def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
|
||||
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
|
||||
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
|
||||
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
|
||||
assert out_exact["age_days"] == threshold
|
||||
assert out_exact["alert"] == 0
|
||||
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
|
||||
assert out_over["alert"] == 1
|
||||
db = _FakeDB(
|
||||
{"real_estate_deals": _JUN_2026},
|
||||
last_pull=_now(2026, 7, 23), # 20 суток
|
||||
interval_days="28",
|
||||
)
|
||||
out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["max_pull_lag_days"] == 56
|
||||
assert out["alert"] == 0
|
||||
|
||||
|
||||
def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None)
|
||||
out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type]
|
||||
assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS
|
||||
|
||||
|
||||
# ── Миграция 180 ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -208,22 +375,18 @@ def test_migration_180_window_9_to_10_utc() -> None:
|
|||
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
|
||||
|
||||
|
||||
def test_migration_180_lag_allowance_25() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert "lag_allowance_days" in sql
|
||||
assert "25" in sql
|
||||
|
||||
|
||||
def test_migration_180_no_psycopg_trap() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
# ── Миграция 212: такт загрузки не должен пересекать порог монитора ───────────
|
||||
# ── Миграция 212: такт загрузки = источник порога ─────────────────────────────
|
||||
#
|
||||
# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74
|
||||
# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника.
|
||||
# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора.
|
||||
# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят
|
||||
# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при
|
||||
# полном прогоне шестидневной давности, потолок 53 держался бы только если бы
|
||||
# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода
|
||||
# не должен расходиться с тактом, который сеет миграция.
|
||||
|
||||
|
||||
def test_migration_212_makes_pull_cadence_weekly() -> None:
|
||||
|
|
@ -234,23 +397,22 @@ def test_migration_212_makes_pull_cadence_weekly() -> None:
|
|||
assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type)
|
||||
|
||||
|
||||
def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None:
|
||||
"""Инвариант: пол возраста + такт загрузки < порога монитора.
|
||||
|
||||
Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог =
|
||||
sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 — запас
|
||||
7 суток. При прежних 28: 46+28=74 > 60 — тревога каждый цикл, что и наблюдали.
|
||||
"""
|
||||
def test_default_pull_interval_matches_migration_212() -> None:
|
||||
"""Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой."""
|
||||
interval_days = int(
|
||||
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1)
|
||||
)
|
||||
observed_floor_days = 46
|
||||
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
|
||||
assert observed_floor_days + interval_days < threshold, (
|
||||
f"такт {interval_days}д даёт потолок возраста "
|
||||
f"{observed_floor_days + interval_days}д при пороге {threshold}д — "
|
||||
"монитор снова будет мерить наш такт, а не застой источника"
|
||||
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr]
|
||||
)
|
||||
assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days
|
||||
|
||||
|
||||
# ── Один порог, а не два ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_second_calendar_threshold_in_settings() -> None:
|
||||
"""#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert not hasattr(settings, "sber_index_max_age_days")
|
||||
|
||||
|
||||
# ── Регистрация в kit registry ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
scrub_pii_event,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -259,3 +260,216 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None:
|
|||
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
|
||||
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
|
||||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
|
||||
|
||||
|
||||
# ── RetryError fingerprint stabilization (glitchtip-noise, #<GlitchTip triage>) ─
|
||||
#
|
||||
# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address
|
||||
# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в
|
||||
# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в
|
||||
# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина
|
||||
# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере).
|
||||
# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and-
|
||||
# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit —
|
||||
# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся
|
||||
# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError)
|
||||
# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные
|
||||
# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории
|
||||
# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ.
|
||||
|
||||
from tenacity import RetryError # noqa: E402
|
||||
|
||||
|
||||
def _hint_for(exc: BaseException) -> dict:
|
||||
"""Строит hint в форме, которую sentry_sdk реально передаёт в before_send —
|
||||
`exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint
|
||||
полагается именно на эту форму, не на уже сериализованный event dict)."""
|
||||
return {"exc_info": (type(exc), exc, exc.__traceback__)}
|
||||
|
||||
|
||||
def _raise_retry_error_from(cause: BaseException) -> RetryError:
|
||||
try:
|
||||
raise cause
|
||||
except type(cause) as caught:
|
||||
try:
|
||||
raise RetryError(None) from caught
|
||||
except RetryError as retry_exc:
|
||||
return retry_exc
|
||||
|
||||
|
||||
def test_stabilize_retry_error_sets_stable_fingerprint() -> None:
|
||||
"""RetryError коллапсится в persistent issue по (culprit, имени типа причины) —
|
||||
НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без
|
||||
`event["logger"]` (напр. capture_exception без LoggingIntegration) culprit
|
||||
падает на явный "unknown", а не пропадает из fingerprint молча."""
|
||||
exc = _raise_retry_error_from(TimeoutError("Nominatim timed out"))
|
||||
out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc))
|
||||
assert out is not None
|
||||
assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"]
|
||||
|
||||
|
||||
def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None:
|
||||
"""Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только
|
||||
culprit (logger-имя модуля) + фиксированное имя типа исключения-причины
|
||||
(маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...)."""
|
||||
exc = _raise_retry_error_from(
|
||||
ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345")
|
||||
)
|
||||
out = stabilize_retry_error_fingerprint({}, _hint_for(exc))
|
||||
assert out is not None
|
||||
fingerprint_text = " ".join(out["fingerprint"])
|
||||
assert "95.165.147.218" not in fingerprint_text
|
||||
assert "12345" not in fingerprint_text
|
||||
assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"]
|
||||
|
||||
|
||||
def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None:
|
||||
"""`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля,
|
||||
вызвавшего logger.exception/.error) идёт в fingerprint как culprit — стабильно
|
||||
per-модуль, не переменные данные запроса."""
|
||||
exc = _raise_retry_error_from(TimeoutError("timed out"))
|
||||
out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc))
|
||||
assert out is not None
|
||||
assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"]
|
||||
|
||||
|
||||
def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None:
|
||||
"""Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из
|
||||
НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один
|
||||
issue — разные проблемы не сливаются, даже если типы причины совпали."""
|
||||
exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out"))
|
||||
exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out"))
|
||||
out_geocoder = stabilize_retry_error_fingerprint(
|
||||
{"logger": "app.services.geocoder"}, _hint_for(exc_geocoder)
|
||||
)
|
||||
out_scraper = stabilize_retry_error_fingerprint(
|
||||
{"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper)
|
||||
)
|
||||
assert out_geocoder is not None
|
||||
assert out_scraper is not None
|
||||
assert out_geocoder["fingerprint"] != out_scraper["fingerprint"]
|
||||
|
||||
|
||||
class _DecoyRetryError(Exception):
|
||||
"""Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError —
|
||||
но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2
|
||||
claim #4) ложно матчило бы такое; isinstance — нет."""
|
||||
|
||||
|
||||
_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None:
|
||||
"""type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только
|
||||
реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук."""
|
||||
exc = _DecoyRetryError("unrelated exception, same class __name__ by accident")
|
||||
event = {"level": "error"}
|
||||
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
|
||||
assert out == event
|
||||
assert "fingerprint" not in out
|
||||
|
||||
|
||||
def test_stabilize_retry_error_leaves_operational_error_untouched() -> None:
|
||||
"""401-аналог задачи: OperationalError — содержательная категория (реальный
|
||||
сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с
|
||||
шумом)."""
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
exc = OperationalError("SELECT 1", {}, Exception("connection refused"))
|
||||
event = {"level": "error", "message": "db connection failed"}
|
||||
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
|
||||
assert out == event
|
||||
assert "fingerprint" not in out
|
||||
|
||||
|
||||
def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None:
|
||||
"""500-аналог задачи: обычное необработанное исключение (не RetryError)
|
||||
проходит без изменений."""
|
||||
exc = RuntimeError("scraper city-sweep failed")
|
||||
event = {"level": "error"}
|
||||
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
|
||||
assert out == event
|
||||
assert "fingerprint" not in out
|
||||
|
||||
|
||||
def test_stabilize_retry_error_no_exc_info_untouched() -> None:
|
||||
"""capture_message-based события (нет exc_info) — фильтр не трогает, напр.
|
||||
scrape_runs.py consecutive-failure алерты (content-ful, должны доходить)."""
|
||||
event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"}
|
||||
out = stabilize_retry_error_fingerprint(dict(event), {})
|
||||
assert out == event
|
||||
|
||||
|
||||
def test_stabilize_retry_error_handles_non_dict_event() -> None:
|
||||
assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ── httpx error-message URL query stabilization (glitchtip-noise review round 2,
|
||||
# claim #1) ────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) bakes
|
||||
# the FULL request URL — including query string — into the exception message.
|
||||
# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()):
|
||||
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
|
||||
# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py
|
||||
# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip
|
||||
# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же
|
||||
# per-address issue-explosion, который reraise=True должен был устранить, просто
|
||||
# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по
|
||||
# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send).
|
||||
|
||||
_NOMINATIM_403_TEMPLATE = (
|
||||
"Client error '403 Forbidden' for url "
|
||||
"'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n"
|
||||
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403"
|
||||
)
|
||||
|
||||
|
||||
def _httpx_error_event(message: str) -> dict:
|
||||
return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}}
|
||||
|
||||
|
||||
def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None:
|
||||
"""Query string режется целиком из httpx-style 'for url' сообщения — host+path
|
||||
остаются стабильными для группировки."""
|
||||
encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3"
|
||||
event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb))
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out is not None
|
||||
value = out["exception"]["values"][0]["value"]
|
||||
assert "search?[REDACTED]'" in value
|
||||
assert "%D0%95" not in value
|
||||
assert "nominatim.openstreetmap.org/search" in value # host+path сохранены
|
||||
|
||||
|
||||
def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None:
|
||||
"""Два РАЗНЫХ адреса (переменная часть query) после редактора дают
|
||||
ИДЕНТИЧНЫЙ текст сообщения — GlitchTip group-title больше не плодит issue
|
||||
на каждый адрес (review round 2 claim #1)."""
|
||||
event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1"))
|
||||
event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5"))
|
||||
out_a = scrub_pii_event(event_a, {})
|
||||
out_b = scrub_pii_event(event_b, {})
|
||||
assert out_a is not None
|
||||
assert out_b is not None
|
||||
assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"]
|
||||
|
||||
|
||||
def test_scrub_pii_event_httpx_url_without_query_untouched() -> None:
|
||||
"""URL без query string (напр. scraper detail page — переменная часть в
|
||||
ПУТИ, не в query) остаётся нетронутым — regex матчит только `?...`."""
|
||||
message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'"
|
||||
event = _httpx_error_event(message)
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out is not None
|
||||
assert out["exception"]["values"][0]["value"] == message
|
||||
|
||||
|
||||
def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None:
|
||||
"""Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и
|
||||
кавычками не должен ложно матчиться."""
|
||||
benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес"
|
||||
event = {"extra": {"note": benign}}
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out is not None
|
||||
assert out["extra"]["note"] == benign
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ def _history_rows(db) -> list[dict]:
|
|||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
сломалась, как только у функции появился второй execute перед вставкой истории.
|
||||
Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
|
|
@ -169,10 +169,9 @@ def test_all_invalid_area_returns_zero_no_crash() -> None:
|
|||
assert saved == 0
|
||||
# db.execute не должен вызываться для пустого rows (нет INSERT)
|
||||
assert _history_rows(db) == []
|
||||
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
|
||||
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
|
||||
# ли история по площади.
|
||||
assert db.commit.call_count == 2
|
||||
# Commit вызывается один раз (пустой батч истории), rollback — нет. Второй коммит
|
||||
# (запись houses.has_panorama) ушёл вместе с колонкой — хвост #2674, мигр. 259.
|
||||
assert db.commit.call_count == 1
|
||||
db.rollback.assert_not_called()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Unit tests for YandexValuationScraper — anonymous house-history scraper.
|
||||
|
||||
Fixture HTML simulates the Yandex valuation page body text containing:
|
||||
- House meta block (year, floors, type, ceiling, lift, total objects, panorama)
|
||||
- House meta block (year, floors, type, ceiling, lift, total objects)
|
||||
- 2-3 historical offer entries with full structure
|
||||
|
||||
Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг
|
||||
|
|
@ -78,7 +78,6 @@ def test_parse_house_meta_full():
|
|||
assert meta.ceiling_height == 2.50
|
||||
assert meta.has_lift is True
|
||||
assert meta.total_objects == 12
|
||||
assert meta.has_panorama is False
|
||||
|
||||
|
||||
def test_parse_house_meta_no_lift():
|
||||
|
|
@ -91,10 +90,16 @@ def test_parse_house_meta_no_lift():
|
|||
assert meta.ceiling_height == 3.0
|
||||
|
||||
|
||||
def test_parse_house_meta_with_panorama():
|
||||
text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание"
|
||||
def test_parse_house_meta_brick_with_lift():
|
||||
"""#2674 (хвост): фикстура больше не содержит «Панорама».
|
||||
|
||||
Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором;
|
||||
зелёный он был по построению. На настоящей странице оценки этого слова нет —
|
||||
проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов.
|
||||
Остаток теста осмысленный: кирпич + лифт на короткой мета-строке.
|
||||
"""
|
||||
text = "7 объектов Дом 2010 года Лифт Кирпичное здание"
|
||||
meta = YandexValuationScraper._parse_house_meta(text)
|
||||
assert meta.has_panorama is True
|
||||
assert meta.has_lift is True
|
||||
assert meta.house_type == "brick"
|
||||
|
||||
|
|
@ -275,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items():
|
|||
Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item.
|
||||
"""
|
||||
text = (
|
||||
"Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт "
|
||||
"Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт "
|
||||
"1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже "
|
||||
"2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ def _history_rows(db) -> list[dict]:
|
|||
"""Строки батча house_placement_history из мока сессии.
|
||||
|
||||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||||
сломалась, как только у функции появился второй execute (UPDATE houses.has_panorama
|
||||
перед вставкой истории). Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
сломалась, как только у функции появился второй execute перед вставкой истории.
|
||||
Фильтруем по SQL: тест переживёт любой новый вызов.
|
||||
"""
|
||||
for call in db.execute.call_args_list:
|
||||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
/**
|
||||
* SiteFooter — подвал. Серверный компонент.
|
||||
*
|
||||
* Что здесь честно ОТСУТСТВУЕТ:
|
||||
* - Реквизиты юрлица/ИП. В репозитории их нет (поиск по коду, бэкенду и
|
||||
* разметке не дал ни наименования, ни ИНН/ОГРН), а выдумывать реквизиты
|
||||
* оператора персональных данных на публичной странице нельзя. Блок
|
||||
* рендерится, как только `LEGAL_ENTITY` в content.ts перестанет быть null;
|
||||
* заполнить обязательно до открытия домена наружу — 152-ФЗ требует
|
||||
* идентифицируемого оператора.
|
||||
* - E-mail поддержки: реального адреса в коде тоже нет. Единственный
|
||||
* проверяемый канал — телеграм-бот из `v2/SupportChatContext.tsx`.
|
||||
* 2026-08-13: блоки реквизитов и почты, которых здесь раньше не было, теперь
|
||||
* рендерятся — данные появились (`LEGAL_ENTITY`, `SUPPORT_EMAIL` в content.ts),
|
||||
* и это не косметика: наименование, ИНН, ОГРН и работающий контакт на странице
|
||||
* с офертой — прямое требование модерации эквайера и условие идентифицируемости
|
||||
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же
|
||||
* набор рендерят оферта и политика ПДн, и разъехаться они не должны.
|
||||
*
|
||||
* Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не
|
||||
* по коротким meraocenka.ru/oferta: короткие адреса существуют только на
|
||||
* публичном хосте (rewrite в Caddy), а это же поддерево открывается и с
|
||||
* gendsgn.ru/trade-in/mera-public — там короткая ссылка ушла бы в 404.
|
||||
*
|
||||
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
|
||||
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
|
||||
|
|
@ -21,8 +23,11 @@ import { safeUrl } from "@/lib/safeUrl";
|
|||
|
||||
import {
|
||||
LEGAL_ENTITY,
|
||||
OFFER_PATH,
|
||||
PRIVACY_PATH,
|
||||
REFUND_PATH,
|
||||
REGION_NAME,
|
||||
SUPPORT_EMAIL,
|
||||
SUPPORT_TELEGRAM_LABEL,
|
||||
SUPPORT_TELEGRAM_URL,
|
||||
} from "../content";
|
||||
|
|
@ -49,6 +54,12 @@ export function SiteFooter() {
|
|||
|
||||
<div>
|
||||
<p className={styles.footerTitle}>Связаться</p>
|
||||
<p className={styles.footerText}>
|
||||
Почта:{" "}
|
||||
<a className={styles.link} href={`mailto:${SUPPORT_EMAIL}`}>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
{telegramHref ? (
|
||||
<p className={styles.footerText}>
|
||||
Поддержка в Telegram:{" "}
|
||||
|
|
@ -61,14 +72,22 @@ export function SiteFooter() {
|
|||
{SUPPORT_TELEGRAM_LABEL}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.footerText}>Контакты появятся к запуску.</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={styles.footerTitle}>Документы</p>
|
||||
<ul className={styles.footerLinks} role="list">
|
||||
<li>
|
||||
<Link className={styles.link} href={OFFER_PATH}>
|
||||
Публичная оферта
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className={styles.link} href={REFUND_PATH}>
|
||||
Политика возврата
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link className={styles.link} href={PRIVACY_PATH}>
|
||||
Обработка персональных данных
|
||||
|
|
@ -82,8 +101,8 @@ export function SiteFooter() {
|
|||
<span>© {year} МЕРА</span>
|
||||
{LEGAL_ENTITY && (
|
||||
<span>
|
||||
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn},{" "}
|
||||
{LEGAL_ENTITY.address}
|
||||
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "}
|
||||
{LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@
|
|||
* Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" → "/v2",
|
||||
* то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение
|
||||
* (и дальше на /login). Пока публичная страница одна, вордмарк — просто текст.
|
||||
*
|
||||
* ФОРМУЛИРОВКА ДЕСКРИПТОРА (14.08.2026, юр-документ владельца, блок 3). Строка
|
||||
* шапки обязана читаться как «МЕРА · Оценка вторичного жилья по рыночным данным ·
|
||||
* <регион>»: слова «по рыночным данным» — то, чем сервис отличается от оценки по
|
||||
* 135-ФЗ, и юрист попросил, чтобы это стояло в самом заметном дескрипторе, а не
|
||||
* только в дисклеймерах внизу. «МЕРА» приходит из вордмарка ниже (у него свой
|
||||
* letter-spacing), поэтому в headerTag её нет — строка складывается из двух узлов.
|
||||
* Регион — из REGION_NAME, не строкой (правило content.ts).
|
||||
*/
|
||||
|
||||
import { REGION_NAME } from "../content";
|
||||
|
|
@ -18,7 +26,7 @@ export function SiteHeader() {
|
|||
МЕРА
|
||||
</div>
|
||||
<p className={styles.headerTag}>
|
||||
Оценка вторичного жилья · {REGION_NAME}
|
||||
Оценка вторичного жилья по рыночным данным · {REGION_NAME}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* AccuracyV3 — «Точность»: KPI-плитки + таблица сверок прогноза с фактом
|
||||
* сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
|
||||
*
|
||||
* KPI-значения — из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это
|
||||
* плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения
|
||||
* замером (см. шапку файла).
|
||||
*
|
||||
* Таблица сверок — `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было
|
||||
* заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому
|
||||
* строки — витринный пример формата отчёта, а не факт. Ровно поэтому подпись
|
||||
* под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · …» — фраза,
|
||||
* прямо утверждающая, что за строками стоят настоящие обезличенные сделки.
|
||||
* Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись
|
||||
* честно называет таблицу иллюстрацией формата, а вторую половину исходной
|
||||
* фразы («"выше средней цены" — разница со средней ценой сделки по похожим
|
||||
* квартирам в районе») сохраняет — она объясняет, ЧТО значит колонка, а не
|
||||
* заявляет что-то о происхождении данных.
|
||||
*
|
||||
* Таблица размечена ARIA role="table/row/columnheader/cell" (а не <table>):
|
||||
* на мобильном брейке (см. landing-v3.module.css) она превращается в карточки
|
||||
* без горизонтального скролла, а implicit table-роли всё равно снимаются
|
||||
* Safari при смене display у настоящей <table> — тот же квирк, что уже описан
|
||||
* в HowItWorks.tsx для списков, только для таблиц его обходят явные роли, а
|
||||
* не CSS-костыль.
|
||||
*/
|
||||
|
||||
import {
|
||||
ACCURACY_PERIOD_LABEL,
|
||||
ACCURACY_PLACEHOLDER,
|
||||
PROOF_ROWS_PLACEHOLDER,
|
||||
} from "../../marketing-v3";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
const KPI_TILES = [
|
||||
{
|
||||
value: ACCURACY_PLACEHOLDER.priceError,
|
||||
label: "средняя ошибка по цене",
|
||||
note: ACCURACY_PLACEHOLDER.priceErrorNote,
|
||||
},
|
||||
{
|
||||
value: ACCURACY_PLACEHOLDER.daysError,
|
||||
label: "точность по сроку продажи",
|
||||
note: ACCURACY_PLACEHOLDER.daysErrorNote,
|
||||
},
|
||||
{
|
||||
value: ACCURACY_PLACEHOLDER.coverage,
|
||||
label: "сделок попали в диапазон",
|
||||
note: ACCURACY_PLACEHOLDER.coverageNote,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AccuracyV3() {
|
||||
return (
|
||||
<section
|
||||
id="accuracy"
|
||||
aria-labelledby="accuracy-v3-title"
|
||||
className={styles.accSection}
|
||||
>
|
||||
<div className={styles.accContainer}>
|
||||
<div className={styles.accHead}>
|
||||
<div className={styles.accEyebrow} aria-hidden="true">
|
||||
ПРОГНОЗ ПРОТИВ ФАКТА
|
||||
</div>
|
||||
<h2 id="accuracy-v3-title" className={styles.accTitle}>
|
||||
Мы сверяем каждый прогноз с реальной сделкой
|
||||
</h2>
|
||||
<p className={styles.accLead}>
|
||||
{`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className={styles.accKpiGrid} role="list">
|
||||
{KPI_TILES.map((tile) => (
|
||||
<li key={tile.label} className={styles.accKpiTile}>
|
||||
<div className={styles.accKpiValue}>{tile.value}</div>
|
||||
<div className={styles.accKpiLabel}>{tile.label}</div>
|
||||
<div className={styles.accKpiNote}>{tile.note}</div>
|
||||
</li>
|
||||
))}
|
||||
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
|
||||
<div className={styles.accKpiValue}>
|
||||
{ACCURACY_PLACEHOLDER.checks}
|
||||
</div>
|
||||
<div className={styles.accKpiLabel}>проверок за год</div>
|
||||
<div className={styles.accKpiNoteDark}>
|
||||
{ACCURACY_PLACEHOLDER.checksNote}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
className={styles.accTable}
|
||||
role="table"
|
||||
aria-label="Сверка прогноза «Меры» с фактом сделки"
|
||||
>
|
||||
<div className={styles.accTableHead} role="row">
|
||||
<span role="columnheader">Объект</span>
|
||||
<span role="columnheader">Мера сказала</span>
|
||||
<span role="columnheader">Факт сделки</span>
|
||||
<span role="columnheader">Расхождение</span>
|
||||
<span role="columnheader" className={styles.accTableHeadCellRight}>
|
||||
Выше средней цены по району
|
||||
</span>
|
||||
</div>
|
||||
{PROOF_ROWS_PLACEHOLDER.map((row) => (
|
||||
<div key={row.addr} className={styles.accRow} role="row">
|
||||
<div role="cell">
|
||||
<span className={styles.accObjectAddr}>{row.addr}</span>
|
||||
<span className={styles.accObjectMeta}>{row.meta}</span>
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellSaid}
|
||||
data-label="Мера сказала"
|
||||
>
|
||||
{row.said}
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellFact}
|
||||
data-label="Факт сделки"
|
||||
>
|
||||
{row.fact}
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellErr}
|
||||
data-label="Расхождение"
|
||||
>
|
||||
{row.err}
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellGain}
|
||||
data-label="Выше средней цены по району"
|
||||
>
|
||||
<div className={styles.accGainStack}>
|
||||
<div className={styles.accGainValue}>{row.gain}</div>
|
||||
<div className={styles.accGainPct}>{row.gainPct}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.accFootnote}>
|
||||
Таблица иллюстрирует формат отчёта, это не список конкретных сделок.
|
||||
«Выше средней цены» — разница со средней ценой сделки по похожим
|
||||
квартирам в районе.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73).
|
||||
*
|
||||
* Презентационный клиентский остров: сегодня публичный расчёт выключен
|
||||
* (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь
|
||||
* город никуда не отправляется — состояние живёт только в этом компоненте и
|
||||
* показывает посетителю, что сервис знает больше одного города области.
|
||||
* Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора
|
||||
* наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/
|
||||
* sticky-баром, самой формы здесь нет.
|
||||
*
|
||||
* Список городов и деление на «полное покрытие» / «данных меньше» берутся из
|
||||
* `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно
|
||||
* объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса»
|
||||
* по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет,
|
||||
* а гадать нельзя (правило честности в content.ts).
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
|
||||
|
||||
import { PRIMARY_CITY, REGION_NAME } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function CityPicker() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [city, setCity] = useState<string>(DEFAULT_CITY.label);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelId = useId();
|
||||
|
||||
// Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// Escape — закрыть и вернуть фокус на кнопку.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [open]);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filtered = normalizedQuery
|
||||
? OBLAST_CITIES.filter((c) =>
|
||||
c.label.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
: OBLAST_CITIES;
|
||||
|
||||
return (
|
||||
<div className={styles.hdrCityWrap} ref={wrapRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={styles.hdrCityBtn}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span className={styles.hdrCityDot} aria-hidden="true" />
|
||||
<span>{city}</span>
|
||||
<span className={styles.hdrCityCaret} aria-hidden="true">
|
||||
{open ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
id={panelId}
|
||||
className={styles.hdrCityPanel}
|
||||
aria-label="Выбор города"
|
||||
>
|
||||
<div className={styles.hdrCitySearchRow}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.hdrCityInput}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск города"
|
||||
aria-label="Поиск города"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hdrCityList}>
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={styles.hdrCityItem}
|
||||
data-active={c.label === city ? "true" : undefined}
|
||||
onClick={() => {
|
||||
setCity(c.label);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
buttonRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className={styles.hdrCityItemName}>{c.label}</span>
|
||||
<span className={styles.hdrCityItemNote}>
|
||||
{c.label === PRIMARY_CITY
|
||||
? "Полное покрытие"
|
||||
: "Данных меньше"}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className={styles.hdrCityEmpty}>
|
||||
Города нет в списке. Мы работаем там, где хватает данных по
|
||||
сделкам — напишите нам, и добавим.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.hdrCityFooter}>
|
||||
ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
|
||||
{REGION_NAME.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue